Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f87683a431 | |||
| 74e3754a80 | |||
| c2cf0656dd | |||
| efc0de230a | |||
| a5c2aa8e8f | |||
| a05b6b896d | |||
| e2522f111b |
@@ -1,6 +1,7 @@
|
||||
---
|
||||
name: pr-feedback-triage
|
||||
description: Triage pull request review comments into fixes, replies, clarification requests, or open follow-ups while respecting safe execution modes.
|
||||
allowed-tools: Bash(git:*), Bash(gh:*), mcp__github__*, Read, Grep, Glob, Edit, MultiEdit, Write
|
||||
---
|
||||
|
||||
# PR Feedback Triage
|
||||
@@ -43,9 +44,10 @@ When a mode disables an action, skip that destructive or externally visible acti
|
||||
|
||||
Gather the complete feedback set before editing:
|
||||
|
||||
- Fetch unresolved review threads, requested-change reviews, PR-level summary comments, and copied comments.
|
||||
- Use platform-native APIs/CLI when available. Paginate results; do not inspect only the first page of threads or comments.
|
||||
- For bot reviewers that post both summary comments and inline comments, collect both. Summary comments often contain severity, rationale, and fix instructions; inline comments contain the exact file and line context.
|
||||
- Fetch unresolved review threads, requested-change reviews, inline comments, copied comments, and PR-level summary comments.
|
||||
- Use whichever authenticated GitHub-capable interface is available and reliable. This skill explicitly permits both `gh` and GitHub MCP tools; paginate results and do not inspect only the first page of threads or comments.
|
||||
- For bot reviewers that post both summary comments and inline comments, prefer inline comments for actionable triage. Incorporate summary findings only when they contain distinct severity, rationale, or fix instructions not already captured from inline comments.
|
||||
- Summary comments may be excluded from triage when they do not add distinct actionable context.
|
||||
- Preserve every thread/comment identifier needed to reply or resolve later.
|
||||
- Compare each comment with the current diff and file contents because review lines can become outdated.
|
||||
|
||||
@@ -56,7 +58,7 @@ Build one triage record per distinct finding:
|
||||
- Prefer exact review-thread identity when available.
|
||||
- For duplicate bot findings appearing in both summary and inline comments, merge by exact issue title first, then by file path plus line range as a fallback.
|
||||
- Prefer inline comments for location and current code context.
|
||||
- Prefer summary comments for severity, category, rationale, and detailed agent prompts.
|
||||
- Use summary comments only for distinct severity, category, rationale, or detailed agent prompts that are not already available from inline comments.
|
||||
- Preserve the reviewer’s exact issue title and original wording where practical. Do not rename findings in a way that would make replies hard to map back to comments.
|
||||
- Preserve the reviewer’s original ordering unless the user asks for priority reordering. Many review bots already order findings by severity.
|
||||
|
||||
@@ -70,13 +72,20 @@ Keep a thread open only when it still needs reviewer, maintainer, or product inp
|
||||
|
||||
When resolving a thread, add a concise reply first only if it provides useful context, such as what changed, why no code change was needed, why a finding was intentionally deferred, or why the original comment is now outdated. Do not add noisy replies for self-evident fixes unless project norms require them.
|
||||
|
||||
## Platform Comment Style
|
||||
|
||||
- Keep every posted reply or comment brief: one sentence by default, two short sentences only when necessary.
|
||||
- Do not post PR-level summary or status comments by default. Omit them when they only restate completed fixes, resolved threads, or verification already visible in commits/checks.
|
||||
- Avoid templates, long bullet lists, exhaustive status logs, and duplicated explanations in platform comments.
|
||||
- For simple fixes, already-addressed findings, or outdated findings, prefer `resolve_only` over adding a reply.
|
||||
|
||||
## Platform Action Contract
|
||||
|
||||
Do not treat triage as complete until every collected source ID reaches an explicit terminal state:
|
||||
Do not treat triage as complete until every incorporated source ID reaches an explicit terminal state:
|
||||
|
||||
- `resolved`: a platform resolve action succeeded, or a re-check shows the thread is already resolved.
|
||||
- `replied_left_open`: a reply or question was posted and the thread is intentionally left unresolved.
|
||||
- `not_resolvable`: the source is a PR-level summary comment or copied comment that has no platform-level resolve action; reply or post a PR summary when useful.
|
||||
- `not_resolvable`: the source is a PR-level summary comment or copied comment that has no platform-level resolve action; post a brief reply only when useful.
|
||||
- `skipped_by_mode`: `dry_run`, `no_push`, or `no_reply` prevented the external action.
|
||||
- `failed_action`: a reply or resolve action was attempted and failed; include the attempted action and failure in the final summary.
|
||||
|
||||
@@ -85,13 +94,13 @@ In normal mode, build and execute a platform action queue after fixes are verifi
|
||||
- `reply_then_resolve`: use for handled threads where the reviewer needs context before resolution.
|
||||
- `resolve_only`: use for self-evident fixes and already-addressed or outdated threads where an extra reply would add noise.
|
||||
- `reply_leave_open`: use only for clarification requests, blocked work, or intentionally open follow-ups.
|
||||
- `reply_only`: use for PR-level comments or summaries that cannot be resolved as review threads.
|
||||
- `reply_only`: use for PR-level comments or summaries that cannot be resolved, only when a short reply adds value.
|
||||
|
||||
For duplicate findings, execute the terminal action for every source thread ID, not only the primary triage record. If one finding is represented by three unresolved inline threads, all three must be resolved or explicitly left open.
|
||||
|
||||
## GitHub Action Guidance
|
||||
|
||||
Prefer platform-native APIs or `gh` commands that expose review-thread resolution state. For GitHub inline review threads, use the thread node ID and the GraphQL `resolveReviewThread` mutation rather than assuming that a reply resolves the conversation.
|
||||
Use whichever authenticated GitHub-capable interface is available and reliable, preferably `gh` or GitHub MCP. Prefer interfaces that expose review-thread resolution state. For GitHub inline review threads, use the thread node ID and the GraphQL `resolveReviewThread` mutation, or an equivalent GitHub MCP resolve-thread tool, rather than assuming that a reply resolves the conversation.
|
||||
|
||||
A reliable pattern is:
|
||||
|
||||
@@ -150,9 +159,9 @@ flowchart TD
|
||||
## Compact Workflow
|
||||
|
||||
1. **Collect all relevant feedback**
|
||||
- Identify the PR and gather unresolved review threads, requested-change reviews, PR-level summaries, inline comments, and copied comments.
|
||||
- Identify the PR and gather unresolved review threads, requested-change reviews, inline comments, copied comments, and PR-level summaries.
|
||||
- Paginate all platform calls and keep comment/thread IDs for later replies and resolution.
|
||||
- For bot reviews, collect both summary and inline comments, then merge duplicates rather than fixing the same finding twice.
|
||||
- For bot reviews, prioritize inline comments and incorporate summary findings only when they add distinct actionable context.
|
||||
|
||||
2. **Classify each triage record**
|
||||
- **Fix**: Valid requested change; make the smallest focused edit when not in `dry_run`.
|
||||
@@ -168,7 +177,7 @@ flowchart TD
|
||||
- In `dry_run`, stop at triage, proposed fixes, suggested replies, and verification plan.
|
||||
- In `no_push`, local edits are allowed, but do not push or resolve threads whose fix is only local. Reply or resolve non-code, already-addressed, or outdated threads only when the action does not depend on unpushed work and `no_reply` is not set.
|
||||
- In `no_reply`, do not post replies or resolve threads; report suggested replies/actions instead.
|
||||
- In normal mode, commit and push changed code when appropriate, then execute the platform action queue for every collected source ID.
|
||||
- In normal mode, commit and push changed code when appropriate, then execute the platform action queue for every incorporated source ID.
|
||||
|
||||
4. **Verify before claiming completion**
|
||||
- For fixes, run appropriate checks or explain why they could not run.
|
||||
@@ -178,15 +187,16 @@ flowchart TD
|
||||
- If a resolve or reply operation fails, retry once when safe; then report `failed_action` with the affected source ID and reason.
|
||||
|
||||
5. **Finish**
|
||||
- Normal mode: commit/push changes when appropriate, post useful replies or a summary, resolve all handled threads by default, and reconcile the final unresolved set.
|
||||
- Normal mode: commit/push changes when appropriate, post only useful short replies/comments, omit PR-level summaries when they add no value, resolve all handled threads by default, and reconcile the final unresolved set.
|
||||
- Safe modes: report the local state and the exact replies/resolution actions a human could take.
|
||||
|
||||
## Reply Guidance
|
||||
|
||||
- Keep inline replies short and tied to the original title or concern.
|
||||
- For fixed findings, mention the concrete change or commit if useful.
|
||||
- For already-addressed or outdated findings, cite the current code path or behavior that makes the finding no longer applicable.
|
||||
- Keep inline replies short: one sentence by default, two short sentences only when needed.
|
||||
- For fixed findings, mention the concrete change or commit only if it helps the reviewer.
|
||||
- For already-addressed or outdated findings, cite the current code path or behavior only as briefly as needed.
|
||||
- For deferred or won't-fix findings, provide the reason and any follow-up issue or owner if known.
|
||||
- Avoid posting PR-level summary comments unless they communicate a decision, blocker, or requested reviewer action.
|
||||
- If a reply or resolve operation fails, continue with the remaining threads and report the failure in the final summary.
|
||||
|
||||
## Final Summary Checklist
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: Pull request review and mention bot using OpenCode
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- ready_for_review
|
||||
issue_comment:
|
||||
types:
|
||||
- created
|
||||
pull_request_review_comment:
|
||||
types:
|
||||
- created
|
||||
pull_request_review:
|
||||
types:
|
||||
- submitted
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
- assigned
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
opencode-review:
|
||||
if: >
|
||||
github.event_name == 'pull_request'
|
||||
&& contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association)
|
||||
&& (! github.event.pull_request.draft)
|
||||
&& (! startsWith(github.head_ref, 'dependabot/'))
|
||||
&& (! startsWith(github.head_ref, 'renovate/'))
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
actions: read
|
||||
uses: dceoy/gh-actions-for-devops/.github/workflows/opencode-review.yml@main # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
model: opencode-go/kimi-k2.7-code
|
||||
secrets:
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
opencode-bot:
|
||||
if: >
|
||||
(
|
||||
(github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
|
||||
&& contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
|
||||
&& (contains(github.event.comment.body, '/oc') || contains(github.event.comment.body, '/opencode'))
|
||||
) || (
|
||||
github.event_name == 'pull_request_review'
|
||||
&& contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.review.author_association)
|
||||
&& (contains(github.event.review.body, '/oc') || contains(github.event.review.body, '/opencode'))
|
||||
) || (
|
||||
github.event_name == 'issues'
|
||||
&& contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association)
|
||||
&& (
|
||||
(contains(github.event.issue.body, '/oc') || contains(github.event.issue.title, '/oc'))
|
||||
|| (contains(github.event.issue.body, '/opencode') || contains(github.event.issue.title, '/opencode'))
|
||||
)
|
||||
)
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
actions: read
|
||||
uses: dceoy/gh-actions-for-devops/.github/workflows/opencode-bot.yml@main # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
model: opencode-go/glm-5.2
|
||||
secrets:
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
@@ -144,9 +144,10 @@ mt5cli -o ticks.json ticks-from --symbol EURUSD \
|
||||
# Export symbols to SQLite3 with custom table name
|
||||
mt5cli -o data.db --table symbols symbols --group "*USD*"
|
||||
|
||||
# Export with connection credentials
|
||||
mt5cli --login 12345 --password mypass --server MyBroker-Demo \
|
||||
-o positions.csv positions
|
||||
# Export with connection credentials from env or placeholders
|
||||
MT5_LOGIN=12345 MT5_PASSWORD=secret MT5_SERVER=MyBroker-Demo \
|
||||
mt5cli -o positions.csv positions
|
||||
MT5_PATH="/path/to/terminal64.exe" mt5cli -o positions.csv positions
|
||||
```
|
||||
|
||||
Run as a Python module:
|
||||
@@ -157,40 +158,54 @@ python -m mt5cli -o account.csv account-info
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `rates-from` | Export rates from a start date |
|
||||
| `rates-from-pos` | Export rates from a start position |
|
||||
| `latest-rates` | Export latest rates from a start position |
|
||||
| `rates-range` | Export rates for a date range |
|
||||
| `ticks-from` | Export ticks from a start date |
|
||||
| `ticks-range` | Export ticks for a date range |
|
||||
| `ticks-recent` | Export ticks from a recent trailing window |
|
||||
| `account-info` | Export account information |
|
||||
| `terminal-info` | Export terminal information |
|
||||
| `version` | Export MetaTrader 5 version information |
|
||||
| `last-error` | Export the last error information |
|
||||
| `symbols` | Export symbol list |
|
||||
| `symbol-info` | Export symbol details |
|
||||
| `symbol-info-tick` | Export the last tick for a symbol |
|
||||
| `minimum-margins` | Export minimum-volume buy and sell margin requirements |
|
||||
| `market-book` | Export market depth (order book) |
|
||||
| `orders` | Export active orders |
|
||||
| `positions` | Export open positions |
|
||||
| `history-orders` | Export historical orders |
|
||||
| `history-deals` | Export historical deals |
|
||||
| `recent-history-deals` | Export historical deals from a recent trailing window |
|
||||
| `mt5-summary` | Export terminal/account status summary |
|
||||
| `order-check` | Check funds sufficiency for a trade request |
|
||||
| `order-send` | Send a raw trade request to the trade server (`--yes` required; expert path) |
|
||||
| `close-positions` | Close open positions by `--symbol` or `--ticket` (`--yes` required for live; `--dry-run` available) |
|
||||
| `collect-history` | Collect rates, history-orders, and history-deals for one or more symbols into a single SQLite database (ticks opt-in via `--dataset ticks`) |
|
||||
| `grafana-schema` | Create or refresh Grafana-ready views and indexes in an existing SQLite database (idempotent, no MT5 connection) |
|
||||
| `snapshot` | Snapshot current account, position, order, and terminal state into SQLite for live Grafana dashboards |
|
||||
| Command | Description |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `rates-from` | Export rates from a start date |
|
||||
| `rates-from-pos` | Export rates from a start position |
|
||||
| `latest-rates` | Export latest rates from a start position |
|
||||
| `rates-range` | Export rates for a date range |
|
||||
| `ticks-from` | Export ticks from a start date |
|
||||
| `ticks-range` | Export ticks for a date range |
|
||||
| `ticks-recent` | Export ticks from a recent trailing window |
|
||||
| `account-info` | Export account information |
|
||||
| `terminal-info` | Export terminal information |
|
||||
| `version` | Export MetaTrader 5 version information |
|
||||
| `last-error` | Export the last error information |
|
||||
| `symbols` | Export symbol list |
|
||||
| `symbol-info` | Export symbol details |
|
||||
| `symbol-info-tick` | Export the last tick for a symbol |
|
||||
| `minimum-margins` | Export minimum-volume buy and sell margin requirements |
|
||||
| `market-book` | Export market depth (order book) |
|
||||
| `orders` | Export active orders |
|
||||
| `positions` | Export open positions |
|
||||
| `history-orders` | Export historical orders |
|
||||
| `history-deals` | Export historical deals |
|
||||
| `recent-history-deals` | Export historical deals from a recent trailing window |
|
||||
| `mt5-summary` | Export terminal/account status summary |
|
||||
| `order-check` | Check funds sufficiency for a trade request |
|
||||
| `order-send` | Send a raw trade request to the trade server (`--yes` required; expert path) |
|
||||
| `close-positions` | Close open positions by `--symbol` or `--ticket` (`--yes` required for live; `--dry-run` available; optional `--deviation` / `--comment` / `--magic`) |
|
||||
| `collect-history` | Collect rates, history-orders, and history-deals for one or more symbols into a single SQLite database (ticks opt-in via `--dataset ticks`) |
|
||||
| `history-gaps` | Export a SQLite-only one-row-per-gap report from managed rate compatibility views without connecting to MT5 |
|
||||
| `grafana-schema` | Create or refresh Grafana-ready views and indexes in an existing SQLite database (idempotent, no MT5 connection) |
|
||||
| `snapshot` | Snapshot current account, position, order, and terminal state into SQLite for live Grafana dashboards |
|
||||
|
||||
Use `order-check` to validate a request payload before running `order-send --yes`.
|
||||
`close-positions` is the safer high-level alternative that builds correct close
|
||||
requests automatically. At least one `--symbol` or `--ticket` must be provided.
|
||||
CLI connection flags fall back to `MT5_LOGIN`, `MT5_PASSWORD`, `MT5_SERVER`,
|
||||
and `MT5_PATH` when unset, and explicit CLI values still win.
|
||||
|
||||
### `history-gaps`
|
||||
|
||||
Inspect collected SQLite rate views offline and export one row per detected gap.
|
||||
For managed compatibility views, the command infers bar spacing from the view
|
||||
name. Use `--granularity-seconds` for custom tables or views.
|
||||
|
||||
```bash
|
||||
mt5cli -o gaps.json history-gaps --sqlite3 history.db
|
||||
mt5cli -o eurusd.csv history-gaps --sqlite3 history.db --table rate_EURUSD__M1_1
|
||||
```
|
||||
|
||||
### `collect-history`
|
||||
|
||||
@@ -302,7 +317,11 @@ WHERE run_id = (SELECT MAX(run_id) FROM snapshot_runs WHERE status = 'ok');
|
||||
SELECT symbol, total_profit FROM grafana_trade_stats ORDER BY total_profit DESC;
|
||||
```
|
||||
|
||||
> **Note**: OpenTelemetry integration is intentionally not part of this release and is tracked separately.
|
||||
#### Grafana and telemetry API docs
|
||||
|
||||
The shipped Grafana helpers are documented in [`docs/api/grafana.md`](docs/api/grafana.md), including `publish_grafana_copy()` for creating a WAL-safe published SQLite copy for Grafana.
|
||||
|
||||
OpenTelemetry metrics are documented in [`docs/api/telemetry.md`](docs/api/telemetry.md), including `enable_otel_metrics()`, `configure_metrics()`, the `mt5cli[otel]` extra, and `OTEL_EXPORTER_OTLP_ENDPOINT`.
|
||||
|
||||
### Incremental history SDK
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Grafana
|
||||
|
||||
::: mt5cli.grafana
|
||||
|
||||
## Grafana-ready SQLite workflow
|
||||
|
||||
Use `ensure_grafana_schema(conn)` or the `grafana-schema` CLI command to create
|
||||
the snapshot tables, `grafana_*` views, and supporting indexes in one step.
|
||||
|
||||
`publish_grafana_copy(source, target)` creates a consistent SQLite copy via the
|
||||
SQLite backup API, which is useful when the primary database is running in WAL
|
||||
mode and Grafana should read from a separate published file.
|
||||
|
||||
## Main APIs
|
||||
|
||||
- `ensure_grafana_schema()`: idempotently creates snapshot tables, Grafana
|
||||
views, and indexes.
|
||||
- `create_grafana_views()`: rebuilds the shipped `grafana_*` views.
|
||||
- `create_grafana_indexes()`: creates read-oriented indexes for Grafana
|
||||
queries.
|
||||
- `publish_grafana_copy()`: writes an atomic, WAL-safe published copy for a
|
||||
Grafana datasource.
|
||||
|
||||
For end-to-end snapshot collection examples, see the Grafana and observability
|
||||
section in the project `README.md`.
|
||||
@@ -18,6 +18,8 @@ responsibilities.
|
||||
| [SDK](sdk.md) | Module-level fetch helpers, multi-account collectors, incremental history |
|
||||
| [Trading](trading.md) | Trading-capable sessions and operational helpers |
|
||||
| [History Collection (SQLite)](history.md) | SQLite schema, incremental writes, dedup, and rate views |
|
||||
| [Telemetry](telemetry.md) | OpenTelemetry metrics setup, meters, and emitted metric names |
|
||||
| [Grafana](grafana.md) | Grafana-ready SQLite schema, views, snapshots, and published copies |
|
||||
| [CLI](cli.md) | Typer commands that delegate to the Python API |
|
||||
| [Utils](utils.md) | Parsing helpers and Click parameter types |
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ Downstream code should import raw pdmt5 types and constants (such as
|
||||
from `pdmt5` when needed. mt5cli does not serve as a pass-through compatibility
|
||||
namespace for pdmt5. mt5cli's trading helpers type their client parameter against
|
||||
an internal protocol backed by `pdmt5.Mt5DataClient`; `Mt5TradingClient` is no
|
||||
longer required. `Mt5TradingError` is conditionally imported where still present
|
||||
in pdmt5, but mt5cli raises `Mt5OperationError` for all trading-related failures.
|
||||
longer required. `pdmt5.Mt5TradingError` was removed upstream in pdmt5 1.0.4;
|
||||
mt5cli raises `Mt5OperationError` for all trading-related failures.
|
||||
|
||||
Note: the former `mt5cli` re-export `TICK_FLAG_MAP` corresponds to `COPY_TICKS_MAP`
|
||||
in pdmt5 — the name changed, it was not simply moved.
|
||||
@@ -70,6 +70,7 @@ timestamp normalization in downstream apps.
|
||||
| Symbol | Role |
|
||||
| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `collect_history` | One-shot date-range export into SQLite |
|
||||
| `report_rate_gaps` | SQLite-only one-row-per-gap report for a rate table or compatibility view |
|
||||
| `update_history`, `update_history_with_config` | Incremental append from `MAX(time)` cursors |
|
||||
| `ThrottledHistoryUpdater` | Minimum interval between successful incremental updates; optional `update_backend` injection |
|
||||
| `RateTarget`, `build_rate_targets` | Neutral `(symbol, timeframe)` series descriptors |
|
||||
@@ -98,6 +99,7 @@ strategy entries, exits, Kelly sizing, or signal logic.
|
||||
| `calculate_symbol_group_margin_ratio` | Estimated symbol-group margin/equity with optional exposure |
|
||||
| `determine_order_limits` | SL/TP price levels from ratios |
|
||||
| `calculate_trailing_stop_updates` | Per-ticket generic trailing stop-loss update plan |
|
||||
| `resolve_broker_filling_mode` | Broker-supported filling-mode selection helper |
|
||||
| `ensure_symbol_selected` | Select/verify Market Watch visibility |
|
||||
| `fetch_recent_history_deals_for_trading_client` | Recent deal history from a connected trading client |
|
||||
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions`, `update_trailing_stop_loss_for_open_positions` | Order execution helpers (`dry_run` supported) |
|
||||
@@ -211,6 +213,9 @@ The Typer application in `mt5cli.cli` exposes file-export commands documented in
|
||||
- Require `-o/--output` and write CSV, JSON, Parquet, or SQLite.
|
||||
- Accept global MT5 connection options (`--login`, `--password`, `--server`,
|
||||
`--path`, `--timeout`).
|
||||
- Resolve unset CLI connection options from `MT5_LOGIN`, `MT5_PASSWORD`,
|
||||
`MT5_SERVER`, and `MT5_PATH`, and expand `${ENV_VAR}` placeholders in CLI
|
||||
string fields before building the MT5 config.
|
||||
- Delegate to the same Python APIs described here; they are not duplicated
|
||||
business logic.
|
||||
|
||||
@@ -228,7 +233,14 @@ constructed request payload. `close-positions` is the safer high-level helper
|
||||
that closes open positions by `--symbol` or `--ticket` using
|
||||
`close_open_positions()`. Both `order-send --yes` and `close-positions --yes`
|
||||
are live execution paths. `close-positions --dry-run` previews close orders
|
||||
without placing them and does not require `--yes`.
|
||||
without placing them and does not require `--yes`. `close-positions` also
|
||||
accepts optional `--deviation`, `--comment`, and `--magic`; `--magic` scopes
|
||||
the selected open positions fail-closed when position magic metadata is absent.
|
||||
|
||||
`history-gaps` reads an existing SQLite history database and exports one row
|
||||
per detected gap from managed rate compatibility views. It never initializes
|
||||
MT5. Pass `--granularity-seconds` for custom tables or views whose bar spacing
|
||||
cannot be inferred from the name.
|
||||
|
||||
## Internal helpers (not stable)
|
||||
|
||||
|
||||
+5
-4
@@ -12,8 +12,8 @@ application.
|
||||
|
||||
`collect_latest_rates_for_accounts_with_retries()` wraps
|
||||
`collect_latest_rates_for_accounts()` with bounded exponential backoff. Only
|
||||
`pdmt5.Mt5TradingError` and `pdmt5.Mt5RuntimeError` are retried; the final
|
||||
failure is re-raised once `retry_count` is exhausted.
|
||||
`pdmt5.Mt5RuntimeError` is retried; the final failure is re-raised once
|
||||
`retry_count` is exhausted.
|
||||
|
||||
```python
|
||||
from mt5cli import AccountSpec, collect_latest_rates_for_accounts_with_retries
|
||||
@@ -32,8 +32,9 @@ rates = collect_latest_rates_for_accounts_with_retries(
|
||||
|
||||
MetaTrader 5 `start_pos=0` includes the still-forming current bar as the last
|
||||
row. `fetch_latest_closed_rates()` handles one connected `MT5Client`; use
|
||||
`fetch_latest_closed_rates_for_trading_client()` from an active
|
||||
`Mt5TradingClient` session. Multi-account helpers fetch `count + 1` bars, drop
|
||||
`fetch_latest_closed_rates_for_trading_client()` from an active trading
|
||||
session created by `create_trading_client()`. Multi-account helpers fetch
|
||||
`count + 1` bars, drop
|
||||
that row with `drop_forming_rate_bar()`, and validate each series is non-empty. Returned frames are ordered
|
||||
oldest-to-newest and may contain fewer than `count` rows only when MT5 returns
|
||||
fewer closed bars.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Telemetry
|
||||
|
||||
::: mt5cli.telemetry
|
||||
|
||||
## Enabling OpenTelemetry metrics
|
||||
|
||||
Install the optional exporter dependencies with:
|
||||
|
||||
```bash
|
||||
uv add 'mt5cli[otel]'
|
||||
```
|
||||
|
||||
Then enable the default OTLP HTTP pipeline:
|
||||
|
||||
```python
|
||||
from mt5cli.telemetry import enable_otel_metrics
|
||||
|
||||
enable_otel_metrics(service_name="mt5cli")
|
||||
```
|
||||
|
||||
When `readers=None`, `enable_otel_metrics()` builds a
|
||||
`PeriodicExportingMetricReader` backed by the OTLP HTTP exporter and reads the
|
||||
endpoint from `OTEL_EXPORTER_OTLP_ENDPOINT`.
|
||||
|
||||
If your application already owns an OpenTelemetry `Meter`, wire mt5cli into it
|
||||
directly with `configure_metrics(meter)`.
|
||||
|
||||
## Emitted metric names
|
||||
|
||||
`enable_otel_metrics()` / `configure_metrics()` register these instruments:
|
||||
|
||||
- `mt5_history_update_duration_seconds`
|
||||
- `mt5_history_update_rows_total`
|
||||
- `mt5_history_update_failures_total`
|
||||
- `mt5_snapshot_update_duration_seconds`
|
||||
- `mt5_snapshot_update_failures_total`
|
||||
- `mt5_account_balance`
|
||||
- `mt5_account_equity`
|
||||
- `mt5_account_margin`
|
||||
- `mt5_account_margin_free`
|
||||
- `mt5_account_margin_level`
|
||||
- `mt5_position_profit`
|
||||
- `mt5_position_volume`
|
||||
- `mt5_terminal_connected`
|
||||
- `mt5_terminal_trade_allowed`
|
||||
- `mt5_terminal_trade_expert`
|
||||
- `mt5_last_successful_update_timestamp`
|
||||
|
||||
The history metrics use a `dataset` attribute. Account and position gauges add
|
||||
labels such as `login`, `server`, and `symbol` where applicable.
|
||||
+10
-7
@@ -109,8 +109,10 @@ mt5cli -o ticks.json ticks-from --symbol EURUSD \
|
||||
# Export symbols to SQLite3 with custom table name
|
||||
mt5cli -o data.db --table symbols symbols --group "*USD*"
|
||||
|
||||
# Export with connection credentials
|
||||
mt5cli --login 12345 --password mypass --server MyBroker-Demo \
|
||||
# Export with connection credentials from env or placeholders
|
||||
MT5_LOGIN=12345 MT5_PASSWORD=secret MT5_SERVER=MyBroker-Demo \
|
||||
mt5cli -o positions.csv positions
|
||||
mt5cli --login '${MT5_LOGIN}' --password '${MT5_PASSWORD}' --server '${MT5_SERVER}' \
|
||||
-o positions.csv positions
|
||||
```
|
||||
|
||||
@@ -164,10 +166,10 @@ mt5cli --login 12345 --password mypass --server MyBroker-Demo \
|
||||
These commands send requests to the live trade server and can place or close
|
||||
real trades. Both require `--yes` for live execution.
|
||||
|
||||
| Command | Description |
|
||||
| ----------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `order-send` | Send a **raw** trade request directly to MT5 (`--yes` required; expert path — no extra validation) |
|
||||
| `close-positions` | Close open positions by `--symbol` or `--ticket` (`--yes` required for live; `--dry-run` to preview) |
|
||||
| Command | Description |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `order-send` | Send a **raw** trade request directly to MT5 (`--yes` required; expert path — no extra validation) |
|
||||
| `close-positions` | Close open positions by `--symbol` or `--ticket` (`--yes` required for live; `--dry-run` to preview; optional `--deviation` / `--comment` / `--magic`) |
|
||||
|
||||
Use `order-check` (Trading State) to validate funds before running `order-send --yes`.
|
||||
`close-positions` is the safer high-level alternative that builds correct close
|
||||
@@ -179,6 +181,7 @@ applications should prefer dedicated closing helpers or their own risk controls.
|
||||
| Command | Description |
|
||||
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `collect-history` | Collect rates, history-orders, and history-deals (ticks opt-in via `--dataset ticks`) for one or more symbols into a single SQLite database (optional cash-event/position views) |
|
||||
| `history-gaps` | Export a SQLite-only one-row-per-gap report from managed rate compatibility views without connecting to MT5 |
|
||||
|
||||
```bash
|
||||
mt5cli -o history.db collect-history \
|
||||
@@ -213,7 +216,7 @@ See the [History schema diagram](api/history.md#entity-relationship-diagram) for
|
||||
| `-f, --format` | Output format (auto-detected from extension if omitted) |
|
||||
| `--table` | Table name for SQLite3 output (default: "data") |
|
||||
| `--login` | Trading account login |
|
||||
| `--password` | Trading account password |
|
||||
| `--password` | Trading account password (`MT5_PASSWORD`) |
|
||||
| `--server` | Trading server name |
|
||||
| `--path` | Path to MetaTrader5 terminal EXE file |
|
||||
| `--timeout` | Connection timeout in milliseconds |
|
||||
|
||||
@@ -65,6 +65,8 @@ nav:
|
||||
- SDK: api/sdk.md
|
||||
- Trading: api/trading.md
|
||||
- History Collection (SQLite): api/history.md
|
||||
- Telemetry: api/telemetry.md
|
||||
- Grafana: api/grafana.md
|
||||
- Utils: api/utils.md
|
||||
|
||||
markdown_extensions:
|
||||
|
||||
@@ -22,6 +22,7 @@ from .history import (
|
||||
drop_forming_rate_bar,
|
||||
load_rate_series_by_granularity,
|
||||
load_rate_series_from_sqlite,
|
||||
report_rate_gaps,
|
||||
)
|
||||
from .sdk import (
|
||||
AccountSpec,
|
||||
@@ -76,6 +77,7 @@ from .trading import (
|
||||
mt5_trading_session,
|
||||
normalize_order_volume,
|
||||
place_market_order,
|
||||
resolve_broker_filling_mode,
|
||||
update_sltp_for_open_positions,
|
||||
update_trailing_stop_loss_for_open_positions,
|
||||
)
|
||||
@@ -140,8 +142,10 @@ __all__ = [
|
||||
"mt5_trading_session",
|
||||
"normalize_order_volume",
|
||||
"place_market_order",
|
||||
"report_rate_gaps",
|
||||
"resolve_account_spec",
|
||||
"resolve_account_specs",
|
||||
"resolve_broker_filling_mode",
|
||||
"update_history",
|
||||
"update_history_with_config",
|
||||
"update_observability",
|
||||
|
||||
+166
-13
@@ -4,6 +4,9 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime # noqa: TC003
|
||||
from pathlib import Path # noqa: TC003
|
||||
@@ -11,10 +14,10 @@ from typing import TYPE_CHECKING, Annotated, Any, cast
|
||||
|
||||
import pandas as pd
|
||||
import typer
|
||||
from pdmt5 import Mt5Config
|
||||
|
||||
from . import sdk
|
||||
from .client import MT5Client
|
||||
from .history import report_rate_gaps, resolve_granularity_name
|
||||
from .trading import OrderExecutionResult, close_open_positions, create_trading_client
|
||||
from .utils import (
|
||||
DATETIME_TYPE,
|
||||
@@ -32,6 +35,8 @@ from .utils import (
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from pdmt5 import Mt5Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -66,12 +71,55 @@ app = typer.Typer(
|
||||
_REQUEST_OPTION_HELP = (
|
||||
"Order request as a JSON object string, or '@path' to load JSON from a file."
|
||||
)
|
||||
_CLI_ENV_DEFAULTS: dict[str, str] = {
|
||||
"path": "MT5_PATH",
|
||||
"login": "MT5_LOGIN",
|
||||
"password": "MT5_PASSWORD",
|
||||
"server": "MT5_SERVER",
|
||||
}
|
||||
_RATE_VIEW_NAME_RE = re.compile(
|
||||
r"^rate_(?P<symbol>.+)__(?:(?P<granularity>[A-Z0-9]+)_)?(?P<timeframe>\d+)$",
|
||||
)
|
||||
|
||||
|
||||
def _get_export_context(ctx: typer.Context) -> _ExportContext:
|
||||
return cast("_ExportContext", ctx.obj)
|
||||
|
||||
|
||||
def _resolve_cli_option(value: str | None, env_name: str) -> str | None:
|
||||
return value if value is not None else os.environ.get(env_name)
|
||||
|
||||
|
||||
def _timeframe_interval_seconds(timeframe: int) -> int | None:
|
||||
granularity = resolve_granularity_name(timeframe)
|
||||
units = {
|
||||
"M": 60,
|
||||
"H": 3600,
|
||||
"D": 86400,
|
||||
"W": 604800,
|
||||
}
|
||||
for prefix, seconds in units.items():
|
||||
suffix = granularity.removeprefix(prefix)
|
||||
if granularity.startswith(prefix) and suffix.isdigit():
|
||||
return int(suffix) * seconds
|
||||
return None
|
||||
|
||||
|
||||
def _infer_gap_table_granularity_seconds(table: str) -> int | None:
|
||||
if (match := _RATE_VIEW_NAME_RE.fullmatch(table)) is None:
|
||||
return None
|
||||
return _timeframe_interval_seconds(int(match.group("timeframe")))
|
||||
|
||||
|
||||
def _default_gap_tables(conn: sqlite3.Connection) -> list[str]:
|
||||
rows = conn.execute(
|
||||
"SELECT name FROM sqlite_master"
|
||||
" WHERE type IN ('table', 'view') AND name GLOB 'rate_*__*'"
|
||||
" ORDER BY name",
|
||||
).fetchall()
|
||||
return [str(row[0]) for row in rows]
|
||||
|
||||
|
||||
def _execute_export(
|
||||
ctx: typer.Context,
|
||||
fetch_fn: Callable[[], pd.DataFrame],
|
||||
@@ -132,20 +180,36 @@ def _callback( # pyright: ignore[reportUnusedFunction]
|
||||
typer.Option(help="Table name for SQLite3 output."),
|
||||
] = "data",
|
||||
login: Annotated[
|
||||
int | None,
|
||||
typer.Option(help="Trading account login."),
|
||||
str | None,
|
||||
typer.Option(
|
||||
help="Trading account login.",
|
||||
envvar=_CLI_ENV_DEFAULTS["login"],
|
||||
show_envvar=True,
|
||||
),
|
||||
] = None,
|
||||
password: Annotated[
|
||||
str | None,
|
||||
typer.Option(help="Trading account password."),
|
||||
typer.Option(
|
||||
help="Trading account password.",
|
||||
envvar=_CLI_ENV_DEFAULTS["password"],
|
||||
show_envvar=True,
|
||||
),
|
||||
] = None,
|
||||
server: Annotated[
|
||||
str | None,
|
||||
typer.Option(help="Trading server name."),
|
||||
typer.Option(
|
||||
help="Trading server name.",
|
||||
envvar=_CLI_ENV_DEFAULTS["server"],
|
||||
show_envvar=True,
|
||||
),
|
||||
] = None,
|
||||
path: Annotated[
|
||||
str | None,
|
||||
typer.Option(help="Path to MetaTrader5 terminal EXE file."),
|
||||
typer.Option(
|
||||
help="Path to MetaTrader5 terminal EXE file.",
|
||||
envvar=_CLI_ENV_DEFAULTS["path"],
|
||||
show_envvar=True,
|
||||
),
|
||||
] = None,
|
||||
timeout: Annotated[
|
||||
int | None,
|
||||
@@ -169,17 +233,22 @@ def _callback( # pyright: ignore[reportUnusedFunction]
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise typer.BadParameter(str(exc)) from exc
|
||||
try:
|
||||
config = sdk.build_config(
|
||||
path=_resolve_cli_option(path, _CLI_ENV_DEFAULTS["path"]),
|
||||
login=_resolve_cli_option(login, _CLI_ENV_DEFAULTS["login"]),
|
||||
password=_resolve_cli_option(password, _CLI_ENV_DEFAULTS["password"]),
|
||||
server=_resolve_cli_option(server, _CLI_ENV_DEFAULTS["server"]),
|
||||
timeout=timeout,
|
||||
allow_whole_dollar_env=True,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise typer.BadParameter(str(exc)) from exc
|
||||
ctx.obj = _ExportContext(
|
||||
output=output,
|
||||
output_format=output_format,
|
||||
table=table,
|
||||
config=Mt5Config(
|
||||
path=path,
|
||||
login=login,
|
||||
password=password,
|
||||
server=server,
|
||||
timeout=timeout,
|
||||
),
|
||||
config=config,
|
||||
)
|
||||
|
||||
|
||||
@@ -658,6 +727,20 @@ def close_positions(
|
||||
help="Position ticket to close (repeat for multiple tickets).",
|
||||
),
|
||||
] = None,
|
||||
deviation: Annotated[
|
||||
int | None,
|
||||
typer.Option(help="Optional slippage/deviation for each close request."),
|
||||
] = None,
|
||||
comment: Annotated[
|
||||
str | None,
|
||||
typer.Option(help="Optional comment attached to each close request."),
|
||||
] = None,
|
||||
magic: Annotated[
|
||||
int | None,
|
||||
typer.Option(
|
||||
help="Optional magic tag for close requests and position filtering.",
|
||||
),
|
||||
] = None,
|
||||
dry_run: Annotated[
|
||||
bool,
|
||||
typer.Option("--dry-run", help="Preview close orders without executing them."),
|
||||
@@ -694,6 +777,9 @@ def close_positions(
|
||||
client,
|
||||
symbols=list(symbol) if symbol else None,
|
||||
tickets=list(ticket) if ticket else None,
|
||||
deviation=deviation,
|
||||
comment=comment,
|
||||
magic=magic,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
finally:
|
||||
@@ -702,6 +788,73 @@ def close_positions(
|
||||
_execute_export(ctx, lambda: df)
|
||||
|
||||
|
||||
@app.command("history-gaps", rich_help_panel="Collection")
|
||||
def history_gaps(
|
||||
ctx: typer.Context,
|
||||
sqlite3_path: Annotated[
|
||||
Path,
|
||||
typer.Option(
|
||||
"--sqlite3",
|
||||
help="Source SQLite history database to analyze.",
|
||||
),
|
||||
],
|
||||
table: Annotated[
|
||||
list[str] | None,
|
||||
typer.Option(
|
||||
"--table",
|
||||
help="Rate table or compatibility view to inspect (repeat for multiple).",
|
||||
),
|
||||
] = None,
|
||||
granularity_seconds: Annotated[
|
||||
int | None,
|
||||
typer.Option(help="Explicit bar interval in seconds for custom tables/views."),
|
||||
] = None,
|
||||
min_gap_intervals: Annotated[
|
||||
int,
|
||||
typer.Option(help="Minimum missing-bar count required to emit a gap row."),
|
||||
] = 1,
|
||||
) -> None:
|
||||
"""Export SQLite rate gaps without connecting to MT5.
|
||||
|
||||
Raises:
|
||||
typer.BadParameter: If no compatible rate view is available and no
|
||||
explicit table is provided, or if granularity inference fails.
|
||||
"""
|
||||
with sqlite3.connect(sqlite3_path) as conn:
|
||||
tables = list(table) if table else _default_gap_tables(conn)
|
||||
if not tables:
|
||||
msg = (
|
||||
"No managed rate compatibility views found; pass --table for a rate "
|
||||
"table or view."
|
||||
)
|
||||
raise typer.BadParameter(msg, param_hint="--table")
|
||||
frames: list[pd.DataFrame] = []
|
||||
for table_name in tables:
|
||||
interval_seconds = (
|
||||
granularity_seconds or _infer_gap_table_granularity_seconds(table_name)
|
||||
)
|
||||
if interval_seconds is None:
|
||||
msg = (
|
||||
f"Could not infer granularity for {table_name!r}; pass "
|
||||
"--granularity-seconds."
|
||||
)
|
||||
raise typer.BadParameter(msg, param_hint="--granularity-seconds")
|
||||
frames.append(
|
||||
report_rate_gaps(
|
||||
conn,
|
||||
table_name,
|
||||
granularity_seconds=interval_seconds,
|
||||
min_gap_intervals=min_gap_intervals,
|
||||
)
|
||||
)
|
||||
df = (
|
||||
pd.concat(frames, ignore_index=True)
|
||||
if frames
|
||||
else pd.DataFrame(columns=["table"])
|
||||
)
|
||||
_execute_export(ctx, lambda: df)
|
||||
|
||||
|
||||
@app.command(rich_help_panel="Collection")
|
||||
def collect_history(
|
||||
ctx: typer.Context,
|
||||
|
||||
@@ -59,6 +59,8 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
||||
"mt5_trading_session",
|
||||
"normalize_order_volume",
|
||||
"place_market_order",
|
||||
"report_rate_gaps",
|
||||
"resolve_broker_filling_mode",
|
||||
"resolve_account_spec",
|
||||
"resolve_account_specs",
|
||||
"update_history",
|
||||
|
||||
+4
-14
@@ -9,11 +9,6 @@ from pdmt5 import Mt5RuntimeError
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
try:
|
||||
from pdmt5 import Mt5TradingError
|
||||
except ImportError: # pragma: no cover
|
||||
Mt5TradingError = None # type: ignore[assignment]
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
__all__ = [
|
||||
@@ -26,10 +21,7 @@ __all__ = [
|
||||
"normalize_mt5_exception",
|
||||
]
|
||||
|
||||
_RECOVERABLE_MT5_ERRORS: tuple[type[BaseException], ...] = (
|
||||
*([Mt5TradingError] if Mt5TradingError is not None else []), # type: ignore[misc]
|
||||
Mt5RuntimeError,
|
||||
)
|
||||
_RECOVERABLE_MT5_ERRORS: tuple[type[BaseException], ...] = (Mt5RuntimeError,)
|
||||
|
||||
|
||||
class Mt5CliError(Exception):
|
||||
@@ -55,7 +47,7 @@ def is_recoverable_mt5_error(exc: BaseException) -> bool:
|
||||
exc: Exception raised by MT5 or pdmt5.
|
||||
|
||||
Returns:
|
||||
True for ``Mt5RuntimeError`` and ``Mt5TradingError`` (if available).
|
||||
True for ``Mt5RuntimeError``.
|
||||
"""
|
||||
return isinstance(exc, _RECOVERABLE_MT5_ERRORS)
|
||||
|
||||
@@ -67,11 +59,9 @@ def normalize_mt5_exception(exc: BaseException) -> Mt5CliError:
|
||||
exc: Original exception from MT5 or pdmt5.
|
||||
|
||||
Returns:
|
||||
``Mt5ConnectionError`` for runtime failures, ``Mt5OperationError`` for
|
||||
trading failures, or the original exception when it is not recognized.
|
||||
``Mt5ConnectionError`` for runtime failures, or the original exception
|
||||
when it is not recognized.
|
||||
"""
|
||||
if Mt5TradingError is not None and isinstance(exc, Mt5TradingError):
|
||||
return Mt5OperationError(str(exc))
|
||||
if isinstance(exc, Mt5RuntimeError):
|
||||
return Mt5ConnectionError(str(exc))
|
||||
if isinstance(exc, Mt5CliError):
|
||||
|
||||
+352
-60
@@ -3,16 +3,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Literal, cast, overload
|
||||
from typing import TYPE_CHECKING, Literal, SupportsInt, cast, overload
|
||||
|
||||
import pandas as pd
|
||||
from pdmt5 import get_timeframe_name as _get_timeframe_name
|
||||
|
||||
from .schemas import DEDUP_KEYS, DataKind
|
||||
from .schemas import DEDUP_KEYS, DataKind, ensure_utc_columns
|
||||
from .utils import (
|
||||
TIMEFRAME_NAMES,
|
||||
Dataset,
|
||||
@@ -29,6 +30,13 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SQLITE_TEXT_TIME_COLUMNS: frozenset[str] = frozenset({
|
||||
"time",
|
||||
"time_setup",
|
||||
"time_done",
|
||||
})
|
||||
_SQLITE_CANONICAL_TIME_FORMAT = "%Y-%m-%dT%H:%M:%f+00:00"
|
||||
|
||||
DEFAULT_HISTORY_TIMEFRAMES: tuple[str, ...] = TIMEFRAME_NAMES
|
||||
DEFAULT_HISTORY_DATASETS: frozenset[Dataset] = frozenset({
|
||||
Dataset.rates,
|
||||
@@ -56,6 +64,20 @@ _POSITIONS_VIEW_REQUIRED_COLUMNS: frozenset[str] = frozenset({
|
||||
"price",
|
||||
"profit",
|
||||
})
|
||||
_RATE_GAP_COLUMNS: tuple[str, ...] = (
|
||||
"table",
|
||||
"symbol",
|
||||
"timeframe",
|
||||
"granularity",
|
||||
"granularity_seconds",
|
||||
"gap_start",
|
||||
"gap_end",
|
||||
"missing_intervals",
|
||||
)
|
||||
_RATE_VIEW_NAME_RE = re.compile(
|
||||
r"^rate_(?P<symbol>.+)__(?:(?P<granularity>[A-Z0-9]+)_)?(?P<timeframe>\d+)$",
|
||||
)
|
||||
_MIN_TIMESTAMPS_FOR_GAPS = 2
|
||||
|
||||
|
||||
def quote_sqlite_identifier(identifier: str) -> str:
|
||||
@@ -224,6 +246,155 @@ def _open_existing_sqlite_database(
|
||||
return conn, True
|
||||
|
||||
|
||||
def _empty_rate_gap_report() -> pd.DataFrame:
|
||||
return pd.DataFrame(columns=_RATE_GAP_COLUMNS)
|
||||
|
||||
|
||||
def _coerce_optional_int(value: object) -> int | None:
|
||||
if value is None or isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if text.lstrip("+-").isdigit():
|
||||
return int(text)
|
||||
return None
|
||||
if not hasattr(value, "__int__"):
|
||||
return None
|
||||
try:
|
||||
return int(cast("SupportsInt", value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _rate_gap_metadata(
|
||||
table: str,
|
||||
frame: pd.DataFrame,
|
||||
*,
|
||||
granularity_seconds: int,
|
||||
) -> dict[str, object]:
|
||||
symbol: str | None = None
|
||||
timeframe: int | None = None
|
||||
granularity: str | None = None
|
||||
|
||||
if "symbol" in frame.columns:
|
||||
symbols = {str(value) for value in frame["symbol"].dropna().unique()}
|
||||
if len(symbols) == 1:
|
||||
symbol = next(iter(symbols))
|
||||
if "timeframe" in frame.columns:
|
||||
timeframes = {
|
||||
coerced
|
||||
for value in frame["timeframe"].dropna().unique()
|
||||
if (coerced := _coerce_optional_int(value)) is not None
|
||||
}
|
||||
if len(timeframes) == 1:
|
||||
timeframe = next(iter(timeframes))
|
||||
|
||||
if timeframe is None and (match := _RATE_VIEW_NAME_RE.fullmatch(table)) is not None:
|
||||
symbol = symbol or match.group("symbol")
|
||||
timeframe = int(match.group("timeframe"))
|
||||
granularity = match.group("granularity") or resolve_granularity_name(timeframe)
|
||||
|
||||
if timeframe is not None and granularity is None:
|
||||
granularity = resolve_granularity_name(timeframe)
|
||||
|
||||
return {
|
||||
"table": table,
|
||||
"symbol": symbol,
|
||||
"timeframe": timeframe,
|
||||
"granularity": granularity,
|
||||
"granularity_seconds": granularity_seconds,
|
||||
}
|
||||
|
||||
|
||||
def _iter_rate_gap_groups(frame: pd.DataFrame) -> list[pd.DataFrame]:
|
||||
series_columns = [
|
||||
column for column in ("symbol", "timeframe") if column in frame.columns
|
||||
]
|
||||
if not series_columns:
|
||||
return [frame]
|
||||
return [
|
||||
group for _, group in frame.groupby(series_columns, dropna=False, sort=False)
|
||||
]
|
||||
|
||||
|
||||
def report_rate_gaps(
|
||||
conn: sqlite3.Connection,
|
||||
table: str,
|
||||
*,
|
||||
granularity_seconds: int,
|
||||
min_gap_intervals: int = 1,
|
||||
) -> pd.DataFrame:
|
||||
"""Return one row per detected gap from a SQLite rate table or view.
|
||||
|
||||
Raises:
|
||||
ValueError: If the table name, schema, timestamps, or gap parameters
|
||||
are invalid.
|
||||
"""
|
||||
table_name = _validate_rate_load_request(table, count=None)
|
||||
if granularity_seconds <= 0:
|
||||
msg = "granularity_seconds must be positive."
|
||||
raise ValueError(msg)
|
||||
if min_gap_intervals <= 0:
|
||||
msg = "min_gap_intervals must be positive."
|
||||
raise ValueError(msg)
|
||||
|
||||
columns = get_table_columns(conn, table_name)
|
||||
_ensure_rate_columns(columns, table_name)
|
||||
quoted_table = quote_sqlite_identifier(table_name)
|
||||
frame = cast(
|
||||
"pd.DataFrame",
|
||||
pd.read_sql_query( # type: ignore[reportUnknownMemberType]
|
||||
f"SELECT * FROM {quoted_table} ORDER BY time ASC", # noqa: S608
|
||||
conn,
|
||||
),
|
||||
)
|
||||
if frame.empty:
|
||||
return _empty_rate_gap_report()
|
||||
|
||||
parsed_times = frame["time"].map(parse_sqlite_timestamp)
|
||||
if parsed_times.isna().any():
|
||||
msg = f"SQLite table or view {table_name!r} contains unparsable time values."
|
||||
raise ValueError(msg)
|
||||
|
||||
series_frame = frame.copy()
|
||||
series_frame["time"] = parsed_times
|
||||
rows: list[dict[str, object]] = []
|
||||
for group in _iter_rate_gap_groups(series_frame):
|
||||
unique_times = group["time"].drop_duplicates().sort_values(ignore_index=True)
|
||||
if len(unique_times) < _MIN_TIMESTAMPS_FOR_GAPS:
|
||||
continue
|
||||
|
||||
metadata = _rate_gap_metadata(
|
||||
table_name,
|
||||
group,
|
||||
granularity_seconds=granularity_seconds,
|
||||
)
|
||||
deltas = unique_times.diff().dropna()
|
||||
for index, delta in enumerate(deltas, start=1):
|
||||
delta_seconds = int(delta.total_seconds())
|
||||
missing_intervals = max(
|
||||
((delta_seconds + (granularity_seconds - 1)) // granularity_seconds)
|
||||
- 1,
|
||||
0,
|
||||
)
|
||||
if missing_intervals < min_gap_intervals:
|
||||
continue
|
||||
previous_time = unique_times.iloc[index - 1]
|
||||
next_time = unique_times.iloc[index]
|
||||
rows.append({
|
||||
**metadata,
|
||||
"gap_start": (
|
||||
previous_time.to_pydatetime()
|
||||
+ timedelta(seconds=granularity_seconds)
|
||||
),
|
||||
"gap_end": (
|
||||
next_time.to_pydatetime() - timedelta(seconds=granularity_seconds)
|
||||
),
|
||||
"missing_intervals": missing_intervals,
|
||||
})
|
||||
return pd.DataFrame(rows, columns=_RATE_GAP_COLUMNS)
|
||||
|
||||
|
||||
def _validate_rate_load_request(table: str, count: int | None) -> str:
|
||||
table_name = _require_non_empty_identifier(table, "table or view")
|
||||
if count is not None and count <= 0:
|
||||
@@ -877,6 +1048,72 @@ def parse_sqlite_timestamp(value: object) -> datetime | None:
|
||||
return None
|
||||
|
||||
|
||||
def _serialize_sqlite_timestamp(value: object) -> str | None:
|
||||
parsed = parse_sqlite_timestamp(value)
|
||||
if parsed is None:
|
||||
return None
|
||||
utc_value = parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)
|
||||
utc_value = utc_value.astimezone(UTC)
|
||||
timespec = "microseconds" if utc_value.microsecond else "seconds"
|
||||
return utc_value.isoformat(timespec=timespec)
|
||||
|
||||
|
||||
def _require_serialized_sqlite_timestamp(value: object) -> str:
|
||||
serialized = _serialize_sqlite_timestamp(value)
|
||||
if serialized is None:
|
||||
msg = f"Invalid SQLite timestamp boundary: {value!r}"
|
||||
raise ValueError(msg)
|
||||
return serialized
|
||||
|
||||
|
||||
def _canonicalize_sqlite_time_columns(frame: pd.DataFrame) -> pd.DataFrame:
|
||||
columns = [
|
||||
column for column in _SQLITE_TEXT_TIME_COLUMNS if column in frame.columns
|
||||
]
|
||||
if not columns:
|
||||
return frame
|
||||
normalized = ensure_utc_columns(frame, columns)
|
||||
for column in columns:
|
||||
normalized[column] = normalized[column].map(_serialize_sqlite_timestamp)
|
||||
return normalized
|
||||
|
||||
|
||||
def _sqlite_dedup_key_expression(column: str) -> str:
|
||||
quoted = quote_sqlite_identifier(column)
|
||||
if column != "time":
|
||||
return quoted
|
||||
normalized = _sqlite_normalized_time_expression(column)
|
||||
return f"COALESCE({normalized}, CAST({quoted} AS TEXT))"
|
||||
|
||||
|
||||
def _sqlite_normalized_time_expression(column: str) -> str:
|
||||
"""Return a canonical UTC timestamp expression for mixed SQLite time values."""
|
||||
quoted = quote_sqlite_identifier(column)
|
||||
return (
|
||||
"COALESCE("
|
||||
f"strftime('{_SQLITE_CANONICAL_TIME_FORMAT}', {quoted}), "
|
||||
f"strftime('{_SQLITE_CANONICAL_TIME_FORMAT}', {quoted}, 'unixepoch')"
|
||||
")"
|
||||
)
|
||||
|
||||
|
||||
def _load_latest_parseable_time(
|
||||
conn: sqlite3.Connection,
|
||||
table: str,
|
||||
*,
|
||||
where_clause: str | None = None,
|
||||
params: Sequence[object] = (),
|
||||
) -> datetime | None:
|
||||
quoted_table = quote_sqlite_identifier(table)
|
||||
time_expr = _sqlite_normalized_time_expression("time")
|
||||
query = f"SELECT time FROM {quoted_table} WHERE {time_expr} IS NOT NULL" # noqa: S608
|
||||
if where_clause:
|
||||
query += f" AND {where_clause}"
|
||||
query += f" ORDER BY {time_expr} DESC, ROWID DESC LIMIT 1"
|
||||
row = conn.execute(query, tuple(params)).fetchone()
|
||||
return parse_sqlite_timestamp(row[0] if row else None)
|
||||
|
||||
|
||||
def get_history_deals_account_event_start_datetime(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
@@ -893,10 +1130,11 @@ def get_history_deals_account_event_start_datetime(
|
||||
where_clause = "symbol IS NULL OR symbol = ''"
|
||||
else:
|
||||
return fallback_start
|
||||
row = conn.execute(
|
||||
f"SELECT MAX(time) FROM {table} WHERE {where_clause}", # noqa: S608
|
||||
).fetchone()
|
||||
parsed = parse_sqlite_timestamp(row[0] if row else None)
|
||||
parsed = _load_latest_parseable_time(
|
||||
conn,
|
||||
table,
|
||||
where_clause=where_clause,
|
||||
)
|
||||
return parsed if parsed is not None else fallback_start
|
||||
|
||||
|
||||
@@ -919,6 +1157,68 @@ def _validate_rates_schema(columns: set[str]) -> None:
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def _load_grouped_rate_start_datetimes(
|
||||
conn: sqlite3.Connection,
|
||||
table: str,
|
||||
*,
|
||||
symbols: Sequence[str],
|
||||
timeframes: Sequence[int],
|
||||
fallback_start: datetime,
|
||||
) -> dict[tuple[str, int | None], datetime]:
|
||||
symbol_placeholders = ", ".join("?" for _ in symbols)
|
||||
timeframe_placeholders = ", ".join("?" for _ in timeframes)
|
||||
time_expr = _sqlite_normalized_time_expression("time")
|
||||
rows = conn.execute(
|
||||
"SELECT symbol, timeframe, MAX(" # noqa: S608
|
||||
f"{time_expr}) FROM "
|
||||
f"{quote_sqlite_identifier(table)}"
|
||||
f" WHERE symbol IN ({symbol_placeholders})"
|
||||
f" AND timeframe IN ({timeframe_placeholders})"
|
||||
f" AND {time_expr} IS NOT NULL"
|
||||
f" GROUP BY symbol, timeframe",
|
||||
[*symbols, *timeframes],
|
||||
).fetchall()
|
||||
parsed_by_key: dict[tuple[str, int | None], datetime] = {}
|
||||
for row_symbol, row_timeframe, max_time in rows:
|
||||
parsed = parse_sqlite_timestamp(max_time)
|
||||
if parsed is not None:
|
||||
parsed_by_key[str(row_symbol), int(row_timeframe)] = parsed
|
||||
return {
|
||||
(symbol, timeframe): parsed_by_key.get((symbol, timeframe), fallback_start)
|
||||
for symbol in symbols
|
||||
for timeframe in timeframes
|
||||
}
|
||||
|
||||
|
||||
def _load_symbol_start_datetimes(
|
||||
conn: sqlite3.Connection,
|
||||
table: str,
|
||||
*,
|
||||
symbols: Sequence[str],
|
||||
fallback_start: datetime,
|
||||
) -> dict[tuple[str, int | None], datetime]:
|
||||
symbol_placeholders = ", ".join("?" for _ in symbols)
|
||||
time_expr = _sqlite_normalized_time_expression("time")
|
||||
rows = conn.execute(
|
||||
"SELECT symbol, MAX(" # noqa: S608
|
||||
f"{time_expr}) FROM "
|
||||
f"{quote_sqlite_identifier(table)}"
|
||||
f" WHERE symbol IN ({symbol_placeholders})"
|
||||
f" AND {time_expr} IS NOT NULL"
|
||||
f" GROUP BY symbol",
|
||||
list(symbols),
|
||||
).fetchall()
|
||||
parsed_by_key: dict[tuple[str, int | None], datetime] = {}
|
||||
for row_symbol, max_time in rows:
|
||||
parsed = parse_sqlite_timestamp(max_time)
|
||||
if parsed is not None:
|
||||
parsed_by_key[str(row_symbol), None] = parsed
|
||||
return {
|
||||
(symbol, None): parsed_by_key.get((symbol, None), fallback_start)
|
||||
for symbol in symbols
|
||||
}
|
||||
|
||||
|
||||
def load_incremental_start_datetimes(
|
||||
conn: sqlite3.Connection,
|
||||
dataset: Dataset,
|
||||
@@ -942,55 +1242,28 @@ def load_incremental_start_datetimes(
|
||||
}
|
||||
return {(symbol, None): fallback_start for symbol in symbols}
|
||||
|
||||
parsed_by_key: dict[tuple[str, int | None], datetime] = {}
|
||||
if (
|
||||
dataset is Dataset.rates
|
||||
and timeframes is not None
|
||||
and {"symbol", "timeframe"}.issubset(columns)
|
||||
):
|
||||
symbol_placeholders = ", ".join("?" for _ in symbols)
|
||||
timeframe_placeholders = ", ".join("?" for _ in timeframes)
|
||||
grouped_rates_query = (
|
||||
"SELECT symbol, timeframe, MAX(time) FROM " # noqa: S608
|
||||
f"{table} WHERE symbol IN ({symbol_placeholders})"
|
||||
f" AND timeframe IN ({timeframe_placeholders})"
|
||||
" GROUP BY symbol, timeframe"
|
||||
return _load_grouped_rate_start_datetimes(
|
||||
conn,
|
||||
table,
|
||||
symbols=symbols,
|
||||
timeframes=timeframes,
|
||||
fallback_start=fallback_start,
|
||||
)
|
||||
rows = conn.execute(
|
||||
grouped_rates_query,
|
||||
[*symbols, *timeframes],
|
||||
).fetchall()
|
||||
for row_symbol, row_timeframe, max_time in rows:
|
||||
parsed = parse_sqlite_timestamp(max_time)
|
||||
if parsed is not None:
|
||||
parsed_by_key[str(row_symbol), int(row_timeframe)] = parsed
|
||||
return {
|
||||
(symbol, timeframe): parsed_by_key.get(
|
||||
(symbol, timeframe),
|
||||
fallback_start,
|
||||
)
|
||||
for symbol in symbols
|
||||
for timeframe in timeframes
|
||||
}
|
||||
|
||||
if "symbol" in columns:
|
||||
symbol_placeholders = ", ".join("?" for _ in symbols)
|
||||
rows = conn.execute(
|
||||
f"SELECT symbol, MAX(time) FROM {table}" # noqa: S608
|
||||
f" WHERE symbol IN ({symbol_placeholders}) GROUP BY symbol",
|
||||
list(symbols),
|
||||
).fetchall()
|
||||
for row_symbol, max_time in rows:
|
||||
parsed = parse_sqlite_timestamp(max_time)
|
||||
if parsed is not None:
|
||||
parsed_by_key[str(row_symbol), None] = parsed
|
||||
return {
|
||||
(symbol, None): parsed_by_key.get((symbol, None), fallback_start)
|
||||
for symbol in symbols
|
||||
}
|
||||
return _load_symbol_start_datetimes(
|
||||
conn,
|
||||
table,
|
||||
symbols=symbols,
|
||||
fallback_start=fallback_start,
|
||||
)
|
||||
|
||||
row = conn.execute(f"SELECT MAX(time) FROM {table}").fetchone() # noqa: S608
|
||||
parsed = parse_sqlite_timestamp(row[0] if row else None)
|
||||
parsed = _load_latest_parseable_time(conn, table)
|
||||
shared_start = parsed if parsed is not None else fallback_start
|
||||
return {(symbol, None): shared_start for symbol in symbols}
|
||||
|
||||
@@ -1029,7 +1302,8 @@ def append_dataframe(
|
||||
if len(frame.columns) == 0:
|
||||
logger.warning("Skipping %s: dataset returned no columns", table_name)
|
||||
return False
|
||||
frame.to_sql( # type: ignore[reportUnknownMemberType]
|
||||
writable = _canonicalize_sqlite_time_columns(frame)
|
||||
writable.to_sql( # type: ignore[reportUnknownMemberType]
|
||||
table_name,
|
||||
conn,
|
||||
if_exists=if_exists.value,
|
||||
@@ -1108,15 +1382,21 @@ def drop_duplicates_in_table(
|
||||
if invalid := {column for column in ids if not column.isidentifier()}:
|
||||
msg = f"Invalid column names: {', '.join(sorted(invalid))}"
|
||||
raise ValueError(msg)
|
||||
ids_csv = ", ".join(f'"{column}"' for column in ids)
|
||||
ids_csv = ", ".join(_sqlite_dedup_key_expression(column) for column in ids)
|
||||
rowid_selector = "MIN" if keep == "first" else "MAX"
|
||||
prepared_scope_params = tuple(
|
||||
_require_serialized_sqlite_timestamp(value)
|
||||
if isinstance(value, datetime)
|
||||
else value
|
||||
for value in scope_params
|
||||
)
|
||||
if scope_where:
|
||||
delete_sql = (
|
||||
f"DELETE FROM {table} WHERE {scope_where} AND ROWID NOT IN" # noqa: S608
|
||||
f" (SELECT {rowid_selector}(ROWID) FROM {table} WHERE {scope_where}"
|
||||
f" GROUP BY {ids_csv})"
|
||||
)
|
||||
cursor.execute(delete_sql, scope_params + scope_params)
|
||||
cursor.execute(delete_sql, prepared_scope_params + prepared_scope_params)
|
||||
return
|
||||
cursor.execute(
|
||||
f"DELETE FROM {table} WHERE ROWID NOT IN" # noqa: S608
|
||||
@@ -1436,11 +1716,12 @@ def _record_symbol_time_dedup(
|
||||
) -> None:
|
||||
"""Record a symbol-scoped deduplication window after an incremental write."""
|
||||
written_tables.add(dataset)
|
||||
time_expr = _sqlite_normalized_time_expression("time")
|
||||
_record_dedup_scope(
|
||||
dedup_scopes,
|
||||
dataset,
|
||||
"symbol = ? AND time >= ?",
|
||||
(symbol, start_date),
|
||||
f"symbol = ? AND {time_expr} >= ?",
|
||||
(symbol, _require_serialized_sqlite_timestamp(start_date)),
|
||||
frozenset({"symbol", "time"}),
|
||||
)
|
||||
|
||||
@@ -1602,11 +1883,16 @@ def _write_incremental_rates(
|
||||
written_columns,
|
||||
):
|
||||
written_tables.add(Dataset.rates)
|
||||
time_expr = _sqlite_normalized_time_expression("time")
|
||||
_record_dedup_scope(
|
||||
dedup_scopes,
|
||||
Dataset.rates,
|
||||
"symbol = ? AND timeframe = ? AND time >= ?",
|
||||
(symbol, timeframe, start_date),
|
||||
f"symbol = ? AND timeframe = ? AND {time_expr} >= ?",
|
||||
(
|
||||
symbol,
|
||||
timeframe,
|
||||
_require_serialized_sqlite_timestamp(start_date),
|
||||
),
|
||||
frozenset({"symbol", "timeframe", "time"}),
|
||||
)
|
||||
|
||||
@@ -1730,29 +2016,35 @@ def _write_incremental_history_deals(
|
||||
):
|
||||
written_tables.add(Dataset.history_deals)
|
||||
columns = get_table_columns(conn, Dataset.history_deals.table_name)
|
||||
time_expr = _sqlite_normalized_time_expression("time")
|
||||
if "symbol" in columns:
|
||||
for symbol in symbols:
|
||||
_record_dedup_scope(
|
||||
dedup_scopes,
|
||||
Dataset.history_deals,
|
||||
"symbol = ? AND time >= ?",
|
||||
(symbol, start_by_symbol[symbol, None]),
|
||||
f"symbol = ? AND {time_expr} >= ?",
|
||||
(
|
||||
symbol,
|
||||
_require_serialized_sqlite_timestamp(
|
||||
start_by_symbol[symbol, None]
|
||||
),
|
||||
),
|
||||
frozenset({"symbol", "time"}),
|
||||
)
|
||||
if "type" in columns:
|
||||
_record_dedup_scope(
|
||||
dedup_scopes,
|
||||
Dataset.history_deals,
|
||||
f"type NOT IN {_TRADE_DEAL_TYPES_SQL} AND time >= ?",
|
||||
(account_event_start,),
|
||||
f"type NOT IN {_TRADE_DEAL_TYPES_SQL} AND {time_expr} >= ?",
|
||||
(_require_serialized_sqlite_timestamp(account_event_start),),
|
||||
frozenset({"type", "time"}),
|
||||
)
|
||||
if "type" not in columns and "symbol" in columns:
|
||||
_record_dedup_scope(
|
||||
dedup_scopes,
|
||||
Dataset.history_deals,
|
||||
"(symbol IS NULL OR symbol = '') AND time >= ?",
|
||||
(account_event_start,),
|
||||
f"(symbol IS NULL OR symbol = '') AND {time_expr} >= ?",
|
||||
(_require_serialized_sqlite_timestamp(account_event_start),),
|
||||
frozenset({"symbol", "time"}),
|
||||
)
|
||||
return
|
||||
|
||||
+2
-3
@@ -29,9 +29,8 @@ def retry_with_backoff(
|
||||
) -> T:
|
||||
"""Call ``fn`` with bounded exponential backoff on recoverable MT5 errors.
|
||||
|
||||
Only ``pdmt5.Mt5RuntimeError`` and ``pdmt5.Mt5TradingError`` are retried.
|
||||
Other exceptions propagate immediately. The final failure is re-raised once
|
||||
retries are exhausted.
|
||||
Only ``pdmt5.Mt5RuntimeError`` is retried. Other exceptions propagate
|
||||
immediately. The final failure is re-raised once retries are exhausted.
|
||||
|
||||
Args:
|
||||
fn: Callable performing MT5 work.
|
||||
|
||||
+14
-14
@@ -16,11 +16,7 @@ from typing import TYPE_CHECKING, Self, TypeVar, cast
|
||||
|
||||
import pandas as pd
|
||||
from pdmt5 import Mt5Config, Mt5DataClient, Mt5RuntimeError
|
||||
|
||||
try:
|
||||
from pdmt5 import Mt5TradingError
|
||||
except ImportError: # pragma: no cover
|
||||
Mt5TradingError = None # type: ignore[assignment]
|
||||
from pydantic import SecretStr
|
||||
|
||||
from .grafana import (
|
||||
create_snapshot_tables,
|
||||
@@ -37,6 +33,7 @@ from .history import (
|
||||
create_history_indexes,
|
||||
create_positions_reconstructed_view,
|
||||
drop_forming_rate_bar,
|
||||
report_rate_gaps,
|
||||
resolve_granularity_name,
|
||||
resolve_history_datasets,
|
||||
resolve_history_tick_flags,
|
||||
@@ -65,7 +62,6 @@ T = TypeVar("T")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_RECOVERABLE_HISTORY_UPDATE_ERRORS: tuple[type[BaseException], ...] = (
|
||||
*([Mt5TradingError] if Mt5TradingError is not None else []), # type: ignore[assignment]
|
||||
Mt5RuntimeError,
|
||||
sqlite3.Error,
|
||||
ValueError,
|
||||
@@ -155,6 +151,7 @@ __all__ = [
|
||||
"positions",
|
||||
"recent_history_deals",
|
||||
"recent_ticks",
|
||||
"report_rate_gaps",
|
||||
"resolve_account_spec",
|
||||
"resolve_account_specs",
|
||||
"substitute_env_placeholders",
|
||||
@@ -1139,8 +1136,8 @@ class ThrottledHistoryUpdater:
|
||||
include_account_events: Include account-level cash events.
|
||||
interval_seconds: Minimum seconds between successful updates. Values
|
||||
``<= 0`` update on every call.
|
||||
suppress_errors: When True, recoverable errors (``Mt5TradingError``,
|
||||
``Mt5RuntimeError``, ``sqlite3.Error``, ``ValueError``,
|
||||
suppress_errors: When True, recoverable errors (``Mt5RuntimeError``,
|
||||
``sqlite3.Error``, ``ValueError``,
|
||||
``OSError``, and MT5 client capability ``AttributeError`` /
|
||||
``TypeError`` for history API methods) raised during an update
|
||||
are swallowed and :meth:`update` returns False without advancing
|
||||
@@ -1703,10 +1700,13 @@ def _build_account_config(
|
||||
login = _coerce_login(account.login)
|
||||
if login is None and base_config is not None:
|
||||
login = base_config.login
|
||||
base_password = base_config.password if base_config else None
|
||||
if isinstance(base_password, SecretStr):
|
||||
base_password = base_password.get_secret_value()
|
||||
return build_config(
|
||||
path=account.path or (base_config.path if base_config else None),
|
||||
login=login,
|
||||
password=account.password or (base_config.password if base_config else None),
|
||||
password=account.password or base_password,
|
||||
server=account.server or (base_config.server if base_config else None),
|
||||
timeout=account.timeout
|
||||
if account.timeout is not None
|
||||
@@ -1782,9 +1782,9 @@ def collect_latest_rates_for_accounts_with_retries(
|
||||
"""Collect latest rates across accounts, retrying transient MT5 failures.
|
||||
|
||||
Wraps :func:`collect_latest_rates_for_accounts` with bounded exponential
|
||||
backoff. Only ``pdmt5.Mt5TradingError`` and ``pdmt5.Mt5RuntimeError`` are
|
||||
retried; other exceptions propagate immediately. The final failure is
|
||||
re-raised once retries are exhausted.
|
||||
backoff. Only ``pdmt5.Mt5RuntimeError`` is retried; other exceptions
|
||||
propagate immediately. The final failure is re-raised once retries are
|
||||
exhausted.
|
||||
|
||||
Args:
|
||||
accounts: Account groups to read. Each must define at least one symbol.
|
||||
@@ -1801,8 +1801,8 @@ def collect_latest_rates_for_accounts_with_retries(
|
||||
Returns:
|
||||
Mapping keyed by ``(symbol, timeframe_int)``. Propagates ``ValueError``
|
||||
for invalid inputs (see :func:`collect_latest_rates_for_accounts`) and
|
||||
re-raises the last ``pdmt5.Mt5TradingError`` or ``pdmt5.Mt5RuntimeError``
|
||||
once retries are exhausted.
|
||||
re-raises the last ``pdmt5.Mt5RuntimeError`` once retries are
|
||||
exhausted.
|
||||
"""
|
||||
|
||||
def _collect() -> dict[tuple[str, int], pd.DataFrame]:
|
||||
|
||||
+119
-3
@@ -199,6 +199,7 @@ _SYMBOL_SNAPSHOT_FIELDS = (
|
||||
"trade_tick_value",
|
||||
"trade_stops_level",
|
||||
"filling_mode",
|
||||
"trade_exemode",
|
||||
)
|
||||
_TICK_SNAPSHOT_FIELDS = ("symbol", "time", "bid", "ask", "last", "volume")
|
||||
POSITION_COLUMNS = (
|
||||
@@ -214,6 +215,7 @@ POSITION_COLUMNS = (
|
||||
"profit",
|
||||
"swap",
|
||||
"comment",
|
||||
"magic",
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -255,6 +257,7 @@ __all__ = [
|
||||
"mt5_trading_session",
|
||||
"normalize_order_volume",
|
||||
"place_market_order",
|
||||
"resolve_broker_filling_mode",
|
||||
"update_sltp_for_open_positions",
|
||||
"update_trailing_stop_loss_for_open_positions",
|
||||
]
|
||||
@@ -658,19 +661,24 @@ def create_trading_client(
|
||||
def detect_position_side(
|
||||
client: _Mt5ClientProtocol,
|
||||
symbol: str,
|
||||
*,
|
||||
magic: int | None = None,
|
||||
) -> PositionSide | None:
|
||||
"""Detect the net open position side for a symbol.
|
||||
|
||||
Args:
|
||||
client: Connected MT5 client instance.
|
||||
symbol: Symbol to inspect.
|
||||
magic: Optional magic number filter applied fail-closed.
|
||||
|
||||
Returns:
|
||||
``"long"`` when there are buy positions and no sell positions,
|
||||
``"short"`` when there are sell positions and no buy positions, or
|
||||
``None`` when no positions or mixed exposure exists.
|
||||
"""
|
||||
positions = get_positions_frame(client, symbol=symbol)
|
||||
positions = _filter_positions(
|
||||
get_positions_frame(client, symbol=symbol), magic=magic
|
||||
)
|
||||
if positions.empty:
|
||||
return None
|
||||
|
||||
@@ -734,6 +742,85 @@ def get_positions_frame(
|
||||
return frame
|
||||
|
||||
|
||||
def _supported_filling_modes(
|
||||
client: _Mt5ClientProtocol,
|
||||
*,
|
||||
symbol: str,
|
||||
preferred_default: OrderFillingMode,
|
||||
) -> set[str] | None:
|
||||
snapshot = get_symbol_snapshot(client, symbol)
|
||||
filling_mode = _optional_int(snapshot.get("filling_mode"))
|
||||
trade_exemode = _optional_int(snapshot.get("trade_exemode"))
|
||||
if filling_mode is None and trade_exemode is None:
|
||||
_logger.debug(
|
||||
"Filling-mode metadata unavailable for %s; keeping preferred mode %s.",
|
||||
symbol,
|
||||
preferred_default,
|
||||
)
|
||||
return None
|
||||
|
||||
supported: set[str] = set()
|
||||
if filling_mode is not None:
|
||||
fok_flag = getattr(client.mt5, "SYMBOL_FILLING_FOK", None)
|
||||
ioc_flag = getattr(client.mt5, "SYMBOL_FILLING_IOC", None)
|
||||
if isinstance(fok_flag, int) and filling_mode & fok_flag:
|
||||
supported.add("FOK")
|
||||
if isinstance(ioc_flag, int) and filling_mode & ioc_flag:
|
||||
supported.add("IOC")
|
||||
market_execution = getattr(client.mt5, "SYMBOL_TRADE_EXECUTION_MARKET", None)
|
||||
if trade_exemode is not None and not (
|
||||
isinstance(market_execution, int) and trade_exemode == market_execution
|
||||
):
|
||||
supported.add("RETURN")
|
||||
if supported:
|
||||
return supported
|
||||
|
||||
_logger.debug(
|
||||
"Filling-mode metadata was unparseable for %s; keeping preferred mode %s.",
|
||||
symbol,
|
||||
preferred_default,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def resolve_broker_filling_mode(
|
||||
client: _Mt5ClientProtocol,
|
||||
*,
|
||||
symbol: str,
|
||||
preferred_modes: Sequence[OrderFillingMode] = ("IOC", "FOK", "RETURN"),
|
||||
default_mode: OrderFillingMode = "IOC",
|
||||
) -> OrderFillingMode:
|
||||
"""Return the first broker-supported filling mode from a preferred order.
|
||||
|
||||
Raises:
|
||||
ValueError: If any preferred or default mode name is unsupported.
|
||||
"""
|
||||
preferred = [mode.upper() for mode in preferred_modes]
|
||||
for mode in [*preferred, default_mode]:
|
||||
if mode not in _ORDER_FILLING_MODES:
|
||||
msg = f"Unsupported order_filling mode: {mode!r}."
|
||||
raise ValueError(msg)
|
||||
|
||||
preferred_default = cast(
|
||||
"OrderFillingMode", preferred[0] if preferred else default_mode
|
||||
)
|
||||
supported = _supported_filling_modes(
|
||||
client,
|
||||
symbol=symbol,
|
||||
preferred_default=preferred_default,
|
||||
)
|
||||
if supported is None:
|
||||
return preferred_default
|
||||
|
||||
for mode in preferred:
|
||||
if mode in supported:
|
||||
return cast("OrderFillingMode", mode)
|
||||
fallback_mode = next(
|
||||
mode for mode in (default_mode, "IOC", "FOK", "RETURN") if mode in supported
|
||||
)
|
||||
return cast("OrderFillingMode", fallback_mode)
|
||||
|
||||
|
||||
def _order_side_from_position_type(
|
||||
client: _Mt5ClientProtocol,
|
||||
position_type: object,
|
||||
@@ -1334,7 +1421,7 @@ def determine_order_limits(
|
||||
}
|
||||
|
||||
|
||||
def place_market_order(
|
||||
def place_market_order( # noqa: C901, PLR0913
|
||||
client: _Mt5ClientProtocol,
|
||||
*,
|
||||
symbol: str,
|
||||
@@ -1345,6 +1432,9 @@ def place_market_order(
|
||||
sl: float | None = None,
|
||||
tp: float | None = None,
|
||||
position: int | None = None,
|
||||
deviation: int | None = None,
|
||||
comment: str | None = None,
|
||||
magic: int | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> OrderExecutionResult:
|
||||
"""Place one normalized market order or return a dry-run result.
|
||||
@@ -1398,6 +1488,12 @@ def place_market_order(
|
||||
request["tp"] = tp
|
||||
if position is not None:
|
||||
request["position"] = position
|
||||
if deviation is not None:
|
||||
request["deviation"] = deviation
|
||||
if comment is not None:
|
||||
request["comment"] = comment
|
||||
if magic is not None:
|
||||
request["magic"] = magic
|
||||
if dry_run:
|
||||
return {
|
||||
"status": "dry_run",
|
||||
@@ -1432,6 +1528,7 @@ def _filter_positions(
|
||||
*,
|
||||
symbols: str | list[str] | None = None,
|
||||
tickets: list[int] | None = None,
|
||||
magic: int | None = None,
|
||||
) -> pd.DataFrame:
|
||||
frame = positions
|
||||
if symbols is not None:
|
||||
@@ -1439,6 +1536,10 @@ def _filter_positions(
|
||||
frame = frame.loc[frame["symbol"].isin(symbol_set)]
|
||||
if tickets is not None:
|
||||
frame = frame.loc[frame["ticket"].isin(tickets)]
|
||||
if magic is not None:
|
||||
if "magic" not in frame.columns:
|
||||
return frame.iloc[0:0].copy()
|
||||
frame = frame.loc[frame["magic"] == magic]
|
||||
return frame
|
||||
|
||||
|
||||
@@ -1447,6 +1548,9 @@ def close_open_positions(
|
||||
*,
|
||||
symbols: str | list[str] | None = None,
|
||||
tickets: list[int] | None = None,
|
||||
deviation: int | None = None,
|
||||
comment: str | None = None,
|
||||
magic: int | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> list[OrderExecutionResult]:
|
||||
"""Close matching open positions.
|
||||
@@ -1458,6 +1562,7 @@ def close_open_positions(
|
||||
get_positions_frame(client),
|
||||
symbols=symbols,
|
||||
tickets=tickets,
|
||||
magic=magic,
|
||||
)
|
||||
results: list[OrderExecutionResult] = []
|
||||
for row in positions.to_dict("records"):
|
||||
@@ -1469,6 +1574,9 @@ def close_open_positions(
|
||||
volume=float(row["volume"]),
|
||||
order_side=side,
|
||||
position=int(row["ticket"]),
|
||||
deviation=deviation,
|
||||
comment=comment,
|
||||
magic=magic,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
results.append(result)
|
||||
@@ -1523,6 +1631,7 @@ def calculate_trailing_stop_updates(
|
||||
*,
|
||||
symbol: str,
|
||||
trailing_stop_ratio: float,
|
||||
magic: int | None = None,
|
||||
) -> dict[int, float]:
|
||||
"""Return per-ticket trailing stop-loss updates for open symbol positions.
|
||||
|
||||
@@ -1533,7 +1642,9 @@ def calculate_trailing_stop_updates(
|
||||
missing side-specific tick price are skipped.
|
||||
"""
|
||||
_require_protective_ratio(trailing_stop_ratio, "trailing_stop_ratio")
|
||||
positions = get_positions_frame(client, symbol=symbol)
|
||||
positions = _filter_positions(
|
||||
get_positions_frame(client, symbol=symbol), magic=magic
|
||||
)
|
||||
if positions.empty:
|
||||
return {}
|
||||
tick = get_tick_snapshot(client, symbol)
|
||||
@@ -1568,6 +1679,7 @@ def update_trailing_stop_loss_for_open_positions(
|
||||
*,
|
||||
symbol: str,
|
||||
trailing_stop_ratio: float,
|
||||
magic: int | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> list[OrderExecutionResult]:
|
||||
"""Update open positions whose trailing stop loss should move favorably.
|
||||
@@ -1579,6 +1691,7 @@ def update_trailing_stop_loss_for_open_positions(
|
||||
client,
|
||||
symbol=symbol,
|
||||
trailing_stop_ratio=trailing_stop_ratio,
|
||||
magic=magic,
|
||||
)
|
||||
results: list[OrderExecutionResult] = []
|
||||
for ticket, stop_loss in updates.items():
|
||||
@@ -1588,6 +1701,7 @@ def update_trailing_stop_loss_for_open_positions(
|
||||
symbol=symbol,
|
||||
tickets=[ticket],
|
||||
stop_loss=stop_loss,
|
||||
magic=magic,
|
||||
dry_run=dry_run,
|
||||
),
|
||||
)
|
||||
@@ -1599,6 +1713,7 @@ def update_sltp_for_open_positions(
|
||||
*,
|
||||
symbol: str | None = None,
|
||||
tickets: list[int] | None = None,
|
||||
magic: int | None = None,
|
||||
stop_loss: float | None = None,
|
||||
take_profit: float | None = None,
|
||||
dry_run: bool = False,
|
||||
@@ -1612,6 +1727,7 @@ def update_sltp_for_open_positions(
|
||||
get_positions_frame(client),
|
||||
symbols=symbol,
|
||||
tickets=tickets,
|
||||
magic=magic,
|
||||
)
|
||||
results: list[OrderExecutionResult] = []
|
||||
for row in positions.to_dict("records"):
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mt5cli"
|
||||
version = "1.1.1"
|
||||
version = "1.1.3"
|
||||
description = "Generic MT5 data and execution infrastructure for Python applications"
|
||||
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||
@@ -9,7 +9,9 @@ license-files = ["LICENSE"]
|
||||
readme = "README.md"
|
||||
requires-python = ">= 3.11, < 3.14"
|
||||
dependencies = [
|
||||
"pdmt5>=1.0.0",
|
||||
"pdmt5 >= 1.1.0",
|
||||
"pandas >= 2.2.2",
|
||||
"pydantic >= 2.13.4",
|
||||
"click >= 8.1.0",
|
||||
"typer >= 0.15.0",
|
||||
]
|
||||
|
||||
@@ -32,10 +32,10 @@ Global options MUST precede the subcommand.
|
||||
| `-o, --output PATH` | Output file path (required). |
|
||||
| `-f, --format FORMAT` | `csv`, `json`, `parquet`, or `sqlite3` (auto from extension). |
|
||||
| `--table NAME` | Table name for SQLite3 output (default: `data`). |
|
||||
| `--login INT` | MT5 trading account login. |
|
||||
| `--password TEXT` | MT5 trading account password. |
|
||||
| `--server TEXT` | MT5 trading server name. |
|
||||
| `--path TEXT` | Path to MetaTrader 5 terminal EXE. |
|
||||
| `--login INT` | MT5 trading account login (`MT5_LOGIN`). |
|
||||
| `--password TEXT` | MT5 trading account password (`MT5_PASSWORD`). |
|
||||
| `--server TEXT` | MT5 trading server name (`MT5_SERVER`). |
|
||||
| `--path TEXT` | Path to MetaTrader 5 terminal EXE (`MT5_PATH`). |
|
||||
| `--timeout INT` | Connection timeout in milliseconds. |
|
||||
| `--log-level LEVEL` | `DEBUG`, `INFO`, `WARNING` (default), `ERROR`. |
|
||||
|
||||
@@ -106,7 +106,8 @@ mt5cli -o history.db collect-history \
|
||||
local MT5 terminal is already logged in.
|
||||
- Avoid passing `--password` on the command line in shared or logged
|
||||
environments — it is visible in `ps`, shell history, and CI logs. Prefer
|
||||
logging in through the MT5 terminal first, then omit credentials here.
|
||||
`MT5_PASSWORD`/`MT5_LOGIN`/`MT5_SERVER`/`MT5_PATH` environment variables or a
|
||||
pre-authenticated local terminal session.
|
||||
- Reach for `--log-level DEBUG` when a command fails silently — MT5
|
||||
connection errors surface there.
|
||||
- If the user asks to run from source in this repo, prefix with `uv run`
|
||||
|
||||
+766
-796
File diff suppressed because it is too large
Load Diff
+29
-23
@@ -14,7 +14,7 @@ if TYPE_CHECKING:
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from pdmt5 import Mt5RuntimeError, Mt5TradingError
|
||||
from pdmt5 import Mt5RuntimeError
|
||||
from pytest_mock import MockerFixture # noqa: TC002
|
||||
|
||||
import mt5cli
|
||||
@@ -26,7 +26,6 @@ from mt5cli import (
|
||||
MT5Client,
|
||||
Mt5CliError,
|
||||
Mt5ConnectionError,
|
||||
Mt5OperationError,
|
||||
Mt5SchemaError,
|
||||
OrderExecutionResult,
|
||||
OrderLimits,
|
||||
@@ -224,10 +223,18 @@ def test_parse_date_range_rejects_inverted_bounds() -> None:
|
||||
parse_date_range("2024-02-01", "2024-01-01")
|
||||
|
||||
|
||||
def test_recent_window_builds_trailing_bounds() -> None:
|
||||
"""Recent windows end at the provided timestamp."""
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[
|
||||
{"hours": 24},
|
||||
{"seconds": 3600},
|
||||
],
|
||||
ids=["hours", "seconds"],
|
||||
)
|
||||
def test_recent_window_success_cases(kwargs: dict[str, int]) -> None:
|
||||
"""Recent windows end at the provided timestamp for both duration inputs."""
|
||||
end = datetime(2024, 1, 2, tzinfo=UTC)
|
||||
start, resolved_end = recent_window(hours=24, date_to=end)
|
||||
start, resolved_end = recent_window(date_to=end, **kwargs)
|
||||
assert resolved_end == end
|
||||
assert start < end
|
||||
|
||||
@@ -239,7 +246,7 @@ def test_granularity_name_maps_timeframe_alias() -> None:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc",
|
||||
[Mt5RuntimeError("init failed"), Mt5TradingError("trade failed")],
|
||||
[Mt5RuntimeError("init failed")],
|
||||
)
|
||||
def test_is_recoverable_mt5_error(exc: Exception) -> None:
|
||||
"""Recoverable MT5 errors are classified consistently."""
|
||||
@@ -250,12 +257,11 @@ def test_is_recoverable_mt5_error(exc: Exception) -> None:
|
||||
("exc", "expected_type"),
|
||||
[
|
||||
(Mt5RuntimeError("x"), Mt5ConnectionError),
|
||||
(Mt5TradingError("x"), Mt5OperationError),
|
||||
],
|
||||
)
|
||||
def test_normalize_mt5_exception_maps_types(
|
||||
exc: Exception,
|
||||
expected_type: type[Mt5ConnectionError | Mt5OperationError],
|
||||
expected_type: type[Mt5ConnectionError],
|
||||
) -> None:
|
||||
"""MT5 exceptions map to stable mt5cli types."""
|
||||
assert isinstance(normalize_mt5_exception(exc), expected_type)
|
||||
@@ -339,22 +345,22 @@ def test_ensure_utc_handles_naive_and_aware_datetimes() -> None:
|
||||
assert ensure_utc("2024-01-01T00:00:00+00:00").tzinfo == UTC
|
||||
|
||||
|
||||
def test_recent_window_validation_errors() -> None:
|
||||
@pytest.mark.parametrize(
|
||||
("kwargs", "match"),
|
||||
[
|
||||
({}, "exactly one"),
|
||||
({"hours": 1, "seconds": 1}, "exactly one"),
|
||||
({"hours": 0}, "positive"),
|
||||
],
|
||||
ids=["no-duration", "both-hours-and-seconds", "non-positive-duration"],
|
||||
)
|
||||
def test_recent_window_validation_errors(
|
||||
kwargs: dict[str, object],
|
||||
match: str,
|
||||
) -> None:
|
||||
"""Recent window helpers validate mutually exclusive length arguments."""
|
||||
with pytest.raises(ValueError, match="exactly one"):
|
||||
recent_window()
|
||||
with pytest.raises(ValueError, match="exactly one"):
|
||||
recent_window(hours=1, seconds=1)
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
recent_window(hours=0)
|
||||
|
||||
|
||||
def test_recent_window_supports_seconds_argument() -> None:
|
||||
"""Recent windows can be built from a seconds-based length."""
|
||||
end = datetime(2024, 1, 2, tzinfo=UTC)
|
||||
start, resolved_end = recent_window(seconds=3600, date_to=end)
|
||||
assert resolved_end == end
|
||||
assert start < end
|
||||
with pytest.raises(ValueError, match=match):
|
||||
recent_window(**kwargs) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_parse_date_range_returns_ordered_bounds() -> None:
|
||||
|
||||
+67
-50
@@ -4,72 +4,89 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
_EXAMPLES_DIR = Path(__file__).parent.parent / "examples" / "grafana"
|
||||
_DASHBOARDS_DIR = _EXAMPLES_DIR / "dashboards"
|
||||
|
||||
|
||||
def _dashboard_json_files() -> list[Path]:
|
||||
"""Return bundled Grafana dashboard JSON files in deterministic order."""
|
||||
return sorted(_DASHBOARDS_DIR.glob("*.json"))
|
||||
|
||||
|
||||
@pytest.fixture(params=_dashboard_json_files(), ids=lambda path: path.name)
|
||||
def dashboard_path(request: pytest.FixtureRequest) -> Iterator[Path]:
|
||||
"""Yield one bundled Grafana dashboard JSON file per test case."""
|
||||
return request.param
|
||||
|
||||
|
||||
class TestGrafanaExamples:
|
||||
"""Validate structure and content of bundled Grafana example files."""
|
||||
|
||||
def test_dashboard_json_files_are_valid_json(self) -> None:
|
||||
"""All dashboard JSON files parse without error."""
|
||||
paths = list(_DASHBOARDS_DIR.glob("*.json"))
|
||||
assert paths, "No dashboard JSON files found"
|
||||
for path in paths:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
obj = json.loads(content)
|
||||
assert isinstance(obj, dict), f"{path.name} root must be a JSON object"
|
||||
def test_dashboard_json_files_are_present(self) -> None:
|
||||
"""At least one dashboard JSON file is bundled."""
|
||||
assert _dashboard_json_files(), "No dashboard JSON files found"
|
||||
|
||||
def test_dashboard_json_has_no_private_placeholders(self) -> None:
|
||||
def test_dashboard_json_file_is_valid_json(self, dashboard_path: Path) -> None:
|
||||
"""Each dashboard JSON file parses to a JSON object."""
|
||||
content = dashboard_path.read_text(encoding="utf-8")
|
||||
obj = json.loads(content)
|
||||
assert isinstance(obj, dict), (
|
||||
f"{dashboard_path.name} root must be a JSON object"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("private_pattern", ["password", "api_key", "apikey"])
|
||||
def test_dashboard_json_has_no_private_placeholders(
|
||||
self,
|
||||
dashboard_path: Path,
|
||||
private_pattern: str,
|
||||
) -> None:
|
||||
"""Dashboard JSON files contain no obvious credential placeholders."""
|
||||
private_patterns = ["password", "api_key", "apikey"]
|
||||
for path in _DASHBOARDS_DIR.glob("*.json"):
|
||||
content = path.read_text(encoding="utf-8").lower()
|
||||
for pat in private_patterns:
|
||||
assert pat not in content, f"{path.name} contains {pat!r}"
|
||||
content = dashboard_path.read_text(encoding="utf-8").lower()
|
||||
assert private_pattern not in content, (
|
||||
f"{dashboard_path.name} contains {private_pattern!r}"
|
||||
)
|
||||
|
||||
def test_dashboard_json_uses_grafana_views(self) -> None:
|
||||
"""All dashboard JSON files query grafana_* views."""
|
||||
for path in _DASHBOARDS_DIR.glob("*.json"):
|
||||
content = path.read_text(encoding="utf-8")
|
||||
assert "grafana_" in content, (
|
||||
f"{path.name} must contain queries against grafana_* views"
|
||||
)
|
||||
def test_dashboard_json_uses_grafana_views(self, dashboard_path: Path) -> None:
|
||||
"""Each dashboard JSON file queries grafana_* views."""
|
||||
content = dashboard_path.read_text(encoding="utf-8")
|
||||
assert "grafana_" in content, (
|
||||
f"{dashboard_path.name} must contain queries against grafana_* views"
|
||||
)
|
||||
|
||||
def test_dashboard_json_has_uid(self) -> None:
|
||||
"""All dashboard JSON files have a non-empty uid field."""
|
||||
for path in _DASHBOARDS_DIR.glob("*.json"):
|
||||
obj = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert obj.get("uid"), f"{path.name} must have a uid"
|
||||
|
||||
def test_dashboard_json_has_title(self) -> None:
|
||||
"""All dashboard JSON files have a non-empty title field."""
|
||||
for path in _DASHBOARDS_DIR.glob("*.json"):
|
||||
obj = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert obj.get("title"), f"{path.name} must have a title"
|
||||
@pytest.mark.parametrize("field", ["uid", "title"])
|
||||
def test_dashboard_json_has_required_field(
|
||||
self,
|
||||
dashboard_path: Path,
|
||||
field: str,
|
||||
) -> None:
|
||||
"""Each dashboard JSON file has a non-empty uid and title field."""
|
||||
obj = json.loads(dashboard_path.read_text(encoding="utf-8"))
|
||||
assert obj.get(field), f"{dashboard_path.name} must have a {field}"
|
||||
|
||||
def test_expected_dashboards_present(self) -> None:
|
||||
"""The three expected dashboard files are present."""
|
||||
names = {p.name for p in _DASHBOARDS_DIR.glob("*.json")}
|
||||
names = {p.name for p in _dashboard_json_files()}
|
||||
assert "mt5cli-overview.json" in names
|
||||
assert "mt5cli-trades.json" in names
|
||||
assert "mt5cli-market.json" in names
|
||||
|
||||
def test_readme_exists(self) -> None:
|
||||
"""examples/grafana/README.md is present."""
|
||||
assert (_EXAMPLES_DIR / "README.md").is_file()
|
||||
|
||||
def test_compose_file_exists(self) -> None:
|
||||
"""examples/grafana/compose.yml is present."""
|
||||
assert (_EXAMPLES_DIR / "compose.yml").is_file()
|
||||
|
||||
def test_datasource_provisioning_exists(self) -> None:
|
||||
"""Datasource provisioning YAML is present."""
|
||||
assert (
|
||||
_EXAMPLES_DIR / "provisioning" / "datasources" / "mt5cli-sqlite.yml"
|
||||
).is_file()
|
||||
|
||||
def test_dashboard_provisioning_exists(self) -> None:
|
||||
"""Dashboard provisioning YAML is present."""
|
||||
assert (_EXAMPLES_DIR / "provisioning" / "dashboards" / "mt5cli.yml").is_file()
|
||||
@pytest.mark.parametrize(
|
||||
"relative_path",
|
||||
[
|
||||
"README.md",
|
||||
"compose.yml",
|
||||
"provisioning/datasources/mt5cli-sqlite.yml",
|
||||
"provisioning/dashboards/mt5cli.yml",
|
||||
],
|
||||
ids=["readme", "compose", "datasource-provisioning", "dashboard-provisioning"],
|
||||
)
|
||||
def test_expected_file_exists(self, relative_path: str) -> None:
|
||||
"""Bundled Grafana example support files are present."""
|
||||
assert (_EXAMPLES_DIR / relative_path).is_file()
|
||||
|
||||
+371
-419
@@ -12,7 +12,7 @@ import pandas as pd
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Callable, Iterator
|
||||
|
||||
from mt5cli.grafana import (
|
||||
_build_snapshot_view, # type: ignore[reportPrivateUsage]
|
||||
@@ -75,6 +75,14 @@ def _make_history_deals_minimal(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _make_history_deals_symbol_pnl_minimal(conn: sqlite3.Connection) -> None:
|
||||
"""history_deals with entry but without volume and price columns."""
|
||||
conn.execute(
|
||||
"CREATE TABLE history_deals"
|
||||
" (time TEXT, symbol TEXT, profit REAL, type INTEGER, entry INTEGER)"
|
||||
)
|
||||
|
||||
|
||||
def _make_history_orders_table(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"CREATE TABLE history_orders"
|
||||
@@ -82,6 +90,9 @@ def _make_history_orders_table(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
|
||||
|
||||
_TIMESTAMP_TIME_SETUP: pd.Timestamp = pd.Timestamp("2024-01-15 10:30:00", tz="UTC")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestSnapshotTables
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -204,169 +215,97 @@ class TestGrafanaViews:
|
||||
create_grafana_views(conn)
|
||||
assert "grafana_rates" not in _get_names(conn, "view")
|
||||
|
||||
def test_grafana_rates_skipped_when_required_cols_missing(
|
||||
@pytest.mark.parametrize(
|
||||
("ddl", "view_name"),
|
||||
[
|
||||
("CREATE TABLE rates (open REAL)", "grafana_rates"),
|
||||
("CREATE TABLE ticks (bid REAL)", "grafana_ticks"),
|
||||
("CREATE TABLE history_deals (symbol TEXT)", "grafana_history_deals"),
|
||||
("CREATE TABLE history_orders (symbol TEXT)", "grafana_history_orders"),
|
||||
("CREATE TABLE history_deals (symbol TEXT)", "grafana_trade_deals"),
|
||||
("CREATE TABLE history_deals (symbol TEXT)", "grafana_cash_events"),
|
||||
(
|
||||
"CREATE TABLE history_deals (time TEXT, type INTEGER)",
|
||||
"grafana_realized_pnl",
|
||||
),
|
||||
(
|
||||
(
|
||||
"CREATE TABLE history_deals"
|
||||
" (time TEXT, symbol TEXT, profit REAL, type INTEGER)"
|
||||
),
|
||||
"grafana_realized_pnl",
|
||||
),
|
||||
(
|
||||
"CREATE TABLE history_deals (time TEXT, type INTEGER)",
|
||||
"grafana_symbol_pnl",
|
||||
),
|
||||
("CREATE TABLE history_deals (time TEXT)", "grafana_trade_stats"),
|
||||
],
|
||||
ids=[
|
||||
"rates-cols",
|
||||
"ticks-cols",
|
||||
"history_deals-time",
|
||||
"history_orders-time_setup",
|
||||
"trade_deals-cols",
|
||||
"cash_events-cols",
|
||||
"realized_pnl-cols",
|
||||
"realized_pnl-entry",
|
||||
"symbol_pnl-cols",
|
||||
"trade_stats-cols",
|
||||
],
|
||||
)
|
||||
def test_grafana_view_skipped_when_required_cols_missing(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
ddl: str,
|
||||
view_name: str,
|
||||
) -> None:
|
||||
"""grafana_rates is skipped when rates table lacks required columns."""
|
||||
conn.execute("CREATE TABLE rates (open REAL)")
|
||||
"""A grafana view is skipped (with warning) when source columns are missing."""
|
||||
conn.execute(ddl)
|
||||
with caplog.at_level(logging.WARNING, logger="mt5cli.grafana"):
|
||||
create_grafana_views(conn)
|
||||
assert "grafana_rates" not in _get_names(conn, "view")
|
||||
assert "Skipping grafana_rates" in caplog.text
|
||||
assert view_name not in _get_names(conn, "view")
|
||||
assert f"Skipping {view_name}" in caplog.text
|
||||
|
||||
def test_grafana_ticks_skipped_when_cols_missing(
|
||||
@pytest.mark.parametrize(
|
||||
("setup_deals", "optional_cols"),
|
||||
[
|
||||
pytest.param(
|
||||
_make_history_deals_symbol_pnl_minimal, set[str](), id="minimal"
|
||||
),
|
||||
pytest.param(
|
||||
_make_history_deals_full,
|
||||
{"volume", "price"},
|
||||
id="full",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_grafana_symbol_pnl_schema(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
setup_deals: Callable[[sqlite3.Connection], None],
|
||||
optional_cols: set[str],
|
||||
) -> None:
|
||||
"""grafana_ticks is skipped when ticks table lacks required columns."""
|
||||
conn.execute("CREATE TABLE ticks (bid REAL)")
|
||||
with caplog.at_level(logging.WARNING, logger="mt5cli.grafana"):
|
||||
create_grafana_views(conn)
|
||||
assert "grafana_ticks" not in _get_names(conn, "view")
|
||||
assert "Skipping grafana_ticks" in caplog.text
|
||||
|
||||
def test_grafana_history_deals_skipped_when_time_missing(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""grafana_history_deals is skipped when history_deals.time is missing."""
|
||||
conn.execute("CREATE TABLE history_deals (symbol TEXT)")
|
||||
with caplog.at_level(logging.WARNING, logger="mt5cli.grafana"):
|
||||
create_grafana_views(conn)
|
||||
assert "grafana_history_deals" not in _get_names(conn, "view")
|
||||
assert "Skipping grafana_history_deals" in caplog.text
|
||||
|
||||
def test_grafana_history_orders_skipped_when_time_setup_missing(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""grafana_history_orders is skipped when time_setup is absent."""
|
||||
conn.execute("CREATE TABLE history_orders (symbol TEXT)")
|
||||
with caplog.at_level(logging.WARNING, logger="mt5cli.grafana"):
|
||||
create_grafana_views(conn)
|
||||
assert "grafana_history_orders" not in _get_names(conn, "view")
|
||||
assert "Skipping grafana_history_orders" in caplog.text
|
||||
|
||||
def test_grafana_trade_deals_skipped_when_cols_missing(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""grafana_trade_deals is skipped when history_deals missing time/type."""
|
||||
conn.execute("CREATE TABLE history_deals (symbol TEXT)")
|
||||
with caplog.at_level(logging.WARNING, logger="mt5cli.grafana"):
|
||||
create_grafana_views(conn)
|
||||
assert "grafana_trade_deals" not in _get_names(conn, "view")
|
||||
|
||||
def test_grafana_cash_events_skipped_when_cols_missing(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""grafana_cash_events is skipped when history_deals missing time/type."""
|
||||
conn.execute("CREATE TABLE history_deals (symbol TEXT)")
|
||||
with caplog.at_level(logging.WARNING, logger="mt5cli.grafana"):
|
||||
create_grafana_views(conn)
|
||||
assert "grafana_cash_events" not in _get_names(conn, "view")
|
||||
|
||||
def test_grafana_realized_pnl_skipped_when_cols_missing(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""grafana_realized_pnl is skipped when history_deals missing required cols."""
|
||||
conn.execute("CREATE TABLE history_deals (time TEXT, type INTEGER)")
|
||||
with caplog.at_level(logging.WARNING, logger="mt5cli.grafana"):
|
||||
create_grafana_views(conn)
|
||||
assert "grafana_realized_pnl" not in _get_names(conn, "view")
|
||||
assert "Skipping grafana_realized_pnl" in caplog.text
|
||||
|
||||
def test_grafana_realized_pnl_skipped_when_entry_missing(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""grafana_realized_pnl is skipped when entry column is absent."""
|
||||
_make_history_deals_minimal(conn)
|
||||
with caplog.at_level(logging.WARNING, logger="mt5cli.grafana"):
|
||||
create_grafana_views(conn)
|
||||
assert "grafana_realized_pnl" not in _get_names(conn, "view")
|
||||
assert "Skipping grafana_realized_pnl" in caplog.text
|
||||
|
||||
def test_grafana_symbol_pnl_skipped_when_required_cols_missing(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""grafana_symbol_pnl is skipped when required columns are absent."""
|
||||
conn.execute("CREATE TABLE history_deals (time TEXT, type INTEGER)")
|
||||
with caplog.at_level(logging.WARNING, logger="mt5cli.grafana"):
|
||||
create_grafana_views(conn)
|
||||
assert "grafana_symbol_pnl" not in _get_names(conn, "view")
|
||||
assert "Skipping grafana_symbol_pnl" in caplog.text
|
||||
|
||||
def test_grafana_symbol_pnl_without_volume_and_price(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
) -> None:
|
||||
"""grafana_symbol_pnl is created with only required columns."""
|
||||
conn.execute(
|
||||
"CREATE TABLE history_deals"
|
||||
" (time TEXT, symbol TEXT, profit REAL, type INTEGER, entry INTEGER)"
|
||||
)
|
||||
"""grafana_symbol_pnl is created and includes optional columns when present."""
|
||||
setup_deals(conn)
|
||||
create_grafana_views(conn)
|
||||
assert "grafana_symbol_pnl" in _get_names(conn, "view")
|
||||
|
||||
def test_grafana_symbol_pnl_with_volume_and_price(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
) -> None:
|
||||
"""grafana_symbol_pnl includes volume and price columns when present."""
|
||||
_make_history_deals_full(conn)
|
||||
create_grafana_views(conn)
|
||||
assert "grafana_symbol_pnl" in _get_names(conn, "view")
|
||||
# View columns include volume and price
|
||||
cols = {row[1] for row in conn.execute("PRAGMA table_info(grafana_symbol_pnl)")}
|
||||
assert "volume" in cols
|
||||
assert "price" in cols
|
||||
assert optional_cols.issubset(cols)
|
||||
|
||||
def test_grafana_trade_stats_skipped_when_cols_missing(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""grafana_trade_stats is skipped when history_deals missing required cols."""
|
||||
conn.execute("CREATE TABLE history_deals (time TEXT)")
|
||||
with caplog.at_level(logging.WARNING, logger="mt5cli.grafana"):
|
||||
create_grafana_views(conn)
|
||||
assert "grafana_trade_stats" not in _get_names(conn, "view")
|
||||
assert "Skipping grafana_trade_stats" in caplog.text
|
||||
|
||||
def test_grafana_trade_stats_without_entry_col(
|
||||
@pytest.mark.parametrize(
|
||||
"setup_deals",
|
||||
[_make_history_deals_minimal, _make_history_deals_full],
|
||||
ids=["minimal", "full"],
|
||||
)
|
||||
def test_grafana_trade_stats_static_summary(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
setup_deals: Callable[[sqlite3.Connection], None],
|
||||
) -> None:
|
||||
"""grafana_trade_stats is a static summary view with no time column."""
|
||||
_make_history_deals_minimal(conn)
|
||||
create_grafana_views(conn)
|
||||
assert "grafana_trade_stats" in _get_names(conn, "view")
|
||||
cols = {
|
||||
row[1] for row in conn.execute("PRAGMA table_info(grafana_trade_stats)")
|
||||
}
|
||||
assert "time" not in cols
|
||||
assert "symbol" in cols
|
||||
|
||||
def test_grafana_trade_stats_with_entry_col(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
) -> None:
|
||||
"""grafana_trade_stats is a static summary view with no time column."""
|
||||
_make_history_deals_full(conn)
|
||||
setup_deals(conn)
|
||||
create_grafana_views(conn)
|
||||
assert "grafana_trade_stats" in _get_names(conn, "view")
|
||||
cols = {
|
||||
@@ -405,116 +344,126 @@ class TestGrafanaViews:
|
||||
assert "time" in cols
|
||||
assert "run_id" in cols
|
||||
|
||||
def test_build_snapshot_view_skips_when_snapshot_runs_missing(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
) -> None:
|
||||
"""_build_snapshot_view skips view when snapshot_runs has wrong columns."""
|
||||
conn.execute("CREATE TABLE only_run (run_id INTEGER NOT NULL)")
|
||||
conn.execute("CREATE TABLE snapshot_runs (foo TEXT)")
|
||||
_build_snapshot_view(conn, "test_view", "only_run")
|
||||
views = _get_names(conn, "view")
|
||||
assert "test_view" not in views
|
||||
|
||||
def test_build_snapshot_view_skips_when_run_id_col_missing(
|
||||
@pytest.mark.parametrize(
|
||||
("use_snapshot_tables", "table_ddl", "table_name", "log_fragment"),
|
||||
[
|
||||
pytest.param(
|
||||
False,
|
||||
"CREATE TABLE only_run (run_id INTEGER NOT NULL)",
|
||||
"only_run",
|
||||
"snapshot_runs missing required columns",
|
||||
id="snapshot-runs-wrong-columns",
|
||||
),
|
||||
pytest.param(
|
||||
True,
|
||||
"CREATE TABLE no_run_id (symbol TEXT)",
|
||||
"no_run_id",
|
||||
"missing run_id column",
|
||||
id="table-missing-run-id",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_build_snapshot_view_skips_negative_cases(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
use_snapshot_tables: bool,
|
||||
table_ddl: str,
|
||||
table_name: str,
|
||||
log_fragment: str,
|
||||
) -> None:
|
||||
"""_build_snapshot_view skips view when the table lacks run_id."""
|
||||
create_snapshot_tables(conn)
|
||||
conn.execute("CREATE TABLE no_run_id (symbol TEXT)")
|
||||
"""_build_snapshot_view skips view creation for invalid table/run metadata."""
|
||||
if use_snapshot_tables:
|
||||
create_snapshot_tables(conn)
|
||||
else:
|
||||
conn.execute("CREATE TABLE snapshot_runs (foo TEXT)")
|
||||
conn.execute(table_ddl)
|
||||
with caplog.at_level(logging.WARNING, logger="mt5cli.grafana"):
|
||||
_build_snapshot_view(conn, "test_view", "no_run_id")
|
||||
_build_snapshot_view(conn, "test_view", table_name)
|
||||
assert "test_view" not in _get_names(conn, "view")
|
||||
assert "missing run_id column" in caplog.text
|
||||
assert log_fragment in caplog.text
|
||||
|
||||
def test_snapshot_view_excludes_failed_run_rows(
|
||||
@pytest.mark.parametrize(
|
||||
("started_at", "status", "message", "keep_row"),
|
||||
[
|
||||
pytest.param(
|
||||
1000,
|
||||
"error",
|
||||
"terminal offline",
|
||||
False,
|
||||
id="excludes-failed-run-rows",
|
||||
),
|
||||
pytest.param(2000, "ok", None, True, id="includes-ok-run-rows"),
|
||||
],
|
||||
)
|
||||
def test_snapshot_view_filters_rows_by_run_status(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
started_at: int,
|
||||
status: str,
|
||||
message: str | None,
|
||||
keep_row: bool,
|
||||
) -> None:
|
||||
"""Snapshot views hide rows from failed runs."""
|
||||
"""Snapshot views expose rows only from successful runs."""
|
||||
create_snapshot_tables(conn)
|
||||
run_id = start_snapshot_run(conn, 1000)
|
||||
run_id = start_snapshot_run(conn, started_at)
|
||||
conn.execute(
|
||||
"INSERT INTO account_snapshots"
|
||||
" (run_id, login, balance, equity, margin, margin_free, profit)"
|
||||
" VALUES (?, 12345, 10000.0, 9800.0, 200.0, 9600.0, -200.0)",
|
||||
(run_id,),
|
||||
)
|
||||
record_snapshot_run(conn, run_id, "error", "terminal offline")
|
||||
create_grafana_views(conn)
|
||||
rows = conn.execute("SELECT * FROM grafana_account_snapshots").fetchall()
|
||||
assert rows == []
|
||||
|
||||
def test_snapshot_view_includes_ok_run_rows(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
) -> None:
|
||||
"""Snapshot views show rows from successful runs and expose run_id."""
|
||||
create_snapshot_tables(conn)
|
||||
run_id = start_snapshot_run(conn, 2000)
|
||||
conn.execute(
|
||||
"INSERT INTO account_snapshots"
|
||||
" (run_id, login, balance, equity, margin, margin_free, profit)"
|
||||
" VALUES (?, 12345, 10000.0, 9800.0, 200.0, 9600.0, -200.0)",
|
||||
(run_id,),
|
||||
)
|
||||
record_snapshot_run(conn, run_id, "ok")
|
||||
record_snapshot_run(conn, run_id, status, message)
|
||||
create_grafana_views(conn)
|
||||
rows = conn.execute(
|
||||
"SELECT time, run_id, login FROM grafana_account_snapshots"
|
||||
).fetchall()
|
||||
assert rows == [(2000, run_id, 12345)]
|
||||
cols = {
|
||||
row[1]
|
||||
for row in conn.execute("PRAGMA table_info(grafana_account_snapshots)")
|
||||
}
|
||||
assert "run_id" in cols
|
||||
if keep_row:
|
||||
assert rows == [(started_at, run_id, 12345)]
|
||||
cols = {
|
||||
row[1]
|
||||
for row in conn.execute("PRAGMA table_info(grafana_account_snapshots)")
|
||||
}
|
||||
assert "run_id" in cols
|
||||
else:
|
||||
assert rows == []
|
||||
|
||||
def test_snapshot_view_same_second_ok_and_error_no_cross_contamination(
|
||||
@pytest.mark.parametrize(
|
||||
("observed_at", "runs", "expected_rows"),
|
||||
[
|
||||
pytest.param(
|
||||
3000,
|
||||
[("error", 99), ("ok", 12345)],
|
||||
[(12345,)],
|
||||
id="same-second-error-and-ok-exposes-only-ok-row",
|
||||
),
|
||||
pytest.param(
|
||||
4000,
|
||||
[("ok", 1), ("ok", 2)],
|
||||
[(1,), (2,)],
|
||||
id="same-second-two-ok-runs-no-duplication",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_snapshot_view_same_second_runs(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
observed_at: int,
|
||||
runs: list[tuple[str, int]],
|
||||
expected_rows: list[tuple[int]],
|
||||
) -> None:
|
||||
"""An ok and error run sharing observed_at expose only the ok run's rows."""
|
||||
"""Snapshot views join same-second rows by run_id."""
|
||||
create_snapshot_tables(conn)
|
||||
run_err = start_snapshot_run(conn, 3000)
|
||||
conn.execute(
|
||||
"INSERT INTO account_snapshots (run_id, login) VALUES (?, 99)",
|
||||
(run_err,),
|
||||
)
|
||||
record_snapshot_run(conn, run_err, "error")
|
||||
run_ok = start_snapshot_run(conn, 3000)
|
||||
conn.execute(
|
||||
"INSERT INTO account_snapshots (run_id, login) VALUES (?, 12345)",
|
||||
(run_ok,),
|
||||
)
|
||||
record_snapshot_run(conn, run_ok, "ok")
|
||||
for status, login in runs:
|
||||
run_id = start_snapshot_run(conn, observed_at)
|
||||
conn.execute(
|
||||
"INSERT INTO account_snapshots (run_id, login) VALUES (?, ?)",
|
||||
(run_id, login),
|
||||
)
|
||||
record_snapshot_run(conn, run_id, status)
|
||||
create_grafana_views(conn)
|
||||
rows = conn.execute("SELECT login FROM grafana_account_snapshots").fetchall()
|
||||
assert rows == [(12345,)]
|
||||
|
||||
def test_snapshot_view_two_ok_runs_same_second_no_duplication(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
) -> None:
|
||||
"""Two ok runs sharing observed_at each produce exactly one row in the view."""
|
||||
create_snapshot_tables(conn)
|
||||
run1 = start_snapshot_run(conn, 4000)
|
||||
conn.execute(
|
||||
"INSERT INTO account_snapshots (run_id, login) VALUES (?, 1)",
|
||||
(run1,),
|
||||
)
|
||||
record_snapshot_run(conn, run1, "ok")
|
||||
run2 = start_snapshot_run(conn, 4000)
|
||||
conn.execute(
|
||||
"INSERT INTO account_snapshots (run_id, login) VALUES (?, 2)",
|
||||
(run2,),
|
||||
)
|
||||
record_snapshot_run(conn, run2, "ok")
|
||||
create_grafana_views(conn)
|
||||
rows = conn.execute("SELECT login FROM grafana_account_snapshots").fetchall()
|
||||
assert len(rows) == 2
|
||||
assert rows == expected_rows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -569,45 +518,32 @@ class TestGrafanaIndexes:
|
||||
assert "idx_order_snapshots_time_symbol" not in indexes
|
||||
assert "idx_snapshot_runs_time_status" not in indexes
|
||||
|
||||
def test_rates_index_skipped_when_cols_missing(
|
||||
@pytest.mark.parametrize(
|
||||
("ddl", "index_name"),
|
||||
[
|
||||
("CREATE TABLE rates (open REAL)", "idx_rates_time_symbol_timeframe"),
|
||||
("CREATE TABLE ticks (bid REAL)", "idx_ticks_time_symbol"),
|
||||
(
|
||||
"CREATE TABLE history_deals (ticket INTEGER)",
|
||||
"idx_history_deals_time_symbol",
|
||||
),
|
||||
(
|
||||
"CREATE TABLE history_orders (ticket INTEGER)",
|
||||
"idx_history_orders_time_setup_symbol",
|
||||
),
|
||||
],
|
||||
ids=["rates", "ticks", "deals", "orders"],
|
||||
)
|
||||
def test_index_skipped_when_cols_missing(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
ddl: str,
|
||||
index_name: str,
|
||||
) -> None:
|
||||
"""Rates index is skipped when required columns are absent."""
|
||||
conn.execute("CREATE TABLE rates (open REAL)")
|
||||
"""An index is skipped when the source table lacks required columns."""
|
||||
conn.execute(ddl)
|
||||
create_grafana_indexes(conn)
|
||||
indexes = _get_names(conn, "index")
|
||||
assert "idx_rates_time_symbol_timeframe" not in indexes
|
||||
|
||||
def test_ticks_index_skipped_when_cols_missing(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
) -> None:
|
||||
"""Ticks index is skipped when required columns are absent."""
|
||||
conn.execute("CREATE TABLE ticks (bid REAL)")
|
||||
create_grafana_indexes(conn)
|
||||
indexes = _get_names(conn, "index")
|
||||
assert "idx_ticks_time_symbol" not in indexes
|
||||
|
||||
def test_deals_indexes_skipped_when_cols_missing(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
) -> None:
|
||||
"""history_deals indexes are skipped when required columns are absent."""
|
||||
conn.execute("CREATE TABLE history_deals (ticket INTEGER)")
|
||||
create_grafana_indexes(conn)
|
||||
indexes = _get_names(conn, "index")
|
||||
assert "idx_history_deals_time_symbol" not in indexes
|
||||
|
||||
def test_orders_index_skipped_when_cols_missing(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
) -> None:
|
||||
"""history_orders index is skipped when required columns are absent."""
|
||||
conn.execute("CREATE TABLE history_orders (ticket INTEGER)")
|
||||
create_grafana_indexes(conn)
|
||||
indexes = _get_names(conn, "index")
|
||||
assert "idx_history_orders_time_setup_symbol" not in indexes
|
||||
assert index_name not in _get_names(conn, "index")
|
||||
|
||||
def test_snapshot_indexes_skipped_when_cols_missing(
|
||||
self,
|
||||
@@ -678,25 +614,56 @@ class TestSnapshotInserts:
|
||||
"""Create snapshot tables before each insert test."""
|
||||
create_snapshot_tables(conn)
|
||||
|
||||
def test_insert_account_snapshot(self, conn: sqlite3.Connection) -> None:
|
||||
"""insert_account_snapshot appends a row with correct values."""
|
||||
@pytest.mark.parametrize(
|
||||
("insert_func", "row", "select_sql", "expected"),
|
||||
[
|
||||
(
|
||||
insert_account_snapshot,
|
||||
{
|
||||
"login": 12345,
|
||||
"currency": "USD",
|
||||
"balance": 10000.0,
|
||||
"equity": 9800.0,
|
||||
"margin": 200.0,
|
||||
"margin_free": 9800.0,
|
||||
"margin_level": 4900.0,
|
||||
"profit": -200.0,
|
||||
"leverage": 100,
|
||||
},
|
||||
"SELECT login, currency, balance FROM account_snapshots",
|
||||
(12345, "USD", 10000.0),
|
||||
),
|
||||
(
|
||||
insert_terminal_snapshot,
|
||||
{
|
||||
"name": "MetaTrader 5",
|
||||
"connected": 1,
|
||||
"community_account": 0,
|
||||
"trade_allowed": 1,
|
||||
"trade_expert": 1,
|
||||
"path": "/mt5",
|
||||
"company": "Broker",
|
||||
"language": "en",
|
||||
},
|
||||
"SELECT name, connected FROM terminal_snapshots",
|
||||
("MetaTrader 5", 1),
|
||||
),
|
||||
],
|
||||
ids=["account", "terminal"],
|
||||
)
|
||||
def test_insert_single_snapshot(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
insert_func: Callable[[sqlite3.Connection, int, dict[str, object]], None],
|
||||
row: dict[str, object],
|
||||
select_sql: str,
|
||||
expected: tuple[object, ...],
|
||||
) -> None:
|
||||
"""insert_account_snapshot and insert_terminal_snapshot append one row."""
|
||||
run_id = start_snapshot_run(conn, 1700000000)
|
||||
row: dict[str, object] = {
|
||||
"login": 12345,
|
||||
"currency": "USD",
|
||||
"balance": 10000.0,
|
||||
"equity": 9800.0,
|
||||
"margin": 200.0,
|
||||
"margin_free": 9800.0,
|
||||
"margin_level": 4900.0,
|
||||
"profit": -200.0,
|
||||
"leverage": 100,
|
||||
}
|
||||
insert_account_snapshot(conn, run_id, row)
|
||||
result = conn.execute(
|
||||
"SELECT login, currency, balance FROM account_snapshots"
|
||||
).fetchone()
|
||||
assert result == (12345, "USD", 10000.0)
|
||||
insert_func(conn, run_id, row)
|
||||
result = conn.execute(select_sql).fetchone()
|
||||
assert result == expected
|
||||
|
||||
def test_insert_account_snapshot_partial_row(
|
||||
self,
|
||||
@@ -710,105 +677,91 @@ class TestSnapshotInserts:
|
||||
).fetchone()
|
||||
assert result == (1, None)
|
||||
|
||||
def test_insert_position_snapshots_with_rows(
|
||||
@pytest.mark.parametrize(
|
||||
("insert_func", "table", "rows", "expected_count"),
|
||||
[
|
||||
(
|
||||
insert_position_snapshots,
|
||||
"position_snapshots",
|
||||
[
|
||||
{"ticket": 1, "symbol": "EURUSD", "volume": 0.1, "profit": 10.0},
|
||||
{"ticket": 2, "symbol": "GBPUSD", "volume": 0.2, "profit": -5.0},
|
||||
],
|
||||
2,
|
||||
),
|
||||
(
|
||||
insert_position_snapshots,
|
||||
"position_snapshots",
|
||||
[],
|
||||
0,
|
||||
),
|
||||
(
|
||||
insert_order_snapshots,
|
||||
"order_snapshots",
|
||||
[
|
||||
{
|
||||
"ticket": 10,
|
||||
"symbol": "EURUSD",
|
||||
"type": 2,
|
||||
"volume_current": 0.1,
|
||||
},
|
||||
],
|
||||
1,
|
||||
),
|
||||
(
|
||||
insert_order_snapshots,
|
||||
"order_snapshots",
|
||||
[],
|
||||
0,
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"positions-with-rows",
|
||||
"positions-empty-noop",
|
||||
"orders-with-rows",
|
||||
"orders-empty-noop",
|
||||
],
|
||||
)
|
||||
def test_insert_snapshot_rows(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
insert_func: Callable[
|
||||
[sqlite3.Connection, int, int | None, list[dict[str, object]]],
|
||||
None,
|
||||
],
|
||||
table: str,
|
||||
rows: list[dict[str, object]],
|
||||
expected_count: int,
|
||||
) -> None:
|
||||
"""insert_position_snapshots appends each position row."""
|
||||
"""insert_*_snapshots appends each row and is a no-op when empty."""
|
||||
run_id = start_snapshot_run(conn, 1700000000)
|
||||
rows: list[dict[str, object]] = [
|
||||
{"ticket": 1, "symbol": "EURUSD", "volume": 0.1, "profit": 10.0},
|
||||
{"ticket": 2, "symbol": "GBPUSD", "volume": 0.2, "profit": -5.0},
|
||||
]
|
||||
insert_position_snapshots(conn, run_id, 12345, rows)
|
||||
count = conn.execute("SELECT COUNT(*) FROM position_snapshots").fetchone()[0]
|
||||
assert count == 2
|
||||
insert_func(conn, run_id, 12345, rows)
|
||||
count = conn.execute(
|
||||
f"SELECT COUNT(*) FROM {table}" # noqa: S608
|
||||
).fetchone()[0]
|
||||
assert count == expected_count
|
||||
|
||||
def test_insert_position_snapshots_noop_when_empty(
|
||||
@pytest.mark.parametrize(
|
||||
("time_setup", "expected_stored"),
|
||||
[
|
||||
(_TIMESTAMP_TIME_SETUP, int(_TIMESTAMP_TIME_SETUP.timestamp())),
|
||||
(1705314600, 1705314600),
|
||||
("not_a_time", None),
|
||||
],
|
||||
ids=["timestamp", "int", "unknown-string"],
|
||||
)
|
||||
def test_insert_order_snapshots_normalizes_time_setup(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
time_setup: object,
|
||||
expected_stored: int | None,
|
||||
) -> None:
|
||||
"""insert_position_snapshots is a no-op when rows is empty."""
|
||||
"""insert_order_snapshots stores epoch int, int as-is, or None for unknown."""
|
||||
run_id = start_snapshot_run(conn, 1700000000)
|
||||
insert_position_snapshots(conn, run_id, 12345, [])
|
||||
count = conn.execute("SELECT COUNT(*) FROM position_snapshots").fetchone()[0]
|
||||
assert count == 0
|
||||
|
||||
def test_insert_order_snapshots_with_rows(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
) -> None:
|
||||
"""insert_order_snapshots appends each order row."""
|
||||
run_id = start_snapshot_run(conn, 1700000000)
|
||||
rows: list[dict[str, object]] = [
|
||||
{"ticket": 10, "symbol": "EURUSD", "type": 2, "volume_current": 0.1},
|
||||
]
|
||||
insert_order_snapshots(conn, run_id, 12345, rows)
|
||||
count = conn.execute("SELECT COUNT(*) FROM order_snapshots").fetchone()[0]
|
||||
assert count == 1
|
||||
|
||||
def test_insert_order_snapshots_noop_when_empty(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
) -> None:
|
||||
"""insert_order_snapshots is a no-op when rows is empty."""
|
||||
run_id = start_snapshot_run(conn, 1700000000)
|
||||
insert_order_snapshots(conn, run_id, 12345, [])
|
||||
count = conn.execute("SELECT COUNT(*) FROM order_snapshots").fetchone()[0]
|
||||
assert count == 0
|
||||
|
||||
def test_insert_order_snapshots_normalizes_timestamp_time_setup(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
) -> None:
|
||||
"""insert_order_snapshots converts pd.Timestamp time_setup to epoch int."""
|
||||
run_id = start_snapshot_run(conn, 1700000000)
|
||||
ts = pd.Timestamp("2024-01-15 10:30:00", tz="UTC")
|
||||
rows: list[dict[str, object]] = [{"ticket": 10, "time_setup": ts}]
|
||||
rows: list[dict[str, object]] = [{"ticket": 10, "time_setup": time_setup}]
|
||||
insert_order_snapshots(conn, run_id, 12345, rows)
|
||||
stored = conn.execute("SELECT time_setup FROM order_snapshots").fetchone()[0]
|
||||
assert stored == int(ts.timestamp())
|
||||
|
||||
def test_insert_order_snapshots_stores_int_time_setup(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
) -> None:
|
||||
"""insert_order_snapshots stores an integer time_setup as-is."""
|
||||
run_id = start_snapshot_run(conn, 1700000000)
|
||||
rows: list[dict[str, object]] = [{"ticket": 10, "time_setup": 1705314600}]
|
||||
insert_order_snapshots(conn, run_id, 12345, rows)
|
||||
stored = conn.execute("SELECT time_setup FROM order_snapshots").fetchone()[0]
|
||||
assert stored == 1705314600
|
||||
|
||||
def test_insert_order_snapshots_stores_null_for_unknown_time_setup_type(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
) -> None:
|
||||
"""insert_order_snapshots stores NULL for an unrecognized time_setup type."""
|
||||
run_id = start_snapshot_run(conn, 1700000000)
|
||||
rows: list[dict[str, object]] = [{"ticket": 10, "time_setup": "not_a_time"}]
|
||||
insert_order_snapshots(conn, run_id, 12345, rows)
|
||||
stored = conn.execute("SELECT time_setup FROM order_snapshots").fetchone()[0]
|
||||
assert stored is None
|
||||
|
||||
def test_insert_terminal_snapshot(self, conn: sqlite3.Connection) -> None:
|
||||
"""insert_terminal_snapshot appends a terminal info row."""
|
||||
run_id = start_snapshot_run(conn, 1700000000)
|
||||
row: dict[str, object] = {
|
||||
"name": "MetaTrader 5",
|
||||
"connected": 1,
|
||||
"community_account": 0,
|
||||
"trade_allowed": 1,
|
||||
"trade_expert": 1,
|
||||
"path": "/mt5",
|
||||
"company": "Broker",
|
||||
"language": "en",
|
||||
}
|
||||
insert_terminal_snapshot(conn, run_id, row)
|
||||
result = conn.execute(
|
||||
"SELECT name, connected FROM terminal_snapshots"
|
||||
).fetchone()
|
||||
assert result == ("MetaTrader 5", 1)
|
||||
assert stored == expected_stored
|
||||
|
||||
def test_start_snapshot_run_returns_incrementing_ids(
|
||||
self,
|
||||
@@ -819,25 +772,30 @@ class TestSnapshotInserts:
|
||||
run2 = start_snapshot_run(conn, 1700000000)
|
||||
assert run1 != run2
|
||||
|
||||
def test_record_snapshot_run_with_detail(
|
||||
@pytest.mark.parametrize(
|
||||
("status", "detail", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
"error",
|
||||
"RuntimeError: boom",
|
||||
("error", "RuntimeError: boom"),
|
||||
id="with-detail",
|
||||
),
|
||||
pytest.param("ok", None, ("ok", None), id="without-detail"),
|
||||
],
|
||||
)
|
||||
def test_record_snapshot_run(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
status: str,
|
||||
detail: str | None,
|
||||
expected: tuple[str, str | None],
|
||||
) -> None:
|
||||
"""record_snapshot_run stores status and detail text."""
|
||||
"""record_snapshot_run stores status and optional detail text."""
|
||||
run_id = start_snapshot_run(conn, 1700000000)
|
||||
record_snapshot_run(conn, run_id, "error", "RuntimeError: boom")
|
||||
record_snapshot_run(conn, run_id, status, detail)
|
||||
row = conn.execute("SELECT status, detail FROM snapshot_runs").fetchone()
|
||||
assert row == ("error", "RuntimeError: boom")
|
||||
|
||||
def test_record_snapshot_run_without_detail(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
) -> None:
|
||||
"""record_snapshot_run stores None for detail when omitted."""
|
||||
run_id = start_snapshot_run(conn, 1700000000)
|
||||
record_snapshot_run(conn, run_id, "ok")
|
||||
row = conn.execute("SELECT status, detail FROM snapshot_runs").fetchone()
|
||||
assert row == ("ok", None)
|
||||
assert row == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -859,23 +817,40 @@ def _make_source_db(path: Path) -> None:
|
||||
class TestPublishGrafanaCopy:
|
||||
"""Tests for publish_grafana_copy."""
|
||||
|
||||
def test_publish_to_fresh_target(self, tmp_path: Path) -> None:
|
||||
"""publish_grafana_copy creates the target file."""
|
||||
@pytest.mark.parametrize(
|
||||
("target_rel", "stale_content", "required_tables"),
|
||||
[
|
||||
pytest.param(
|
||||
Path("out") / "grafana.db",
|
||||
None,
|
||||
frozenset({"snapshot_runs", "account_snapshots"}),
|
||||
id="fresh-target",
|
||||
),
|
||||
pytest.param(
|
||||
Path("grafana.db"),
|
||||
b"stale",
|
||||
frozenset({"snapshot_runs"}),
|
||||
id="overwrite-stale-target",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_publish_creates_valid_sqlite_target(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
target_rel: Path,
|
||||
stale_content: bytes | None,
|
||||
required_tables: frozenset[str],
|
||||
) -> None:
|
||||
"""publish_grafana_copy creates or replaces a valid SQLite target."""
|
||||
source = tmp_path / "src.db"
|
||||
target = tmp_path / "out" / "grafana.db"
|
||||
target = tmp_path / target_rel
|
||||
_make_source_db(source)
|
||||
if stale_content is not None:
|
||||
target.write_bytes(stale_content)
|
||||
result = publish_grafana_copy(source, target)
|
||||
assert target.exists()
|
||||
assert isinstance(result, Path)
|
||||
assert result == target.resolve()
|
||||
|
||||
def test_overwrite_existing_target(self, tmp_path: Path) -> None:
|
||||
"""publish_grafana_copy replaces an existing target without error."""
|
||||
source = tmp_path / "src.db"
|
||||
target = tmp_path / "grafana.db"
|
||||
_make_source_db(source)
|
||||
target.write_bytes(b"stale")
|
||||
publish_grafana_copy(source, target)
|
||||
# Target must now be a valid SQLite file from source
|
||||
with sqlite3.connect(target) as conn:
|
||||
tables = {
|
||||
row[0]
|
||||
@@ -883,22 +858,7 @@ class TestPublishGrafanaCopy:
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()
|
||||
}
|
||||
assert "snapshot_runs" in tables
|
||||
|
||||
def test_target_contains_source_tables(self, tmp_path: Path) -> None:
|
||||
"""Published target contains the same tables as the source."""
|
||||
source = tmp_path / "src.db"
|
||||
target = tmp_path / "grafana.db"
|
||||
_make_source_db(source)
|
||||
publish_grafana_copy(source, target)
|
||||
with sqlite3.connect(target) as conn:
|
||||
tables = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()
|
||||
}
|
||||
assert {"snapshot_runs", "account_snapshots"}.issubset(tables)
|
||||
assert required_tables <= tables
|
||||
|
||||
def test_target_can_be_opened_readonly(self, tmp_path: Path) -> None:
|
||||
"""Published target can be opened with uri=True in read-only mode."""
|
||||
@@ -956,14 +916,6 @@ class TestPublishGrafanaCopy:
|
||||
tmp_files = list(tmp_path.glob("grafana.db.*.tmp"))
|
||||
assert not tmp_files, "Temp file should be cleaned up on failure"
|
||||
|
||||
def test_returns_path_object(self, tmp_path: Path) -> None:
|
||||
"""publish_grafana_copy returns a Path instance."""
|
||||
source = tmp_path / "src.db"
|
||||
target = tmp_path / "grafana.db"
|
||||
_make_source_db(source)
|
||||
result = publish_grafana_copy(source, target)
|
||||
assert isinstance(result, Path)
|
||||
|
||||
def test_fresh_target_has_readable_permissions(self, tmp_path: Path) -> None:
|
||||
"""Published copy is readable by the owner."""
|
||||
import stat as _stat # noqa: PLC0415
|
||||
|
||||
+1367
-350
File diff suppressed because it is too large
Load Diff
+607
-557
File diff suppressed because it is too large
Load Diff
+163
-100
@@ -20,23 +20,13 @@ from mt5cli.telemetry import (
|
||||
class TestNoOp:
|
||||
"""Tests for _NoOp no-op instrument."""
|
||||
|
||||
def test_add_is_noop(self) -> None:
|
||||
"""_NoOp.add accepts amount and optional attributes without error."""
|
||||
@pytest.mark.parametrize("method", ["add", "set", "record"])
|
||||
def test_method_is_noop(self, method: str) -> None:
|
||||
"""_NoOp methods accept amount and optional attributes without error."""
|
||||
noop = _NoOp()
|
||||
noop.add(1.0)
|
||||
noop.add(1.0, {"key": "val"})
|
||||
|
||||
def test_set_is_noop(self) -> None:
|
||||
"""_NoOp.set accepts amount and optional attributes without error."""
|
||||
noop = _NoOp()
|
||||
noop.set(2.0)
|
||||
noop.set(2.0, {"key": "val"})
|
||||
|
||||
def test_record_is_noop(self) -> None:
|
||||
"""_NoOp.record accepts amount and optional attributes without error."""
|
||||
noop = _NoOp()
|
||||
noop.record(3.0)
|
||||
noop.record(3.0, {"key": "val"})
|
||||
bound = getattr(noop, method)
|
||||
bound(1.0)
|
||||
bound(1.0, {"key": "val"})
|
||||
|
||||
|
||||
class TestMt5Metrics:
|
||||
@@ -64,32 +54,105 @@ class TestMt5Metrics:
|
||||
assert meter.create_counter.called
|
||||
assert meter.create_gauge.called
|
||||
|
||||
def test_record_history_update_success(self) -> None:
|
||||
"""record_history_update records duration and timestamp on success."""
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"method",
|
||||
"kwargs",
|
||||
"duration_attr",
|
||||
"failures_attr",
|
||||
"last_success_attr",
|
||||
),
|
||||
[
|
||||
pytest.param(
|
||||
"record_history_update",
|
||||
{"dataset": "rates"},
|
||||
"_history_duration",
|
||||
"_history_failures",
|
||||
"_last_successful_update",
|
||||
id="history-update",
|
||||
),
|
||||
pytest.param(
|
||||
"record_snapshot_update",
|
||||
{},
|
||||
"_snapshot_duration",
|
||||
"_snapshot_failures",
|
||||
None,
|
||||
id="snapshot-update",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_record_update_success(
|
||||
self,
|
||||
method: str,
|
||||
kwargs: dict[str, str],
|
||||
duration_attr: str,
|
||||
failures_attr: str,
|
||||
last_success_attr: str | None,
|
||||
) -> None:
|
||||
"""record_*_update records duration and timestamp on success."""
|
||||
meter = MagicMock()
|
||||
m = _Mt5Metrics()
|
||||
m.configure(meter)
|
||||
with m.record_history_update(dataset="rates"):
|
||||
with getattr(m, method)(**kwargs):
|
||||
pass
|
||||
m._history_duration.record.assert_called_once() # type: ignore[reportPrivateUsage]
|
||||
m._last_successful_update.set.assert_called_once() # type: ignore[reportPrivateUsage]
|
||||
m._history_failures.add.assert_not_called() # type: ignore[reportPrivateUsage]
|
||||
getattr(m, duration_attr).record.assert_called_once() # type: ignore[reportPrivateUsage]
|
||||
getattr(m, failures_attr).add.assert_not_called() # type: ignore[reportPrivateUsage]
|
||||
if last_success_attr is not None:
|
||||
getattr(m, last_success_attr).set.assert_called_once() # type: ignore[reportPrivateUsage]
|
||||
|
||||
def test_record_history_update_failure(self) -> None:
|
||||
"""record_history_update increments failure counter and re-raises on error."""
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"method",
|
||||
"kwargs",
|
||||
"exc",
|
||||
"duration_attr",
|
||||
"failures_attr",
|
||||
"failure_labels",
|
||||
),
|
||||
[
|
||||
pytest.param(
|
||||
"record_history_update",
|
||||
{"dataset": "rates"},
|
||||
ValueError("boom"),
|
||||
"_history_duration",
|
||||
"_history_failures",
|
||||
{"dataset": "rates"},
|
||||
id="history-update",
|
||||
),
|
||||
pytest.param(
|
||||
"record_snapshot_update",
|
||||
{},
|
||||
RuntimeError("snap fail"),
|
||||
"_snapshot_duration",
|
||||
"_snapshot_failures",
|
||||
{},
|
||||
id="snapshot-update",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_record_update_failure(
|
||||
self,
|
||||
method: str,
|
||||
kwargs: dict[str, str],
|
||||
exc: BaseException,
|
||||
duration_attr: str,
|
||||
failures_attr: str,
|
||||
failure_labels: dict[str, str],
|
||||
) -> None:
|
||||
"""record_*_update increments failure counter and re-raises on error."""
|
||||
meter = MagicMock()
|
||||
m = _Mt5Metrics()
|
||||
m.configure(meter)
|
||||
exc = ValueError("boom")
|
||||
with (
|
||||
pytest.raises(ValueError, match="boom"),
|
||||
m.record_history_update(dataset="rates"),
|
||||
pytest.raises(type(exc), match=str(exc)),
|
||||
getattr(m, method)(**kwargs),
|
||||
):
|
||||
raise exc
|
||||
m._history_failures.add.assert_called_once_with( # type: ignore[reportPrivateUsage]
|
||||
1, {"dataset": "rates"}
|
||||
getattr(m, failures_attr).add.assert_called_once_with( # type: ignore[reportPrivateUsage]
|
||||
1,
|
||||
failure_labels,
|
||||
)
|
||||
m._history_duration.record.assert_not_called() # type: ignore[reportPrivateUsage]
|
||||
getattr(m, duration_attr).record.assert_not_called() # type: ignore[reportPrivateUsage]
|
||||
|
||||
def test_add_history_rows(self) -> None:
|
||||
"""add_history_rows increments the rows-written counter."""
|
||||
@@ -101,82 +164,82 @@ class TestMt5Metrics:
|
||||
42, {"dataset": "rates"}
|
||||
)
|
||||
|
||||
def test_record_snapshot_update_success(self) -> None:
|
||||
"""record_snapshot_update records duration on success."""
|
||||
@pytest.mark.parametrize(
|
||||
("method", "kwargs", "gauge_attr", "expected_set_count"),
|
||||
[
|
||||
pytest.param(
|
||||
"record_position_state",
|
||||
{
|
||||
"login": "42",
|
||||
"server": "demo",
|
||||
"symbol": "EURUSD",
|
||||
"profit": 12.5,
|
||||
"volume": 0.01,
|
||||
},
|
||||
"_position_profit",
|
||||
2,
|
||||
id="position-state",
|
||||
),
|
||||
pytest.param(
|
||||
"record_terminal_state",
|
||||
{
|
||||
"connected": 1.0,
|
||||
"trade_allowed": 1.0,
|
||||
"trade_expert": 0.0,
|
||||
},
|
||||
"_terminal_connected",
|
||||
3,
|
||||
id="terminal-state",
|
||||
),
|
||||
pytest.param(
|
||||
"record_account_state",
|
||||
{
|
||||
"login": "99",
|
||||
"server": "live",
|
||||
"balance": 5000.0,
|
||||
"equity": 5100.0,
|
||||
"margin": 200.0,
|
||||
"margin_free": 4800.0,
|
||||
"margin_level": 2550.0,
|
||||
},
|
||||
"_account_balance",
|
||||
5,
|
||||
id="account-state",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_record_state_emits_gauges(
|
||||
self,
|
||||
method: str,
|
||||
kwargs: dict[str, float | str],
|
||||
gauge_attr: str,
|
||||
expected_set_count: int,
|
||||
) -> None:
|
||||
"""record_*_state emits the expected gauge set calls after configure."""
|
||||
meter = MagicMock()
|
||||
m = _Mt5Metrics()
|
||||
m.configure(meter)
|
||||
with m.record_snapshot_update():
|
||||
pass
|
||||
m._snapshot_duration.record.assert_called_once() # type: ignore[reportPrivateUsage]
|
||||
m._snapshot_failures.add.assert_not_called() # type: ignore[reportPrivateUsage]
|
||||
|
||||
def test_record_snapshot_update_failure(self) -> None:
|
||||
"""record_snapshot_update increments failure counter and re-raises on error."""
|
||||
meter = MagicMock()
|
||||
m = _Mt5Metrics()
|
||||
m.configure(meter)
|
||||
exc = RuntimeError("snap fail")
|
||||
with (
|
||||
pytest.raises(RuntimeError, match="snap fail"),
|
||||
m.record_snapshot_update(),
|
||||
):
|
||||
raise exc
|
||||
m._snapshot_failures.add.assert_called_once_with(1, {}) # type: ignore[reportPrivateUsage]
|
||||
m._snapshot_duration.record.assert_not_called() # type: ignore[reportPrivateUsage]
|
||||
|
||||
def test_record_position_state(self) -> None:
|
||||
"""record_position_state emits profit and volume gauges."""
|
||||
meter = MagicMock()
|
||||
m = _Mt5Metrics()
|
||||
m.configure(meter)
|
||||
m.record_position_state(
|
||||
login="42",
|
||||
server="demo",
|
||||
symbol="EURUSD",
|
||||
profit=12.5,
|
||||
volume=0.01,
|
||||
getattr(m, method)(**kwargs)
|
||||
# Related gauges share the same create_gauge mock; verify total set calls.
|
||||
assert ( # type: ignore[reportPrivateUsage]
|
||||
getattr(m, gauge_attr).set.call_count == expected_set_count
|
||||
)
|
||||
# Both profit and volume share the same gauge mock via create_gauge.
|
||||
# Verify that set was called exactly twice (once each).
|
||||
assert m._position_profit.set.call_count == 2 # type: ignore[reportPrivateUsage]
|
||||
|
||||
def test_record_terminal_state(self) -> None:
|
||||
"""record_terminal_state emits connected, trade_allowed, trade_expert gauges."""
|
||||
meter = MagicMock()
|
||||
@pytest.mark.parametrize(
|
||||
("method", "kwargs"),
|
||||
[
|
||||
("record_history_update", {"dataset": "ticks"}),
|
||||
("record_snapshot_update", {}),
|
||||
],
|
||||
)
|
||||
def test_record_update_noop_before_configure(
|
||||
self,
|
||||
method: str,
|
||||
kwargs: dict[str, str],
|
||||
) -> None:
|
||||
"""record_*_update works without configure (no-op instruments)."""
|
||||
m = _Mt5Metrics()
|
||||
m.configure(meter)
|
||||
m.record_terminal_state(connected=1.0, trade_allowed=1.0, trade_expert=0.0)
|
||||
# All three terminal gauges share the same mock; set is called 3 times.
|
||||
assert m._terminal_connected.set.call_count == 3 # type: ignore[reportPrivateUsage]
|
||||
|
||||
def test_record_account_state_after_configure(self) -> None:
|
||||
"""record_account_state emits all five account gauges."""
|
||||
meter = MagicMock()
|
||||
m = _Mt5Metrics()
|
||||
m.configure(meter)
|
||||
m.record_account_state(
|
||||
login="99",
|
||||
server="live",
|
||||
balance=5000.0,
|
||||
equity=5100.0,
|
||||
margin=200.0,
|
||||
margin_free=4800.0,
|
||||
margin_level=2550.0,
|
||||
)
|
||||
# All five account gauges share the same gauge mock; set is called 5 times.
|
||||
assert m._account_balance.set.call_count == 5 # type: ignore[reportPrivateUsage]
|
||||
|
||||
def test_record_history_update_noop_before_configure(self) -> None:
|
||||
"""record_history_update works without configure (no-op instruments)."""
|
||||
m = _Mt5Metrics()
|
||||
with m.record_history_update(dataset="ticks"):
|
||||
pass
|
||||
|
||||
def test_record_snapshot_update_noop_before_configure(self) -> None:
|
||||
"""record_snapshot_update works without configure (no-op instruments)."""
|
||||
m = _Mt5Metrics()
|
||||
with m.record_snapshot_update():
|
||||
with getattr(m, method)(**kwargs):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
+806
-712
File diff suppressed because it is too large
Load Diff
+167
-144
@@ -89,28 +89,45 @@ class TestExportDataframe:
|
||||
"""Create a sample DataFrame for testing."""
|
||||
return pd.DataFrame({"a": [1, 2, 3], "b": ["x", "y", "z"]})
|
||||
|
||||
def test_export_csv(self, tmp_path: Path, sample_df: pd.DataFrame) -> None:
|
||||
"""Test CSV export."""
|
||||
output = tmp_path / "out.csv"
|
||||
export_dataframe(sample_df, output, "csv")
|
||||
result = pd.read_csv(output)
|
||||
pd.testing.assert_frame_equal(result, sample_df)
|
||||
|
||||
def test_export_json(self, tmp_path: Path, sample_df: pd.DataFrame) -> None:
|
||||
"""Test JSON export."""
|
||||
output = tmp_path / "out.json"
|
||||
export_dataframe(sample_df, output, "json")
|
||||
with output.open() as f:
|
||||
records = json.load(f)
|
||||
assert len(records) == 3
|
||||
assert records[0]["a"] == 1
|
||||
|
||||
def test_export_parquet(self, tmp_path: Path, sample_df: pd.DataFrame) -> None:
|
||||
"""Test Parquet export."""
|
||||
output = tmp_path / "out.parquet"
|
||||
export_dataframe(sample_df, output, "parquet")
|
||||
result = pd.read_parquet(output)
|
||||
pd.testing.assert_frame_equal(result, sample_df)
|
||||
@pytest.mark.parametrize(
|
||||
("filename", "output_format", "reader"),
|
||||
[
|
||||
("out.csv", "csv", "csv"),
|
||||
("out.json", "json", "json"),
|
||||
("out.parquet", "parquet", "parquet"),
|
||||
("out.db", "sqlite3", "sqlite3"),
|
||||
],
|
||||
ids=["csv", "json", "parquet", "sqlite3"],
|
||||
)
|
||||
def test_export_round_trip(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
sample_df: pd.DataFrame,
|
||||
filename: str,
|
||||
output_format: str,
|
||||
reader: str,
|
||||
) -> None:
|
||||
"""Test CSV/JSON/Parquet/SQLite3 exports round-trip the sample DataFrame."""
|
||||
output = tmp_path / filename
|
||||
export_dataframe(sample_df, output, output_format, table_name="test_table")
|
||||
if reader == "csv":
|
||||
result = pd.read_csv(output)
|
||||
pd.testing.assert_frame_equal(result, sample_df)
|
||||
elif reader == "json":
|
||||
with output.open() as f:
|
||||
records = json.load(f)
|
||||
assert len(records) == 3
|
||||
assert records[0]["a"] == 1
|
||||
elif reader == "sqlite3":
|
||||
with sqlite3.connect(output) as conn:
|
||||
result = pd.read_sql( # type: ignore[reportUnknownMemberType]
|
||||
"SELECT * FROM test_table",
|
||||
conn,
|
||||
)
|
||||
pd.testing.assert_frame_equal(result, sample_df)
|
||||
else:
|
||||
result = pd.read_parquet(output)
|
||||
pd.testing.assert_frame_equal(result, sample_df)
|
||||
|
||||
def test_export_parquet_without_pyarrow(
|
||||
self,
|
||||
@@ -123,17 +140,6 @@ class TestExportDataframe:
|
||||
with pytest.raises(ImportError, match="mt5cli\\[parquet\\]"):
|
||||
export_dataframe(sample_df, tmp_path / "out.parquet", "parquet")
|
||||
|
||||
def test_export_sqlite3(self, tmp_path: Path, sample_df: pd.DataFrame) -> None:
|
||||
"""Test SQLite3 export."""
|
||||
output = tmp_path / "out.db"
|
||||
export_dataframe(sample_df, output, "sqlite3", table_name="test_table")
|
||||
with sqlite3.connect(output) as conn:
|
||||
result = pd.read_sql( # type: ignore[reportUnknownMemberType]
|
||||
"SELECT * FROM test_table",
|
||||
conn,
|
||||
)
|
||||
pd.testing.assert_frame_equal(result, sample_df)
|
||||
|
||||
def test_unsupported_format_raises(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
@@ -147,13 +153,41 @@ class TestExportDataframe:
|
||||
class TestExportDataframeToSqlite:
|
||||
"""Tests for export_dataframe_to_sqlite."""
|
||||
|
||||
def test_append_preserves_existing_rows(self, tmp_path: Path) -> None:
|
||||
"""Test append mode keeps prior rows in the SQLite table."""
|
||||
@pytest.mark.parametrize(
|
||||
("first_if_exists", "second_if_exists"),
|
||||
[
|
||||
pytest.param(IfExists.REPLACE, IfExists.APPEND, id="replace-then-append"),
|
||||
pytest.param(None, None, id="default-append"),
|
||||
],
|
||||
)
|
||||
def test_append_preserves_existing_rows(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
first_if_exists: IfExists | None,
|
||||
second_if_exists: IfExists | None,
|
||||
) -> None:
|
||||
"""Test explicit append and default append modes keep prior rows."""
|
||||
output = tmp_path / "append.db"
|
||||
first = pd.DataFrame({"id": [1], "value": ["a"]})
|
||||
second = pd.DataFrame({"id": [2], "value": ["b"]})
|
||||
export_dataframe_to_sqlite(first, output, "items", if_exists=IfExists.REPLACE)
|
||||
export_dataframe_to_sqlite(second, output, "items", if_exists=IfExists.APPEND)
|
||||
if first_if_exists is None:
|
||||
export_dataframe_to_sqlite(first, output, "items")
|
||||
else:
|
||||
export_dataframe_to_sqlite(
|
||||
first,
|
||||
output,
|
||||
"items",
|
||||
if_exists=first_if_exists,
|
||||
)
|
||||
if second_if_exists is None:
|
||||
export_dataframe_to_sqlite(second, output, "items")
|
||||
else:
|
||||
export_dataframe_to_sqlite(
|
||||
second,
|
||||
output,
|
||||
"items",
|
||||
if_exists=second_if_exists,
|
||||
)
|
||||
with sqlite3.connect(output) as conn:
|
||||
result = pd.read_sql( # type: ignore[reportUnknownMemberType]
|
||||
"SELECT id, value FROM items ORDER BY id",
|
||||
@@ -205,26 +239,6 @@ class TestExportDataframeToSqlite:
|
||||
}),
|
||||
)
|
||||
|
||||
def test_default_if_exists_appends_without_dropping_rows(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test the default append mode keeps prior rows."""
|
||||
output = tmp_path / "default-append.db"
|
||||
first = pd.DataFrame({"id": [1], "value": ["a"]})
|
||||
second = pd.DataFrame({"id": [2], "value": ["b"]})
|
||||
export_dataframe_to_sqlite(first, output, "items")
|
||||
export_dataframe_to_sqlite(second, output, "items")
|
||||
with sqlite3.connect(output) as conn:
|
||||
result = pd.read_sql( # type: ignore[reportUnknownMemberType]
|
||||
"SELECT id, value FROM items ORDER BY id",
|
||||
conn,
|
||||
)
|
||||
pd.testing.assert_frame_equal(
|
||||
result,
|
||||
pd.DataFrame({"id": [1, 2], "value": ["a", "b"]}),
|
||||
)
|
||||
|
||||
def test_writes_index_with_label(self, tmp_path: Path) -> None:
|
||||
"""Test optional index export with a custom label."""
|
||||
output = tmp_path / "index.db"
|
||||
@@ -258,15 +272,17 @@ class TestExportDataframeToSqlite:
|
||||
class TestParseDatetime:
|
||||
"""Tests for parse_datetime."""
|
||||
|
||||
def test_valid_date(self) -> None:
|
||||
"""Test parsing a date string."""
|
||||
result = parse_datetime("2024-01-15")
|
||||
assert result == datetime(2024, 1, 15, tzinfo=UTC)
|
||||
|
||||
def test_valid_datetime_with_tz(self) -> None:
|
||||
"""Test parsing a datetime with timezone."""
|
||||
result = parse_datetime("2024-01-15T12:00:00+00:00")
|
||||
assert result == datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC)
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("2024-01-15", datetime(2024, 1, 15, tzinfo=UTC)),
|
||||
("2024-01-15T12:00:00+00:00", datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC)),
|
||||
],
|
||||
ids=["date", "datetime-with-tz"],
|
||||
)
|
||||
def test_valid_inputs(self, value: str, expected: datetime) -> None:
|
||||
"""Test parsing valid date and datetime strings."""
|
||||
assert parse_datetime(value) == expected
|
||||
|
||||
def test_invalid_format_raises(self) -> None:
|
||||
"""Test that invalid format raises ValueError."""
|
||||
@@ -279,26 +295,29 @@ class TestParseTimeframe:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[("M1", 1), ("h1", 16385), ("D1", 16408), ("MN1", 49153)],
|
||||
[
|
||||
("M1", 1),
|
||||
("h1", 16385),
|
||||
("D1", 16408),
|
||||
("MN1", 49153),
|
||||
("1", 1),
|
||||
(16385, 16385),
|
||||
],
|
||||
ids=["M1", "h1", "D1", "MN1", "int-string-1", "int-16385"],
|
||||
)
|
||||
def test_named_timeframe(self, value: str, expected: int) -> None:
|
||||
"""Test parsing named timeframes."""
|
||||
def test_valid_timeframe(self, value: str | int, expected: int) -> None:
|
||||
"""Test parsing valid string and integer timeframe values."""
|
||||
assert parse_timeframe(value) == expected
|
||||
|
||||
def test_integer_timeframe(self) -> None:
|
||||
"""Test parsing supported integer timeframes."""
|
||||
assert parse_timeframe("1") == 1
|
||||
assert parse_timeframe(16385) == 16385
|
||||
|
||||
def test_unsupported_integer_timeframe_raises(self) -> None:
|
||||
"""Test that unsupported integer timeframes raise ValueError."""
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
["42", "INVALID"],
|
||||
ids=["unsupported-integer", "invalid-string"],
|
||||
)
|
||||
def test_invalid_timeframe_raises(self, value: str) -> None:
|
||||
"""Test that invalid timeframe values raise ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid timeframe"):
|
||||
parse_timeframe("42")
|
||||
|
||||
def test_invalid_timeframe_raises(self) -> None:
|
||||
"""Test that invalid timeframe raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid timeframe"):
|
||||
parse_timeframe("INVALID")
|
||||
parse_timeframe(value)
|
||||
|
||||
|
||||
class TestParseTickFlags:
|
||||
@@ -306,26 +325,29 @@ class TestParseTickFlags:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[("ALL", -1), ("info", 1), ("TRADE", 2), ("COPY_TICKS_ALL", -1)],
|
||||
[
|
||||
("ALL", -1),
|
||||
("info", 1),
|
||||
("TRADE", 2),
|
||||
("COPY_TICKS_ALL", -1),
|
||||
("-1", -1),
|
||||
(2, 2),
|
||||
],
|
||||
ids=["ALL", "info", "TRADE", "COPY_TICKS_ALL", "int-string--1", "int-2"],
|
||||
)
|
||||
def test_named_flag(self, value: str, expected: int) -> None:
|
||||
"""Test parsing named tick flags."""
|
||||
def test_valid_flag(self, value: str | int, expected: int) -> None:
|
||||
"""Test parsing valid string and integer tick flag values."""
|
||||
assert parse_tick_flags(value) == expected
|
||||
|
||||
def test_integer_flag(self) -> None:
|
||||
"""Test parsing supported integer tick flags."""
|
||||
assert parse_tick_flags("-1") == -1
|
||||
assert parse_tick_flags(2) == 2
|
||||
|
||||
def test_unsupported_integer_flag_raises(self) -> None:
|
||||
"""Test that unsupported integer tick flags raise ValueError."""
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
["7", "INVALID"],
|
||||
ids=["unsupported-integer", "invalid-string"],
|
||||
)
|
||||
def test_invalid_flag_raises(self, value: str) -> None:
|
||||
"""Test that invalid tick flag values raise ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid tick flags"):
|
||||
parse_tick_flags("7")
|
||||
|
||||
def test_invalid_flag_raises(self) -> None:
|
||||
"""Test that invalid flag raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid tick flags"):
|
||||
parse_tick_flags("INVALID")
|
||||
parse_tick_flags(value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -348,15 +370,17 @@ class TestParseRequest:
|
||||
result = parse_request(f"@{path}")
|
||||
assert result == {"action": 2}
|
||||
|
||||
def test_invalid_json_raises(self) -> None:
|
||||
"""Test that invalid JSON raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid JSON request"):
|
||||
parse_request("not json")
|
||||
|
||||
def test_non_object_raises(self) -> None:
|
||||
"""Test that a non-object JSON raises ValueError."""
|
||||
with pytest.raises(ValueError, match="must be a JSON object"):
|
||||
parse_request("[1, 2, 3]")
|
||||
@pytest.mark.parametrize(
|
||||
("value", "match"),
|
||||
[
|
||||
pytest.param("not json", "Invalid JSON request", id="invalid-json"),
|
||||
pytest.param("[1, 2, 3]", "must be a JSON object", id="non-object"),
|
||||
],
|
||||
)
|
||||
def test_invalid_request_raises(self, value: str, match: str) -> None:
|
||||
"""Test that invalid JSON and non-object requests raise ValueError."""
|
||||
with pytest.raises(ValueError, match=match):
|
||||
parse_request(value)
|
||||
|
||||
def test_missing_file_raises(self, tmp_path: Path) -> None:
|
||||
"""Test that a missing request file raises ValueError."""
|
||||
@@ -373,13 +397,10 @@ class TestParseRequest:
|
||||
class TestConstants:
|
||||
"""Tests for module constants."""
|
||||
|
||||
def test_timeframe_map_is_private_in_utils(self) -> None:
|
||||
"""TIMEFRAME_MAP is a private implementation detail; not a public attribute."""
|
||||
assert not hasattr(mt5cli.utils, "TIMEFRAME_MAP")
|
||||
|
||||
def test_tick_flag_map_absent_from_utils(self) -> None:
|
||||
"""TICK_FLAG_MAP is not exposed by mt5cli.utils."""
|
||||
assert not hasattr(mt5cli.utils, "TICK_FLAG_MAP")
|
||||
@pytest.mark.parametrize("name", ["TIMEFRAME_MAP", "TICK_FLAG_MAP"])
|
||||
def test_private_maps_absent_from_utils(self, name: str) -> None:
|
||||
"""Private pdmt5 maps are not exposed as public mt5cli.utils attributes."""
|
||||
assert not hasattr(mt5cli.utils, name)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("dataset", "expected"),
|
||||
@@ -422,23 +443,24 @@ class TestDateTimeType:
|
||||
class TestTimeframeType:
|
||||
"""Tests for _TimeframeType."""
|
||||
|
||||
def test_convert_string(self) -> None:
|
||||
"""Test converting a string to timeframe integer."""
|
||||
assert TIMEFRAME_TYPE.convert("H1", None, None) == 16385
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[("H1", 16385), (16385, 16385)],
|
||||
ids=["string", "int"],
|
||||
)
|
||||
def test_convert_valid(self, value: str | int, expected: int) -> None:
|
||||
"""Test converting valid string and integer timeframe values."""
|
||||
assert TIMEFRAME_TYPE.convert(value, None, None) == expected
|
||||
|
||||
def test_convert_int(self) -> None:
|
||||
"""Test converting supported integer timeframe values."""
|
||||
assert TIMEFRAME_TYPE.convert(16385, None, None) == 16385
|
||||
|
||||
def test_convert_unsupported_int(self) -> None:
|
||||
"""Test that unsupported integer values raise BadParameter."""
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[42, "bad"],
|
||||
ids=["unsupported-int", "invalid-string"],
|
||||
)
|
||||
def test_convert_invalid(self, value: object) -> None:
|
||||
"""Test that unsupported int and invalid string values raise BadParameter."""
|
||||
with pytest.raises(Exception, match="Invalid timeframe"):
|
||||
TIMEFRAME_TYPE.convert(42, None, None)
|
||||
|
||||
def test_convert_invalid(self) -> None:
|
||||
"""Test that invalid values raise BadParameter."""
|
||||
with pytest.raises(Exception, match="Invalid timeframe"):
|
||||
TIMEFRAME_TYPE.convert("bad", None, None)
|
||||
TIMEFRAME_TYPE.convert(value, None, None)
|
||||
|
||||
@pytest.mark.parametrize("value", [True, False, None, 1.5])
|
||||
def test_convert_invalid_types(self, value: object) -> None:
|
||||
@@ -450,18 +472,24 @@ class TestTimeframeType:
|
||||
class TestTickFlagsType:
|
||||
"""Tests for _TickFlagsType."""
|
||||
|
||||
def test_convert_string(self) -> None:
|
||||
"""Test converting a string to tick flags integer."""
|
||||
assert TICK_FLAGS_TYPE.convert("ALL", None, None) == -1
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[("ALL", -1), (2, 2)],
|
||||
ids=["string", "int"],
|
||||
)
|
||||
def test_convert_valid(self, value: str | int, expected: int) -> None:
|
||||
"""Test converting valid string and integer tick flag values."""
|
||||
assert TICK_FLAGS_TYPE.convert(value, None, None) == expected
|
||||
|
||||
def test_convert_int(self) -> None:
|
||||
"""Test converting supported integer tick flag values."""
|
||||
assert TICK_FLAGS_TYPE.convert(2, None, None) == 2
|
||||
|
||||
def test_convert_unsupported_int(self) -> None:
|
||||
"""Test that unsupported integer values raise BadParameter."""
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[7, "bad"],
|
||||
ids=["unsupported-int", "invalid-string"],
|
||||
)
|
||||
def test_convert_invalid(self, value: object) -> None:
|
||||
"""Test that unsupported int and invalid string values raise BadParameter."""
|
||||
with pytest.raises(Exception, match="Invalid tick flags"):
|
||||
TICK_FLAGS_TYPE.convert(7, None, None)
|
||||
TICK_FLAGS_TYPE.convert(value, None, None)
|
||||
|
||||
@pytest.mark.parametrize("value", [True, False, None, 1.5])
|
||||
def test_convert_invalid_types(self, value: object) -> None:
|
||||
@@ -469,11 +497,6 @@ class TestTickFlagsType:
|
||||
with pytest.raises(Exception, match="Invalid tick flags"):
|
||||
TICK_FLAGS_TYPE.convert(value, None, None)
|
||||
|
||||
def test_convert_invalid(self) -> None:
|
||||
"""Test that invalid values raise BadParameter."""
|
||||
with pytest.raises(Exception, match="Invalid tick flags"):
|
||||
TICK_FLAGS_TYPE.convert("bad", None, None)
|
||||
|
||||
|
||||
class TestRequestType:
|
||||
"""Tests for _RequestType."""
|
||||
|
||||
@@ -367,15 +367,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "metatrader5"
|
||||
version = "5.0.5640"
|
||||
version = "5.0.5735"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/a0/3b764c6743ef601ff12f7d8d62ca5768eb25e90d3758ac7e1d08e667af85/metatrader5-5.0.5640-cp311-cp311-win_amd64.whl", hash = "sha256:4057255f2d63138a3ea1a5d492715038a71d3177cad793fca97a5c58771b4eb1", size = 48091, upload-time = "2026-02-20T23:31:12.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/ee/b158bece0322ccf426b2879742bfce00104e53291a3c057a333c90633a88/metatrader5-5.0.5640-cp312-cp312-win_amd64.whl", hash = "sha256:24fab41178067e67c4d9ceb70bc0923d1814090a6e1f9bc5d7d13fa83737ddee", size = 48067, upload-time = "2026-02-20T23:31:13.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/39/a735f26d826c6a5dc8c8d665d2bca83f22dc20f92543647712c210800be1/metatrader5-5.0.5640-cp313-cp313-win_amd64.whl", hash = "sha256:08431a9d02a26d517dcde19fccd571a57ec4454acb798ee246d95eae8946e60e", size = 48073, upload-time = "2026-02-20T23:31:15.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/0b/3dd5143f14319dca393a3bcf11ddf24f36f16dfb8c6d1bf2b193ce0c5733/metatrader5-5.0.5735-cp311-cp311-win_amd64.whl", hash = "sha256:0d05b69cef5eb3f43ea47cb6d36dac0e7e15f82a4fb77974439c565daa54d1c9", size = 48091, upload-time = "2026-04-04T16:44:08.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/db/42dc3437c7371492262b0642c64c7b5f67c396bbcb8101ccf182981b67b3/metatrader5-5.0.5735-cp312-cp312-win_amd64.whl", hash = "sha256:f6e8584e48f2c3f5de818f17ee65f0f5adfa1e4af29cd5f4bf3f72b91ff06e10", size = 48062, upload-time = "2026-04-04T16:44:09.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/2c/7ffc362d84f402b97773be5c6b37f1a0ab9882244c987bbb224c379d17d8/metatrader5-5.0.5735-cp313-cp313-win_amd64.whl", hash = "sha256:0933ea4a9a52b32adcf5590df00f9f75ff380a02bad7b62e23cbd757f34fbb12", size = 48071, upload-time = "2026-04-04T16:44:11.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -499,11 +499,13 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mt5cli"
|
||||
version = "1.1.1"
|
||||
version = "1.1.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "pandas" },
|
||||
{ name = "pdmt5" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "typer" },
|
||||
]
|
||||
|
||||
@@ -540,8 +542,10 @@ requires-dist = [
|
||||
{ name = "opentelemetry-api", marker = "extra == 'otel'" },
|
||||
{ name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'otel'" },
|
||||
{ name = "opentelemetry-sdk", marker = "extra == 'otel'" },
|
||||
{ name = "pdmt5", specifier = ">=1.0.0" },
|
||||
{ name = "pandas", specifier = ">=2.2.2" },
|
||||
{ name = "pdmt5", specifier = ">=1.1.0" },
|
||||
{ name = "pyarrow", marker = "extra == 'parquet'", specifier = ">=19.0.0" },
|
||||
{ name = "pydantic", specifier = ">=2.13.4" },
|
||||
{ name = "typer", specifier = ">=0.15.0" },
|
||||
]
|
||||
provides-extras = ["parquet", "otel"]
|
||||
@@ -731,46 +735,46 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pandas"
|
||||
version = "3.0.1"
|
||||
version = "3.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2e/0c/b28ed414f080ee0ad153f848586d61d1878f91689950f037f976ce15f6c8/pandas-3.0.1.tar.gz", hash = "sha256:4186a699674af418f655dbd420ed87f50d56b4cd6603784279d9eef6627823c8", size = 4641901, upload-time = "2026-02-17T22:20:16.434Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/07/c7087e003ceee9b9a82539b40414ec557aa795b584a1a346e89180853d79/pandas-3.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:de09668c1bf3b925c07e5762291602f0d789eca1b3a781f99c1c78f6cac0e7ea", size = 10323380, upload-time = "2026-02-17T22:18:16.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/27/90683c7122febeefe84a56f2cde86a9f05f68d53885cebcc473298dfc33e/pandas-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:24ba315ba3d6e5806063ac6eb717504e499ce30bd8c236d8693a5fd3f084c796", size = 9923455, upload-time = "2026-02-17T22:18:19.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/f1/ed17d927f9950643bc7631aa4c99ff0cc83a37864470bc419345b656a41f/pandas-3.0.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:406ce835c55bac912f2a0dcfaf27c06d73c6b04a5dde45f1fd3169ce31337389", size = 10753464, upload-time = "2026-02-17T22:18:21.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/7c/870c7e7daec2a6c7ff2ac9e33b23317230d4e4e954b35112759ea4a924a7/pandas-3.0.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:830994d7e1f31dd7e790045235605ab61cff6c94defc774547e8b7fdfbff3dc7", size = 11255234, upload-time = "2026-02-17T22:18:24.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/39/3653fe59af68606282b989c23d1a543ceba6e8099cbcc5f1d506a7bae2aa/pandas-3.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a64ce8b0f2de1d2efd2ae40b0abe7f8ae6b29fbfb3812098ed5a6f8e235ad9bf", size = 11767299, upload-time = "2026-02-17T22:18:26.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/31/1daf3c0c94a849c7a8dab8a69697b36d313b229918002ba3e409265c7888/pandas-3.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9832c2c69da24b602c32e0c7b1b508a03949c18ba08d4d9f1c1033426685b447", size = 12333292, upload-time = "2026-02-17T22:18:28.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/67/af63f83cd6ca603a00fe8530c10a60f0879265b8be00b5930e8e78c5b30b/pandas-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:84f0904a69e7365f79a0c77d3cdfccbfb05bf87847e3a51a41e1426b0edb9c79", size = 9892176, upload-time = "2026-02-17T22:18:31.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/ab/9c776b14ac4b7b4140788eca18468ea39894bc7340a408f1d1e379856a6b/pandas-3.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:4a68773d5a778afb31d12e34f7dd4612ab90de8c6fb1d8ffe5d4a03b955082a1", size = 9151328, upload-time = "2026-02-17T22:18:35.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/51/b467209c08dae2c624873d7491ea47d2b47336e5403309d433ea79c38571/pandas-3.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:476f84f8c20c9f5bc47252b66b4bb25e1a9fc2fa98cead96744d8116cb85771d", size = 10344357, upload-time = "2026-02-17T22:18:38.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f1/e2567ffc8951ab371db2e40b2fe068e36b81d8cf3260f06ae508700e5504/pandas-3.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0ab749dfba921edf641d4036c4c21c0b3ea70fea478165cb98a998fb2a261955", size = 9884543, upload-time = "2026-02-17T22:18:41.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/39/327802e0b6d693182403c144edacbc27eb82907b57062f23ef5a4c4a5ea7/pandas-3.0.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8e36891080b87823aff3640c78649b91b8ff6eea3c0d70aeabd72ea43ab069b", size = 10396030, upload-time = "2026-02-17T22:18:43.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/fe/89d77e424365280b79d99b3e1e7d606f5165af2f2ecfaf0c6d24c799d607/pandas-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:532527a701281b9dd371e2f582ed9094f4c12dd9ffb82c0c54ee28d8ac9520c4", size = 10876435, upload-time = "2026-02-17T22:18:45.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/a6/2a75320849dd154a793f69c951db759aedb8d1dd3939eeacda9bdcfa1629/pandas-3.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:356e5c055ed9b0da1580d465657bc7d00635af4fd47f30afb23025352ba764d1", size = 11405133, upload-time = "2026-02-17T22:18:48.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/53/1d68fafb2e02d7881df66aa53be4cd748d25cbe311f3b3c85c93ea5d30ca/pandas-3.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9d810036895f9ad6345b8f2a338dd6998a74e8483847403582cab67745bff821", size = 11932065, upload-time = "2026-02-17T22:18:50.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/08/67cc404b3a966b6df27b38370ddd96b3b023030b572283d035181854aac5/pandas-3.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:536232a5fe26dd989bd633e7a0c450705fdc86a207fec7254a55e9a22950fe43", size = 9741627, upload-time = "2026-02-17T22:18:53.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/4f/caf9952948fb00d23795f09b893d11f1cacb384e666854d87249530f7cbe/pandas-3.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f463ebfd8de7f326d38037c7363c6dacb857c5881ab8961fb387804d6daf2f7", size = 9052483, upload-time = "2026-02-17T22:18:57.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/48/aad6ec4f8d007534c091e9a7172b3ec1b1ee6d99a9cbb936b5eab6c6cf58/pandas-3.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5272627187b5d9c20e55d27caf5f2cd23e286aba25cadf73c8590e432e2b7262", size = 10317509, upload-time = "2026-02-17T22:18:59.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/14/5990826f779f79148ae9d3a2c39593dc04d61d5d90541e71b5749f35af95/pandas-3.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:661e0f665932af88c7877f31da0dc743fe9c8f2524bdffe23d24fdcb67ef9d56", size = 9860561, upload-time = "2026-02-17T22:19:02.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/80/f01ff54664b6d70fed71475543d108a9b7c888e923ad210795bef04ffb7d/pandas-3.0.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75e6e292ff898679e47a2199172593d9f6107fd2dd3617c22c2946e97d5df46e", size = 10365506, upload-time = "2026-02-17T22:19:05.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/85/ab6d04733a7d6ff32bfc8382bf1b07078228f5d6ebec5266b91bfc5c4ff7/pandas-3.0.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ff8cf1d2896e34343197685f432450ec99a85ba8d90cce2030c5eee2ef98791", size = 10873196, upload-time = "2026-02-17T22:19:07.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/a9/9301c83d0b47c23ac5deab91c6b39fd98d5b5db4d93b25df8d381451828f/pandas-3.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eca8b4510f6763f3d37359c2105df03a7a221a508f30e396a51d0713d462e68a", size = 11370859, upload-time = "2026-02-17T22:19:09.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/fe/0c1fc5bd2d29c7db2ab372330063ad555fb83e08422829c785f5ec2176ca/pandas-3.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06aff2ad6f0b94a17822cf8b83bbb563b090ed82ff4fe7712db2ce57cd50d9b8", size = 11924584, upload-time = "2026-02-17T22:19:11.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/7d/216a1588b65a7aa5f4535570418a599d943c85afb1d95b0876fc00aa1468/pandas-3.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9fea306c783e28884c29057a1d9baa11a349bbf99538ec1da44c8476563d1b25", size = 9742769, upload-time = "2026-02-17T22:19:13.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/cb/810a22a6af9a4e97c8ab1c946b47f3489c5bca5adc483ce0ffc84c9cc768/pandas-3.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:a8d37a43c52917427e897cb2e429f67a449327394396a81034a4449b99afda59", size = 9043855, upload-time = "2026-02-17T22:19:16.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/fa/423c89086cca1f039cf1253c3ff5b90f157b5b3757314aa635f6bf3e30aa/pandas-3.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d54855f04f8246ed7b6fc96b05d4871591143c46c0b6f4af874764ed0d2d6f06", size = 10752673, upload-time = "2026-02-17T22:19:18.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/23/b5a08ec1f40020397f0faba72f1e2c11f7596a6169c7b3e800abff0e433f/pandas-3.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e1b677accee34a09e0dc2ce5624e4a58a1870ffe56fc021e9caf7f23cd7668f", size = 10404967, upload-time = "2026-02-17T22:19:20.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/81/94841f1bb4afdc2b52a99daa895ac2c61600bb72e26525ecc9543d453ebc/pandas-3.0.1-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9cabbdcd03f1b6cd254d6dda8ae09b0252524be1592594c00b7895916cb1324", size = 10320575, upload-time = "2026-02-17T22:19:24.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/8b/2ae37d66a5342a83adadfd0cb0b4bf9c3c7925424dd5f40d15d6cfaa35ee/pandas-3.0.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ae2ab1f166668b41e770650101e7090824fd34d17915dd9cd479f5c5e0065e9", size = 10710921, upload-time = "2026-02-17T22:19:27.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/61/772b2e2757855e232b7ccf7cb8079a5711becb3a97f291c953def15a833f/pandas-3.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6bf0603c2e30e2cafac32807b06435f28741135cb8697eae8b28c7d492fc7d76", size = 11334191, upload-time = "2026-02-17T22:19:29.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/08/b16c6df3ef555d8495d1d265a7963b65be166785d28f06a350913a4fac78/pandas-3.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c426422973973cae1f4a23e51d4ae85974f44871b24844e4f7de752dd877098", size = 11782256, upload-time = "2026-02-17T22:19:32.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/80/178af0594890dee17e239fca96d3d8670ba0f5ff59b7d0439850924a9c09/pandas-3.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b03f91ae8c10a85c1613102c7bef5229b5379f343030a3ccefeca8a33414cf35", size = 10485047, upload-time = "2026-02-17T22:19:34.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/16/b5c76b838fd9bf6ce84d3a53346b8874ec05c5f0040d75ef2c320100cd2a/pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98", size = 10338495, upload-time = "2026-05-11T18:52:11.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/b0/a4ffc4ae74d2d822200dcc46898987d8eb6032d1e2b219cae39da6f5cbcc/pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639", size = 9938250, upload-time = "2026-05-11T18:52:17.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/b2/3323601a52caee42c019e370090ca4544b241437240ca04f786cce82b0cf/pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2", size = 10770558, upload-time = "2026-05-11T18:52:19.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/f1/bbecd2f867b97abebe0f9b53d750f862251b40337e061b36676ded3d920f/pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27", size = 11274611, upload-time = "2026-05-11T18:52:22.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/4f/eafabf2d5fae5adf143b4d18d3706c5efdc368a7c4eb1ee8a3eddabbd0f6/pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824", size = 11784670, upload-time = "2026-05-11T18:52:25.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/44/1eb20389301b57b19cc099a1c2f662501f72f08a65f912d05822613c1532/pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938", size = 12353708, upload-time = "2026-05-11T18:52:28.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/62/c321f13b5ba1819fc8dca456c7fce578da2dcfecff1abbf0eaddf8406c0f/pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea", size = 9907609, upload-time = "2026-05-11T18:52:30.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/85/1b7f563ebc6357c27233a02a96b589bcce1fa9c6eb89fb4f0e56421d277e/pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a", size = 9165596, upload-time = "2026-05-11T18:52:33.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -796,16 +800,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pdmt5"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "metatrader5", marker = "sys_platform == 'win32'" },
|
||||
{ name = "pandas" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/6d/b51d2d0ec4636e914210be03a7da20e5078bc8cd7a351edd13bc30b7d2b1/pdmt5-1.0.0.tar.gz", hash = "sha256:ba53a1a5db41fdf4c022ef55353f5a4f6884f625c9310de2b0ddb20457956716", size = 123155, upload-time = "2026-06-25T23:41:50.503Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/35/7a/7caafb93b74ccaa60a1c844948698bed714e04aa3be292a05fd71864faa0/pdmt5-1.1.0.tar.gz", hash = "sha256:fcdab1924e001204ae776dd2afc1f150fe01e6944cba99dae72af23bc99137c6", size = 22160, upload-time = "2026-07-04T04:41:51.476Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/38/27b712c572d8146efddf571a0e4e7b6d2314a335eb0f47dec1f499ab2189/pdmt5-1.0.0-py3-none-any.whl", hash = "sha256:f969c17902f9ffcbf56d7ccf9285aab39ececd676fcca50c094abcf9d728d517", size = 23992, upload-time = "2026-06-25T23:41:49.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/80/1062ea4e4c82de81d91ba8bb43b1a6d4903771010d87eca15d5d18d827df/pdmt5-1.1.0-py3-none-any.whl", hash = "sha256:603273065814824680d6ec8847139b3029c7dd04da7c25868ad7f4ede55a9acf", size = 20489, upload-time = "2026-07-04T04:41:49.999Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -879,7 +883,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
version = "2.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
@@ -887,78 +891,81 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.41.5"
|
||||
version = "2.46.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user