Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f87683a431 | |||
| 74e3754a80 | |||
| c2cf0656dd | |||
| efc0de230a | |||
| a5c2aa8e8f | |||
| a05b6b896d | |||
| e2522f111b | |||
| 513eb7617d | |||
| 1ffac45d57 | |||
| d27da02f3f | |||
| 4bc36d09d2 | |||
| 3a126ced30 | |||
| 80c3f3f65e | |||
| 43f632bc40 | |||
| 8028263b24 | |||
| 63a8d67419 | |||
| 93565681e1 | |||
| f435544f07 | |||
| 668f38d8aa | |||
| 8da5ee9242 | |||
| 9dbb46fbb1 | |||
| dfe80ce500 | |||
| 15bfd17db3 | |||
| 37eef16e99 | |||
| 96c75f7852 | |||
| 292fac899a |
@@ -10,7 +10,7 @@ uv run pyright .
|
|||||||
uv run pytest
|
uv run pytest
|
||||||
|
|
||||||
# Markdown
|
# Markdown
|
||||||
npx -y prettier --write './**/*.md'
|
npx -y prettier --write './**/*.{md,json}'
|
||||||
|
|
||||||
# GitHub Actions
|
# GitHub Actions
|
||||||
case "${OSTYPE}" in
|
case "${OSTYPE}" in
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: pr-feedback-triage
|
name: pr-feedback-triage
|
||||||
description: Triage pull request review comments into fixes, replies, clarification requests, or open follow-ups while respecting safe execution modes.
|
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
|
# 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:
|
Gather the complete feedback set before editing:
|
||||||
|
|
||||||
- Fetch unresolved review threads, requested-change reviews, PR-level summary comments, and copied comments.
|
- Fetch unresolved review threads, requested-change reviews, inline comments, copied comments, and PR-level summary comments.
|
||||||
- Use platform-native APIs/CLI when available. Paginate results; do not inspect only the first page of threads or 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, collect both. Summary comments often contain severity, rationale, and fix instructions; inline comments contain the exact file and line context.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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 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 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.
|
- 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.
|
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
|
## 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.
|
- `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.
|
- `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.
|
- `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.
|
- `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.
|
- `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.
|
- `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_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.
|
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
|
## 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:
|
A reliable pattern is:
|
||||||
|
|
||||||
@@ -150,9 +159,9 @@ flowchart TD
|
|||||||
## Compact Workflow
|
## Compact Workflow
|
||||||
|
|
||||||
1. **Collect all relevant feedback**
|
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.
|
- 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**
|
2. **Classify each triage record**
|
||||||
- **Fix**: Valid requested change; make the smallest focused edit when not in `dry_run`.
|
- **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 `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_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 `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**
|
4. **Verify before claiming completion**
|
||||||
- For fixes, run appropriate checks or explain why they could not run.
|
- 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.
|
- If a resolve or reply operation fails, retry once when safe; then report `failed_action` with the affected source ID and reason.
|
||||||
|
|
||||||
5. **Finish**
|
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.
|
- Safe modes: report the local state and the exact replies/resolution actions a human could take.
|
||||||
|
|
||||||
## Reply Guidance
|
## Reply Guidance
|
||||||
|
|
||||||
- Keep inline replies short and tied to the original title or concern.
|
- Keep inline replies short: one sentence by default, two short sentences only when needed.
|
||||||
- For fixed findings, mention the concrete change or commit if useful.
|
- 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 that makes the finding no longer applicable.
|
- 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.
|
- 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.
|
- If a reply or resolve operation fails, continue with the remaining threads and report the failure in the final summary.
|
||||||
|
|
||||||
## Final Summary Checklist
|
## 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 }}
|
||||||
@@ -29,25 +29,29 @@ Built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data han
|
|||||||
pip install -U mt5cli MetaTrader5
|
pip install -U mt5cli MetaTrader5
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Parquet export is not included by default. To enable it, install the `parquet` extra:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -U "mt5cli[parquet]" MetaTrader5
|
||||||
|
```
|
||||||
|
|
||||||
## Python API (downstream packages)
|
## Python API (downstream packages)
|
||||||
|
|
||||||
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives. `Mt5CliClient` remains available as a backward-compatible alias.
|
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from mt5cli import (
|
from mt5cli import (
|
||||||
DataKind,
|
|
||||||
Dataset,
|
|
||||||
MT5Client,
|
MT5Client,
|
||||||
build_config,
|
build_config,
|
||||||
collect_history,
|
collect_history,
|
||||||
export_dataframe,
|
|
||||||
mt5_session,
|
mt5_session,
|
||||||
normalize_dataframe,
|
|
||||||
update_history_with_config,
|
update_history_with_config,
|
||||||
)
|
)
|
||||||
|
from mt5cli.schemas import DataKind, normalize_dataframe
|
||||||
|
from mt5cli.utils import Dataset, export_dataframe
|
||||||
|
|
||||||
# Persistent session for multiple calls
|
# Persistent session for multiple calls
|
||||||
with mt5_session(build_config(login=12345, server="Broker-Demo")) as client:
|
with mt5_session(build_config(login=12345, server="Broker-Demo")) as client:
|
||||||
@@ -83,7 +87,7 @@ update_history_with_config(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
Schema contracts live in `mt5cli.schemas` (`DataKind`, `validate_schema`, `normalize_dataframe`). Storage helpers are re-exported from `mt5cli.storage` and the package root.
|
Schema contracts live in `mt5cli.schemas` (`DataKind`, `validate_schema`, `normalize_dataframe`). Export and storage helpers are in `mt5cli.utils` (`Dataset`, `export_dataframe`) and `mt5cli.history`.
|
||||||
|
|
||||||
`MT5Client.order_send()` is a live execution primitive: it can place real trades on the connected account. mt5cli does not implement strategy logic, signal generation, backtesting, or optimization — downstream applications must gate live execution explicitly.
|
`MT5Client.order_send()` is a live execution primitive: it can place real trades on the connected account. mt5cli does not implement strategy logic, signal generation, backtesting, or optimization — downstream applications must gate live execution explicitly.
|
||||||
|
|
||||||
@@ -92,16 +96,21 @@ Schema contracts live in `mt5cli.schemas` (`DataKind`, `validate_schema`, `norma
|
|||||||
Trading applications can depend on `mt5cli` imports only; terminal path,
|
Trading applications can depend on `mt5cli` imports only; terminal path,
|
||||||
credentials, server, and timeout are forwarded to `pdmt5.Mt5Config`, numeric
|
credentials, server, and timeout are forwarded to `pdmt5.Mt5Config`, numeric
|
||||||
login strings are coerced to integers, and empty login strings are treated as
|
login strings are coerced to integers, and empty login strings are treated as
|
||||||
unset.
|
unset. Pass `allow_whole_dollar_env=True` to expand `${ENV_VAR}` and bare
|
||||||
|
`$ENV_NAME` placeholders in connection string parameters before coercion.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from mt5cli import (
|
from mt5cli import (
|
||||||
|
build_config,
|
||||||
calculate_spread_ratio,
|
calculate_spread_ratio,
|
||||||
create_trading_client,
|
create_trading_client,
|
||||||
get_account_snapshot,
|
get_account_snapshot,
|
||||||
mt5_trading_session,
|
mt5_trading_session,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Login from environment — numeric string is coerced to int automatically
|
||||||
|
config = build_config(login="$MT5_LOGIN", allow_whole_dollar_env=True)
|
||||||
|
|
||||||
with mt5_trading_session(
|
with mt5_trading_session(
|
||||||
path=r"C:\Program Files\MetaTrader 5\terminal64.exe",
|
path=r"C:\Program Files\MetaTrader 5\terminal64.exe",
|
||||||
login="12345",
|
login="12345",
|
||||||
@@ -135,9 +144,10 @@ mt5cli -o ticks.json ticks-from --symbol EURUSD \
|
|||||||
# Export symbols to SQLite3 with custom table name
|
# Export symbols to SQLite3 with custom table name
|
||||||
mt5cli -o data.db --table symbols symbols --group "*USD*"
|
mt5cli -o data.db --table symbols symbols --group "*USD*"
|
||||||
|
|
||||||
# Export with connection credentials
|
# Export with connection credentials from env or placeholders
|
||||||
mt5cli --login 12345 --password mypass --server MyBroker-Demo \
|
MT5_LOGIN=12345 MT5_PASSWORD=secret MT5_SERVER=MyBroker-Demo \
|
||||||
-o positions.csv positions
|
mt5cli -o positions.csv positions
|
||||||
|
MT5_PATH="/path/to/terminal64.exe" mt5cli -o positions.csv positions
|
||||||
```
|
```
|
||||||
|
|
||||||
Run as a Python module:
|
Run as a Python module:
|
||||||
@@ -148,39 +158,58 @@ python -m mt5cli -o account.csv account-info
|
|||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ---------------------- | ------------------------------------------------------------------------------------------------------------ |
|
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `rates-from` | Export rates from a start date |
|
| `rates-from` | Export rates from a start date |
|
||||||
| `rates-from-pos` | Export rates from a start position |
|
| `rates-from-pos` | Export rates from a start position |
|
||||||
| `latest-rates` | Export latest rates from a start position |
|
| `latest-rates` | Export latest rates from a start position |
|
||||||
| `rates-range` | Export rates for a date range |
|
| `rates-range` | Export rates for a date range |
|
||||||
| `ticks-from` | Export ticks from a start date |
|
| `ticks-from` | Export ticks from a start date |
|
||||||
| `ticks-range` | Export ticks for a date range |
|
| `ticks-range` | Export ticks for a date range |
|
||||||
| `ticks-recent` | Export ticks from a recent trailing window |
|
| `ticks-recent` | Export ticks from a recent trailing window |
|
||||||
| `account-info` | Export account information |
|
| `account-info` | Export account information |
|
||||||
| `terminal-info` | Export terminal information |
|
| `terminal-info` | Export terminal information |
|
||||||
| `version` | Export MetaTrader 5 version information |
|
| `version` | Export MetaTrader 5 version information |
|
||||||
| `last-error` | Export the last error information |
|
| `last-error` | Export the last error information |
|
||||||
| `symbols` | Export symbol list |
|
| `symbols` | Export symbol list |
|
||||||
| `symbol-info` | Export symbol details |
|
| `symbol-info` | Export symbol details |
|
||||||
| `symbol-info-tick` | Export the last tick for a symbol |
|
| `symbol-info-tick` | Export the last tick for a symbol |
|
||||||
| `minimum-margins` | Export minimum-volume buy and sell margin requirements |
|
| `minimum-margins` | Export minimum-volume buy and sell margin requirements |
|
||||||
| `market-book` | Export market depth (order book) |
|
| `market-book` | Export market depth (order book) |
|
||||||
| `orders` | Export active orders |
|
| `orders` | Export active orders |
|
||||||
| `positions` | Export open positions |
|
| `positions` | Export open positions |
|
||||||
| `history-orders` | Export historical orders |
|
| `history-orders` | Export historical orders |
|
||||||
| `history-deals` | Export historical deals |
|
| `history-deals` | Export historical deals |
|
||||||
| `recent-history-deals` | Export historical deals from a recent trailing window |
|
| `recent-history-deals` | Export historical deals from a recent trailing window |
|
||||||
| `mt5-summary` | Export terminal/account status summary |
|
| `mt5-summary` | Export terminal/account status summary |
|
||||||
| `order-check` | Check funds sufficiency for a trade request |
|
| `order-check` | Check funds sufficiency for a trade request |
|
||||||
| `order-send` | Send a trade request to the trade server (`--yes` required) |
|
| `order-send` | Send a raw trade request to the trade server (`--yes` required; expert path) |
|
||||||
| `collect-history` | Bundle rates, ticks, history-orders, and history-deals for one or more symbols into a single SQLite database |
|
| `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`.
|
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`
|
### `collect-history`
|
||||||
|
|
||||||
Collect several historical datasets per symbol into one SQLite database in a single MT5 session. Pick datasets with repeatable `--dataset` (default: all four), choose conflict behavior with `--if-exists append|replace|fail` (default: `fail`), and optionally derive `cash_events` / `positions_reconstructed` views from `history_deals` via `--with-views`.
|
Collect several historical datasets per symbol into one SQLite database in a single MT5 session. Pick datasets with repeatable `--dataset` (default: `rates`, `history-orders`, `history-deals`; add `--dataset ticks` when tick-level history is required — tick data can grow the SQLite database quickly), choose conflict behavior with `--if-exists append|replace|fail` (default: `fail`), and optionally derive `cash_events` / `positions_reconstructed` views from `history_deals` via `--with-views`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mt5cli -o history.db collect-history \
|
mt5cli -o history.db collect-history \
|
||||||
@@ -192,13 +221,116 @@ mt5cli -o history.db collect-history \
|
|||||||
|
|
||||||
History orders and deals are fetched per symbol and concatenated, so the symbol filter is applied consistently across all datasets. The `cash_events` view is derived from symbol-filtered `history_deals`, so account-level cash events with empty or non-matching symbols may be excluded. The `rates` table records the requested `timeframe` so appended runs at different timeframes remain distinguishable. The `positions_reconstructed` view aggregates trade deals by `position_id`, excludes positions without closing-side entries, and uses volume-weighted open/close prices; reversal deals (`DEAL_ENTRY_INOUT`) are reported via `volume_reversal` / `reversal_count` columns.
|
History orders and deals are fetched per symbol and concatenated, so the symbol filter is applied consistently across all datasets. The `cash_events` view is derived from symbol-filtered `history_deals`, so account-level cash events with empty or non-matching symbols may be excluded. The `rates` table records the requested `timeframe` so appended runs at different timeframes remain distinguishable. The `positions_reconstructed` view aggregates trade deals by `position_id`, excludes positions without closing-side entries, and uses volume-weighted open/close prices; reversal deals (`DEAL_ENTRY_INOUT`) are reported via `volume_reversal` / `reversal_count` columns.
|
||||||
|
|
||||||
|
### Grafana-ready SQLite dashboards
|
||||||
|
|
||||||
|
mt5cli can prepare a SQLite database for use as a Grafana datasource (via the [SQLite plugin](https://grafana.com/grafana/plugins/frser-sqlite-datasource/) or similar). Most `grafana_*` views expose an integer epoch-second `time` column for use in Grafana time-series panels. Two views (`grafana_realized_pnl`, `grafana_trade_stats`) are static symbol-level summaries with no `time` column — use them in table or stat panels.
|
||||||
|
|
||||||
|
#### Prepare the schema (idempotent, no MT5 connection needed)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mt5cli -o history.db grafana-schema
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates snapshot tables (`account_snapshots`, `position_snapshots`, `order_snapshots`, `terminal_snapshots`, `snapshot_runs`) and all `grafana_*` views and indexes in the SQLite database. Safe to run repeatedly — all operations are idempotent.
|
||||||
|
|
||||||
|
#### Snapshot current account state
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mt5cli -o history.db snapshot \
|
||||||
|
--symbol JP225 --symbol HK50 --symbol NL25 \
|
||||||
|
--with-account --with-positions --with-orders --with-terminal \
|
||||||
|
--with-grafana-schema
|
||||||
|
```
|
||||||
|
|
||||||
|
Appends one timestamped row per data type. Never places orders or modifies trading state. Run periodically (e.g. from a cron job or a loop) to build a time-series account history.
|
||||||
|
|
||||||
|
#### SDK usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pdmt5 import Mt5DataClient, Mt5Config
|
||||||
|
from mt5cli import update_observability, update_observability_with_config
|
||||||
|
|
||||||
|
# Reuse an already-connected client
|
||||||
|
client = Mt5DataClient(config=Mt5Config(login=12345))
|
||||||
|
client.initialize_and_login_mt5()
|
||||||
|
try:
|
||||||
|
update_observability(
|
||||||
|
client=client,
|
||||||
|
output="history.db",
|
||||||
|
symbols=["EURUSD", "GBPUSD"], # optional position/order filter
|
||||||
|
include_account=True,
|
||||||
|
include_positions=True,
|
||||||
|
include_orders=True,
|
||||||
|
include_terminal=True,
|
||||||
|
with_grafana_schema=True,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
client.shutdown()
|
||||||
|
|
||||||
|
# Standalone wrapper that opens/closes MT5 automatically
|
||||||
|
update_observability_with_config(
|
||||||
|
output="history.db",
|
||||||
|
config=Mt5Config(login=12345),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Available Grafana views
|
||||||
|
|
||||||
|
**Time-series views** (integer epoch-second `time` column; snapshot views also expose `run_id`):
|
||||||
|
|
||||||
|
| View | Source | Description |
|
||||||
|
| ---------------------------- | -------------------- | ---------------------------------------------------------- |
|
||||||
|
| `grafana_rates` | `rates` | OHLCV bars with integer epoch `time` |
|
||||||
|
| `grafana_ticks` | `ticks` | Tick data with integer epoch `time` |
|
||||||
|
| `grafana_history_deals` | `history_deals` | All deals with epoch `time` |
|
||||||
|
| `grafana_history_orders` | `history_orders` | All historical orders; adds epoch `time` from `time_setup` |
|
||||||
|
| `grafana_trade_deals` | `history_deals` | Trade deals only (`type IN (0,1)`) |
|
||||||
|
| `grafana_cash_events` | `history_deals` | Non-trade deals (deposits, dividends, etc.) |
|
||||||
|
| `grafana_symbol_pnl` | `history_deals` | Per-close-deal profit/loss per symbol |
|
||||||
|
| `grafana_account_snapshots` | `account_snapshots` | Account balance/equity/margin time series |
|
||||||
|
| `grafana_position_snapshots` | `position_snapshots` | Open position snapshots over time |
|
||||||
|
| `grafana_order_snapshots` | `order_snapshots` | Active order snapshots over time |
|
||||||
|
| `grafana_terminal_snapshots` | `terminal_snapshots` | Terminal connectivity snapshots |
|
||||||
|
|
||||||
|
**Static summary views** (no `time` column; use in table or stat panels, not time-series):
|
||||||
|
|
||||||
|
| View | Source | Description |
|
||||||
|
| ---------------------- | --------------- | ------------------------------------- |
|
||||||
|
| `grafana_realized_pnl` | `history_deals` | Cumulative realized PnL per symbol |
|
||||||
|
| `grafana_trade_stats` | `history_deals` | Win/loss counts and profit per symbol |
|
||||||
|
|
||||||
|
#### Example Grafana queries
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Equity curve over time
|
||||||
|
SELECT time, equity FROM grafana_account_snapshots ORDER BY time;
|
||||||
|
|
||||||
|
-- Rolling balance by account login
|
||||||
|
SELECT time, login, balance FROM grafana_account_snapshots
|
||||||
|
WHERE login = $login ORDER BY time;
|
||||||
|
|
||||||
|
-- Open positions at latest successful snapshot
|
||||||
|
SELECT symbol, volume, profit FROM grafana_position_snapshots
|
||||||
|
WHERE run_id = (SELECT MAX(run_id) FROM snapshot_runs WHERE status = 'ok');
|
||||||
|
|
||||||
|
-- Realized PnL by symbol
|
||||||
|
SELECT symbol, total_profit FROM grafana_trade_stats ORDER BY total_profit DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 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
|
### Incremental history SDK
|
||||||
|
|
||||||
For automated pipelines, use the importable incremental API instead of re-fetching fixed date ranges:
|
For automated pipelines, use the importable incremental API instead of re-fetching fixed date ranges:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from pdmt5 import Mt5Config, Mt5DataClient
|
from pdmt5 import Mt5Config, Mt5DataClient
|
||||||
from mt5cli import Dataset, update_history, update_history_with_config
|
from mt5cli import update_history, update_history_with_config
|
||||||
|
from mt5cli.utils import Dataset
|
||||||
|
|
||||||
# Reuse an already-connected pdmt5 client (does not open/close MT5)
|
# Reuse an already-connected pdmt5 client (does not open/close MT5)
|
||||||
client = Mt5DataClient(config=Mt5Config(login=12345))
|
client = Mt5DataClient(config=Mt5Config(login=12345))
|
||||||
@@ -233,7 +365,7 @@ update_history_with_config(
|
|||||||
- **Rate view resolution**: use `resolve_rate_view_name()` / `resolve_rate_view_names()` to map symbols and granularities to existing SQLite compatibility views without creating databases. Both accept `None` (or a missing path) and return deterministic default names unless `require_existing=True`.
|
- **Rate view resolution**: use `resolve_rate_view_name()` / `resolve_rate_view_names()` to map symbols and granularities to existing SQLite compatibility views without creating databases. Both accept `None` (or a missing path) and return deterministic default names unless `require_existing=True`.
|
||||||
- **Rate view loading**: use `load_rate_data()` / `load_rate_data_from_connection()` to load a SQLite rate table or view into a `DatetimeIndex` DataFrame.
|
- **Rate view loading**: use `load_rate_data()` / `load_rate_data_from_connection()` to load a SQLite rate table or view into a `DatetimeIndex` DataFrame.
|
||||||
- **Multi-series rate loading**: use `build_rate_targets()` to build neutral `RateTarget(symbol, timeframe)` pairs, `resolve_rate_tables()` to map them to table/view names (pass `require_existing=True` for strict resolution), and `load_rate_series_from_sqlite()` to load them into a mapping keyed by `(symbol, integer timeframe)`. The loader requires existing managed views unless `explicit_tables` is supplied, and rejects duplicate `(symbol, timeframe)` targets.
|
- **Multi-series rate loading**: use `build_rate_targets()` to build neutral `RateTarget(symbol, timeframe)` pairs, `resolve_rate_tables()` to map them to table/view names (pass `require_existing=True` for strict resolution), and `load_rate_series_from_sqlite()` to load them into a mapping keyed by `(symbol, integer timeframe)`. The loader requires existing managed views unless `explicit_tables` is supplied, and rejects duplicate `(symbol, timeframe)` targets.
|
||||||
- **Multi-account latest rates**: use `collect_latest_rates_for_accounts()` with `AccountSpec` to read the latest bars for several account groups, merged into a `(symbol, integer timeframe)` mapping. For long-running pollers, `collect_latest_rates_for_accounts_with_retries()` adds bounded exponential backoff that retries only `pdmt5.Mt5TradingError` / `pdmt5.Mt5RuntimeError` and re-raises once `retry_count` is exhausted.
|
- **Multi-account latest rates**: use `collect_latest_rates_for_accounts()` with `AccountSpec` to read the latest bars for several account groups, merged into a `(symbol, integer timeframe)` mapping. For long-running pollers, `collect_latest_rates_for_accounts_with_retries()` adds bounded exponential backoff that retries only recoverable MT5 errors and re-raises once `retry_count` is exhausted.
|
||||||
- **Latest closed bars**: use `collect_latest_closed_rates_for_accounts()` when downstream logic must exclude the still-forming current bar. It fetches `count + 1` bars at `start_pos=0`, drops the last row with `drop_forming_rate_bar()`, and validates each series is non-empty. `collect_latest_closed_rates_by_granularity()` returns the same data keyed by `(symbol, granularity_name)` such as `("EURUSD", "M1")`.
|
- **Latest closed bars**: use `collect_latest_closed_rates_for_accounts()` when downstream logic must exclude the still-forming current bar. It fetches `count + 1` bars at `start_pos=0`, drops the last row with `drop_forming_rate_bar()`, and validates each series is non-empty. `collect_latest_closed_rates_by_granularity()` returns the same data keyed by `(symbol, granularity_name)` such as `("EURUSD", "M1")`.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -248,9 +380,9 @@ rates = collect_latest_closed_rates_by_granularity(
|
|||||||
eurusd_m1 = rates["EURUSD", "M1"] # closed bars only
|
eurusd_m1 = rates["EURUSD", "M1"] # closed bars only
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Credential resolution**: use `resolve_account_spec()` / `resolve_account_specs()` to merge explicit override values over `AccountSpec` fields and expand `${ENV_VAR}` placeholders (via `substitute_env_placeholders()`), raising `ValueError` for missing variables. This keeps secrets out of plan/config files without coupling to any strategy code.
|
- **Credential resolution**: use `resolve_account_spec()` / `resolve_account_specs()` to merge explicit override values over `AccountSpec` fields and expand `${ENV_VAR}` placeholders (via `substitute_env_placeholders()`), raising `ValueError` for missing variables. This keeps secrets out of plan/config files without coupling to any strategy code. For config dicts or nested structures loaded from YAML/TOML, use `substitute_mapping_values(data, keys={"login", "password"})` to expand placeholders only for caller-specified keys — key names are never hard-coded in mt5cli.
|
||||||
- **Throttled history updates**: use `ThrottledHistoryUpdater` to wrap `update_history()` with a minimum `interval_seconds` between successful runs (monotonic clock). Call `should_update()` / `update(client, symbols)` from an application loop; errors propagate by default, or pass `suppress_errors=True` to swallow recoverable `Mt5*Error`, `sqlite3.Error`, `ValueError`, `OSError`, and MT5 client capability errors for history API methods without advancing the throttle (other `AttributeError` / `TypeError` values always propagate). Pass `update_backend` to inject a custom history update callable (same keyword arguments as `update_history`) instead of monkey-patching `mt5cli.sdk.update_history`.
|
- **Throttled history updates**: use `ThrottledHistoryUpdater` to wrap `update_history()` with a minimum `interval_seconds` between successful runs (monotonic clock). Call `should_update()` / `update(client, symbols)` from an application loop; errors propagate by default, or pass `suppress_errors=True` to swallow recoverable `Mt5*Error`, `sqlite3.Error`, `ValueError`, `OSError`, and MT5 client capability errors for history API methods without advancing the throttle (other `AttributeError` / `TypeError` values always propagate). Pass `update_backend` to inject a custom history update callable (same keyword arguments as `update_history`) instead of monkey-patching `mt5cli.sdk.update_history`.
|
||||||
- **Trading session helpers**: use `mt5_trading_session()` for a trading-capable `pdmt5.Mt5TradingClient` that initializes/logs in via `Mt5Config.path` and always shuts down safely. Pair with `detect_position_side()`, `calculate_margin_and_volume()`, and `determine_order_limits()` for generic position and sizing utilities. The read-only `mt5_session()` / `Mt5CliClient` SDK is unchanged.
|
- **Trading session helpers**: use `mt5_trading_session()` for a trading-capable client that initializes/logs in via `Mt5Config.path` and always shuts down safely. Pair with `detect_position_side()`, `calculate_margin_and_volume()`, and `determine_order_limits()` for generic position and sizing utilities. Keep read-only collection on `mt5_session()` / `MT5Client`.
|
||||||
- **Granularity-keyed rate loading**: `load_rate_series_by_granularity()` builds targets with `build_rate_targets()`, loads them with `load_rate_series_from_sqlite()`, and returns a mapping keyed by `(symbol | None, granularity_name)` such as `("EURUSD", "M1")` to reduce downstream boilerplate.
|
- **Granularity-keyed rate loading**: `load_rate_series_by_granularity()` builds targets with `build_rate_targets()`, loads them with `load_rate_series_from_sqlite()`, and returns a mapping keyed by `(symbol | None, granularity_name)` such as `("EURUSD", "M1")` to reduce downstream boilerplate.
|
||||||
- **MT5 session helper**: use the `mt5_session()` context manager to attach to (or, when `Mt5Config.path` is set, launch) an MT5 terminal, log in, and yield a connected `MT5Client` that shuts down on exit.
|
- **MT5 session helper**: use the `mt5_session()` context manager to attach to (or, when `Mt5Config.path` is set, launch) an MT5 terminal, log in, and yield a connected `MT5Client` that shuts down on exit.
|
||||||
- **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns.
|
- **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns.
|
||||||
@@ -317,7 +449,7 @@ finally:
|
|||||||
client.shutdown()
|
client.shutdown()
|
||||||
```
|
```
|
||||||
|
|
||||||
Read-only collectors can keep using `mt5_session()` and `MT5Client` (or the `Mt5CliClient` alias) without changes.
|
Read-only collectors can keep using `mt5_session()` and `MT5Client`.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
+5
-3
@@ -182,12 +182,14 @@ targets without hard-coding view names:
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from mt5cli import (
|
from mt5cli import (
|
||||||
load_rate_data,
|
|
||||||
load_rate_series_by_granularity,
|
load_rate_series_by_granularity,
|
||||||
load_rate_series_from_sqlite,
|
load_rate_series_from_sqlite,
|
||||||
resolve_rate_table_name,
|
|
||||||
)
|
)
|
||||||
from mt5cli.history import resolve_rate_view_name
|
from mt5cli.history import (
|
||||||
|
load_rate_data,
|
||||||
|
resolve_rate_table_name,
|
||||||
|
resolve_rate_view_name,
|
||||||
|
)
|
||||||
|
|
||||||
view = resolve_rate_view_name(Path("history.db"), "EURUSD", "M1", require_existing=True)
|
view = resolve_rate_view_name(Path("history.db"), "EURUSD", "M1", require_existing=True)
|
||||||
rates = load_rate_data(Path("history.db"), view, count=1000)
|
rates = load_rate_data(Path("history.db"), view, count=1000)
|
||||||
|
|||||||
+5
-5
@@ -13,12 +13,13 @@ responsibilities.
|
|||||||
| [Public API Contract](public-contract.md) | Stable downstream SDK exports, CLI boundary, and out-of-scope items |
|
| [Public API Contract](public-contract.md) | Stable downstream SDK exports, CLI boundary, and out-of-scope items |
|
||||||
| [Client](client.md) | `MT5Client` session abstraction for data access and order primitives |
|
| [Client](client.md) | `MT5Client` session abstraction for data access and order primitives |
|
||||||
| [Schemas](schemas.md) | Canonical DataFrame contracts and normalization helpers |
|
| [Schemas](schemas.md) | Canonical DataFrame contracts and normalization helpers |
|
||||||
| [Storage](storage.md) | CSV/JSON/Parquet/SQLite export and history collection helpers |
|
|
||||||
| [Converters](converters.md) | Symbol, timeframe, timezone, and date-range utilities |
|
| [Converters](converters.md) | Symbol, timeframe, timezone, and date-range utilities |
|
||||||
| [Exceptions](exceptions.md) | Stable mt5cli exception types and MT5 error normalization |
|
| [Exceptions](exceptions.md) | Stable mt5cli exception types and MT5 error normalization |
|
||||||
| [SDK](sdk.md) | Module-level fetch helpers, multi-account collectors, incremental history |
|
| [SDK](sdk.md) | Module-level fetch helpers, multi-account collectors, incremental history |
|
||||||
| [Trading](trading.md) | Trading-capable sessions and operational helpers |
|
| [Trading](trading.md) | Trading-capable sessions and operational helpers |
|
||||||
| [History Collection (SQLite)](history.md) | SQLite schema, incremental writes, dedup, and rate views |
|
| [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 |
|
| [CLI](cli.md) | Typer commands that delegate to the Python API |
|
||||||
| [Utils](utils.md) | Parsing helpers and Click parameter types |
|
| [Utils](utils.md) | Parsing helpers and Click parameter types |
|
||||||
|
|
||||||
@@ -30,15 +31,14 @@ flowchart TD
|
|||||||
CLI["mt5cli CLI"] --> Client
|
CLI["mt5cli CLI"] --> Client
|
||||||
Client --> SDK["sdk / pdmt5"]
|
Client --> SDK["sdk / pdmt5"]
|
||||||
Client --> Schemas["schemas"]
|
Client --> Schemas["schemas"]
|
||||||
Storage["storage"] --> History["history SQLite"]
|
History["history SQLite"] --> Utils["utils export"]
|
||||||
Storage --> Utils["utils export"]
|
|
||||||
SDK --> PDMT5["pdmt5.Mt5DataClient"]
|
SDK --> PDMT5["pdmt5.Mt5DataClient"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Downstream packages should depend on the package root exports documented in the
|
Downstream packages should depend on the package root exports documented in the
|
||||||
[Public API Contract](public-contract.md) (`MT5Client`,
|
[Public API Contract](public-contract.md) (`MT5Client`,
|
||||||
`DataKind`, `normalize_dataframe`, `collect_history`, `load_rate_data`,
|
`collect_history`, `load_rate_series_from_sqlite`, etc.) rather than private
|
||||||
`resolve_rate_view_name`, etc.) rather than private modules.
|
modules. Lower-level helpers are accessible directly from their owning modules.
|
||||||
|
|
||||||
`MT5Client.order_send()` is a live execution primitive that can place real trades. mt5cli exposes minimal execution helpers only; strategy logic, signals, backtests, and optimization remain out of scope and must be implemented downstream with explicit execution gating.
|
`MT5Client.order_send()` is a live execution primitive that can place real trades. mt5cli exposes minimal execution helpers only; strategy logic, signals, backtests, and optimization remain out of scope and must be implemented downstream with explicit execution gating.
|
||||||
|
|
||||||
|
|||||||
+180
-91
@@ -1,61 +1,53 @@
|
|||||||
# Public API Contract
|
# Public API Contract
|
||||||
|
|
||||||
mt5cli is the generic MT5 data and execution infrastructure layer for downstream
|
mt5cli is the canonical operational trading SDK and CLI/batch layer over pdmt5.
|
||||||
Python applications. The intended dependency direction is:
|
The intended dependency direction is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
downstream app -> mt5cli -> pdmt5 -> MetaTrader 5
|
downstream app -> mt5cli -> pdmt5 -> MetaTrader 5
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Responsibility boundary
|
||||||
|
|
||||||
|
| Layer | Owns |
|
||||||
|
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **pdmt5** | MT5 core wrapper; DataFrame/dict conversion; canonical MT5 constants and parsers; direct low-level order primitives |
|
||||||
|
| **mt5cli** | CLI/batch workflows; SQLite history collection; normalized datasets; closed-bar helpers; small downstream operational SDK; generic broker-facing margin/volume/order orchestration |
|
||||||
|
| **downstream** | Strategy logic; signals; risk policy; backtesting; optimization; YAML/application semantics |
|
||||||
|
|
||||||
|
Downstream code should import raw pdmt5 types and constants (such as
|
||||||
|
`Mt5Config`, `Mt5RuntimeError`, `TIMEFRAME_MAP`, `COPY_TICKS_MAP`) directly
|
||||||
|
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. `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.
|
||||||
|
|
||||||
Downstream packages should import from the package root (`from mt5cli import
|
Downstream packages should import from the package root (`from mt5cli import
|
||||||
...`) and treat the symbols listed below as the stable SDK contract. CLI
|
...`). The contract set `STABLE_SDK_EXPORTS` in `mt5cli.contract` enumerates
|
||||||
commands mirror the same behavior but are not importable Python APIs.
|
every package-root symbol. Lower-level helpers (schema utilities, export
|
||||||
|
functions, parser helpers, low-level MT5 wrappers) are available directly from
|
||||||
|
their owning modules (`mt5cli.schemas`, `mt5cli.utils`, `mt5cli.converters`,
|
||||||
|
`mt5cli.sdk`, etc.) and are not part of the root SDK surface.
|
||||||
|
|
||||||
## Stable downstream SDK API
|
## Stable downstream SDK API
|
||||||
|
|
||||||
These names are exported from `mt5cli` and covered by the contract in
|
These names are exported from `mt5cli` and enumerated in
|
||||||
`mt5cli.STABLE_SDK_EXPORTS` (defined in `mt5cli.contract`). Prefer `MT5Client` over the legacy `Mt5CliClient`
|
`mt5cli.STABLE_SDK_EXPORTS` (defined in `mt5cli.contract`).
|
||||||
alias for new code.
|
|
||||||
|
|
||||||
### Session lifecycle and configuration
|
### Session lifecycle and configuration
|
||||||
|
|
||||||
| Symbol | Role |
|
| Symbol | Role |
|
||||||
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `MT5Client`, `Mt5CliClient` | Read-only data client with optional `order_check` / `order_send` |
|
| `MT5Client` | Read-only data client with optional `order_check` / `order_send` |
|
||||||
| `build_config` | Build `pdmt5.Mt5Config` from connection fields |
|
| `build_config` | Build `pdmt5.Mt5Config` from connection fields; `login` accepts `int \| str \| None` — numeric strings are coerced to `int`, blank strings are treated as unset, and `${ENV_VAR}` / `$ENV_NAME` placeholders in string parameters are expanded when `allow_whole_dollar_env=True` |
|
||||||
| `mt5_session` | Context manager: initialize, login, yield client, shutdown |
|
| `mt5_session` | Context manager: initialize, login, yield client, shutdown |
|
||||||
| `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle |
|
| `create_trading_client`, `mt5_trading_session` | Trading-capable MT5 client lifecycle; returns a raw `pdmt5.Mt5DataClient` (not `MT5Client`) supporting order execution, account management, and history deal retrieval |
|
||||||
| `AccountSpec` | Generic account group: symbols plus optional credentials |
|
| `AccountSpec` | Generic account group: symbols plus optional credentials |
|
||||||
| `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders; opt-in `allow_whole_dollar_env` for bare `$NAME` |
|
| `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders; opt-in `allow_whole_dollar_env` for bare `$NAME` |
|
||||||
| `substitute_env_placeholders` | Replace `${NAME}` substrings from the environment; opt-in `allow_whole_dollar_env` for whole-value `$NAME` |
|
|
||||||
|
|
||||||
Credential resolution is generic: any environment variable name may appear inside
|
|
||||||
`${...}`. mt5cli does not hard-code application-specific keys such as
|
|
||||||
`mt5_login` or `mt5_exe`.
|
|
||||||
|
|
||||||
Pass `allow_whole_dollar_env=True` to `substitute_env_placeholders()`,
|
|
||||||
`resolve_account_spec()`, `resolve_account_specs()`, and `build_config()` to
|
|
||||||
additionally expand strings whose entire value is a bare `$ENV_NAME` identifier.
|
|
||||||
Partial strings such as `"plan$pass"`, `"abc$ENV"`, or `"$ENV-suffix"` are
|
|
||||||
**never** expanded — only an exact `$IDENTIFIER` whole-string match qualifies.
|
|
||||||
Default is `False` to preserve backward compatibility.
|
|
||||||
|
|
||||||
### Read-only MT5 data access
|
|
||||||
|
|
||||||
Module-level helpers open a transient connection per call. Prefer `mt5_session`
|
|
||||||
or `MT5Client` when making many requests in one process.
|
|
||||||
|
|
||||||
| Area | Symbols |
|
|
||||||
| -------------------- | ---------------------------------------------------------------------------------------------------- |
|
|
||||||
| Rates | `copy_rates_from`, `copy_rates_from_pos`, `copy_rates_range`, `latest_rates`, `collect_latest_rates` |
|
|
||||||
| Ticks | `copy_ticks_from`, `copy_ticks_range`, `recent_ticks` |
|
|
||||||
| Account / terminal | `account_info`, `terminal_info`, `mt5_version`, `last_error`, `mt5_summary`, `mt5_summary_as_df` |
|
|
||||||
| Symbols / market | `symbols`, `symbol_info`, `symbol_info_tick`, `market_book`, `minimum_margins` |
|
|
||||||
| Trading state (read) | `orders`, `positions`, `history_orders`, `history_deals`, `recent_history_deals` |
|
|
||||||
|
|
||||||
Use `mt5_version` for MetaTrader 5 terminal version data. The name `version` at
|
|
||||||
the package root refers to `importlib.metadata.version` (package metadata), not
|
|
||||||
the MT5 SDK helper.
|
|
||||||
|
|
||||||
### Closed-bar rate helpers
|
### Closed-bar rate helpers
|
||||||
|
|
||||||
@@ -67,29 +59,22 @@ timestamp normalization in downstream apps.
|
|||||||
| ------------------------------------------------ | ------------------------------------------------------------------------------- |
|
| ------------------------------------------------ | ------------------------------------------------------------------------------- |
|
||||||
| `drop_forming_rate_bar` | Remove the last row from chronologically ordered rate data |
|
| `drop_forming_rate_bar` | Remove the last row from chronologically ordered rate data |
|
||||||
| `fetch_latest_closed_rates` | Single connected client: fetch `count + 1`, drop forming bar |
|
| `fetch_latest_closed_rates` | Single connected client: fetch `count + 1`, drop forming bar |
|
||||||
| `fetch_latest_closed_rates_for_trading_client` | Closed bars from an active `Mt5TradingClient` session; returns RangeIndex |
|
| `fetch_latest_closed_rates_for_trading_client` | Closed bars from an active trading client session; returns RangeIndex |
|
||||||
| `fetch_latest_closed_rates_indexed` | Same as above but returns a UTC `DatetimeIndex` named `"time"` (no time column) |
|
| `fetch_latest_closed_rates_indexed` | Same as above but returns a UTC `DatetimeIndex` named `"time"` (no time column) |
|
||||||
| `collect_latest_closed_rates_for_accounts` | Multi-account closed bars with optional retry wrapper |
|
| `collect_latest_closed_rates_for_accounts` | Multi-account closed bars with optional retry wrapper |
|
||||||
| `collect_latest_closed_rates_by_granularity` | Same data keyed by `(symbol, granularity_name)` |
|
| `collect_latest_closed_rates_by_granularity` | Same data keyed by `(symbol, granularity_name)` |
|
||||||
| `collect_latest_rates_for_accounts` | Latest bars including the forming bar when `start_pos=0` |
|
|
||||||
| `collect_latest_rates_for_accounts_with_retries` | Bounded exponential backoff for transient MT5 errors |
|
| `collect_latest_rates_for_accounts_with_retries` | Bounded exponential backoff for transient MT5 errors |
|
||||||
|
|
||||||
### SQLite history collection and rate loading
|
### SQLite history collection and rate loading
|
||||||
|
|
||||||
| Symbol | Role |
|
| Symbol | Role |
|
||||||
| ----------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||||
| `collect_history` | One-shot date-range export into SQLite |
|
| `collect_history` | One-shot date-range export into SQLite |
|
||||||
| `update_history`, `update_history_with_config` | Incremental append from `MAX(time)` cursors |
|
| `report_rate_gaps` | SQLite-only one-row-per-gap report for a rate table or compatibility view |
|
||||||
| `ThrottledHistoryUpdater` | Minimum interval between successful incremental updates; optional `update_backend` injection |
|
| `update_history`, `update_history_with_config` | Incremental append from `MAX(time)` cursors |
|
||||||
| `resolve_history_datasets`, `resolve_history_timeframes`, `resolve_history_tick_flags` | History pipeline configuration |
|
| `ThrottledHistoryUpdater` | Minimum interval between successful incremental updates; optional `update_backend` injection |
|
||||||
| `build_rate_view_name`, `resolve_rate_table_name`, `resolve_rate_view_name`, `resolve_rate_view_names`, `resolve_rate_tables` | Map symbols/timeframes to mt5cli-managed table or view names |
|
| `RateTarget`, `build_rate_targets` | Neutral `(symbol, timeframe)` series descriptors |
|
||||||
| `RateTarget`, `build_rate_targets` | Neutral `(symbol, timeframe)` series descriptors |
|
| `load_rate_series_from_sqlite`, `load_rate_series_by_granularity` | Load one or many series; fail clearly when managed views are missing |
|
||||||
| `load_rate_data`, `load_rate_data_from_connection` | Load one table/view into a time-indexed DataFrame |
|
|
||||||
| `load_rate_series_from_sqlite`, `load_rate_series_by_granularity` | Load one or many series; fail clearly when managed views are missing |
|
|
||||||
|
|
||||||
Pass `require_existing=True` to rate view resolution helpers when downstream
|
|
||||||
code must fail instead of receiving a best-guess view name. Multi-series loaders
|
|
||||||
require existing managed `rate_*__*` views unless `explicit_tables` is supplied.
|
|
||||||
|
|
||||||
See [History Collection (SQLite)](history.md) for schema, view naming, and ER
|
See [History Collection (SQLite)](history.md) for schema, view naming, and ER
|
||||||
diagrams.
|
diagrams.
|
||||||
@@ -99,25 +84,40 @@ diagrams.
|
|||||||
These helpers implement broker-facing calculations only. They do not encode
|
These helpers implement broker-facing calculations only. They do not encode
|
||||||
strategy entries, exits, Kelly sizing, or signal logic.
|
strategy entries, exits, Kelly sizing, or signal logic.
|
||||||
|
|
||||||
| Symbol | Role |
|
| Symbol | Role |
|
||||||
| -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
|
| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
|
||||||
| `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views |
|
| `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views |
|
||||||
| `detect_position_side` | Net long / short / flat from open positions |
|
| `extract_tick_price` | Positive finite bid/ask extraction from tick mappings |
|
||||||
| `calculate_spread_ratio` | Relative bid-ask spread |
|
| `detect_position_side` | Net long / short / flat from open positions |
|
||||||
| `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing |
|
| `calculate_spread_ratio` | Relative bid-ask spread |
|
||||||
| `normalize_order_volume`, `estimate_order_margin`, `calculate_positions_margin` | Broker volume normalization and margin totals |
|
| `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing |
|
||||||
| `calculate_positions_margin_by_symbol` | Per-symbol margin map (resilient, first-seen order) |
|
| `normalize_order_volume`, `estimate_order_margin`, `calculate_positions_margin` | Broker volume normalization and margin totals |
|
||||||
| `calculate_positions_margin_safe` | Summed total margin across symbols (failed symbols skipped) |
|
| `calculate_positions_margin_by_symbol` | Per-symbol margin map (resilient, first-seen order) |
|
||||||
| `determine_order_limits` | SL/TP price levels from ratios |
|
| `calculate_positions_margin_safe` | Summed total margin across symbols (failed symbols skipped) |
|
||||||
| `ensure_symbol_selected` | Select/verify Market Watch visibility |
|
| `calculate_projected_margin_ratio` | Estimated symbol-scoped margin/equity after optional new exposure |
|
||||||
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions` | Order execution helpers (`dry_run` supported) |
|
| `calculate_account_projected_margin_ratio` | Account snapshot margin/equity after optional new exposure |
|
||||||
| `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers |
|
| `calculate_symbol_group_margin_ratio` | Estimated symbol-group margin/equity with optional exposure |
|
||||||
| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers |
|
| `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) |
|
||||||
|
| `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers |
|
||||||
|
| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers |
|
||||||
|
| `ProjectionMode` | Literal type for `calculate_symbol_group_margin_ratio` projection |
|
||||||
|
|
||||||
|
`calculate_symbol_group_margin_ratio` accepts an optional `projection_mode`
|
||||||
|
parameter (`"add"` by default). Pass `projection_mode="replace_symbol"` to
|
||||||
|
subtract current exposure for `new_symbol` before adding the candidate margin —
|
||||||
|
useful for reversal-style projections. mt5cli only calculates broker-facing
|
||||||
|
exposure; downstream applications own thresholds, risk guard actions, and
|
||||||
|
strategy policy.
|
||||||
|
|
||||||
`MT5Client.order_send()` and CLI `order-send --yes` are live execution paths.
|
`MT5Client.order_send()` and CLI `order-send --yes` are live execution paths.
|
||||||
|
|
||||||
Order helpers validate broker stop-level distance in `determine_order_limits()` and
|
Order helpers validate broker stop-level distance in `determine_order_limits()` and
|
||||||
raise `Mt5TradingError` when computed SL/TP prices are too close to the entry
|
raise `Mt5OperationError` when computed SL/TP prices are too close to the entry
|
||||||
quote. Validation uses `trade_stops_level * point` from the current quote and
|
quote. Validation uses `trade_stops_level * point` from the current quote and
|
||||||
symbol metadata as a pre-check only; it does not guarantee live order acceptance
|
symbol metadata as a pre-check only; it does not guarantee live order acceptance
|
||||||
after price movement and does not inspect `trade_freeze_level`. Live
|
after price movement and does not inspect `trade_freeze_level`. Live
|
||||||
@@ -127,21 +127,83 @@ sending requests. Failed, malformed, or unknown broker retcodes are fail-closed
|
|||||||
and returned as `status="failed"` with normalized `request` / `response` details;
|
and returned as `status="failed"` with normalized `request` / `response` details;
|
||||||
`dry_run=True` never calls `ensure_symbol_selected()` or `order_send()`.
|
`dry_run=True` never calls `ensure_symbol_selected()` or `order_send()`.
|
||||||
|
|
||||||
### Errors and MT5 type re-exports
|
### Grafana observability (SQLite read model)
|
||||||
|
|
||||||
| Symbol | Role |
|
These helpers prepare a SQLite database as a Grafana datasource. All DDL is
|
||||||
| ------------------------------------------------------------------------------------ | ----------------------------------------------- |
|
idempotent (`CREATE TABLE IF NOT EXISTS`, `DROP VIEW IF EXISTS` + `CREATE
|
||||||
| `Mt5CliError`, `Mt5ConnectionError`, `Mt5OperationError`, `Mt5SchemaError` | Stable mt5cli exception types |
|
VIEW`, `CREATE INDEX IF NOT EXISTS`). Missing source tables are skipped with a
|
||||||
| `normalize_mt5_exception`, `call_with_normalized_errors`, `is_recoverable_mt5_error` | Error normalization and retry classification |
|
warning rather than raising an error.
|
||||||
| `Mt5Config`, `Mt5RuntimeError`, `Mt5TradingClient`, `Mt5TradingError` | Re-exported pdmt5 types for adapter convenience |
|
|
||||||
|
|
||||||
### Additional public exports (secondary)
|
| Symbol | Role |
|
||||||
|
| ---------------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||||
|
| `update_observability` | Append one timestamped snapshot row per data type; accepts an already-connected `Mt5DataClient` |
|
||||||
|
| `update_observability_with_config` | Standalone wrapper: opens/closes MT5 connection automatically around `update_observability` |
|
||||||
|
|
||||||
The package root also exports schema, storage, and parsing helpers (for example
|
Both functions write to the SQLite path given by `output=`. The optional
|
||||||
`DataKind`, `Dataset`, `normalize_dataframe`, `export_dataframe`,
|
`symbols` parameter filters `positions_get` / `orders_get` by symbol.
|
||||||
`parse_timeframe`, `TIMEFRAME_MAP`). These are public but oriented toward export
|
`with_grafana_schema=False` (default) skips Grafana view/index setup; run
|
||||||
pipelines and advanced integration. Prefer the stable symbols above for core
|
`grafana-schema` once to set up the schema, then call `snapshot` repeatedly
|
||||||
infrastructure.
|
without this flag.
|
||||||
|
|
||||||
|
**Snapshot tables** (created by `create_snapshot_tables` in `mt5cli.grafana`):
|
||||||
|
|
||||||
|
| Table | Content |
|
||||||
|
| -------------------- | ----------------------------------------- |
|
||||||
|
| `account_snapshots` | Balance, equity, margin, free-margin, P&L |
|
||||||
|
| `position_snapshots` | Open positions: symbol, volume, profit, … |
|
||||||
|
| `order_snapshots` | Active orders: symbol, type, price, … |
|
||||||
|
| `terminal_snapshots` | Terminal connectivity and build info |
|
||||||
|
| `snapshot_runs` | Per-run status (`ok` / `error`) timestamp |
|
||||||
|
|
||||||
|
**Grafana time-series views** (integer epoch-second `time` column; snapshot views also expose `run_id`):
|
||||||
|
|
||||||
|
| View | Source |
|
||||||
|
| ---------------------------- | -------------------------------- |
|
||||||
|
| `grafana_rates` | `rates` table |
|
||||||
|
| `grafana_ticks` | `ticks` table |
|
||||||
|
| `grafana_history_deals` | `history_deals` |
|
||||||
|
| `grafana_history_orders` | `history_orders` |
|
||||||
|
| `grafana_trade_deals` | `history_deals` trade types only |
|
||||||
|
| `grafana_cash_events` | `history_deals` non-trade events |
|
||||||
|
| `grafana_symbol_pnl` | Per-close-deal P&L per symbol |
|
||||||
|
| `grafana_account_snapshots` | `account_snapshots` |
|
||||||
|
| `grafana_position_snapshots` | `position_snapshots` |
|
||||||
|
| `grafana_order_snapshots` | `order_snapshots` |
|
||||||
|
| `grafana_terminal_snapshots` | `terminal_snapshots` |
|
||||||
|
|
||||||
|
**Grafana static summary views** (no `time` column; use for table/stat panels, not time-series):
|
||||||
|
|
||||||
|
| View | Source |
|
||||||
|
| ---------------------- | ------------------------------------- |
|
||||||
|
| `grafana_realized_pnl` | Cumulative realized PnL per symbol |
|
||||||
|
| `grafana_trade_stats` | Win/loss counts and profit per symbol |
|
||||||
|
|
||||||
|
Lower-level helpers (`ensure_grafana_schema`, `create_grafana_views`,
|
||||||
|
`create_grafana_indexes`, `create_snapshot_tables`, `start_snapshot_run`,
|
||||||
|
`insert_account_snapshot`, `insert_position_snapshots`, `insert_order_snapshots`,
|
||||||
|
`insert_terminal_snapshot`, `record_snapshot_run`) are available directly from
|
||||||
|
`mt5cli.grafana` and are not part of the package-root stable surface.
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
|
| Symbol | Role |
|
||||||
|
| -------------------------------------------------------------------------- | ----------------------------- |
|
||||||
|
| `Mt5CliError`, `Mt5ConnectionError`, `Mt5OperationError`, `Mt5SchemaError` | Stable mt5cli exception types |
|
||||||
|
|
||||||
|
## Module-scoped helpers
|
||||||
|
|
||||||
|
Lower-level helpers are available from their owning modules and are not part
|
||||||
|
of the package-root stable surface. Import them directly when needed:
|
||||||
|
|
||||||
|
| Module | Examples |
|
||||||
|
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `mt5cli.grafana` | `ensure_grafana_schema`, `create_grafana_views`, `create_grafana_indexes`, `create_snapshot_tables`, `start_snapshot_run`, `insert_account_snapshot`, `record_snapshot_run` |
|
||||||
|
| `mt5cli.history` | `resolve_rate_view_name`, `resolve_rate_tables`, `load_rate_data`, `build_rate_view_name` |
|
||||||
|
| `mt5cli.sdk` | `copy_rates_from`, `copy_ticks_from`, `account_info`, `symbols`, `mt5_summary`, `latest_rates` |
|
||||||
|
| `mt5cli.schemas` | `DataKind`, `normalize_dataframe`, `validate_schema`, `DEDUP_KEYS` |
|
||||||
|
| `mt5cli.utils` | `Dataset`, `IfExists`, `detect_format`, `export_dataframe`, `export_dataframe_to_sqlite` |
|
||||||
|
| `mt5cli.converters` | `normalize_symbol`, `ensure_utc`, `parse_date_range`, `granularity_name` |
|
||||||
|
| `mt5cli.exceptions` | `normalize_mt5_exception`, `call_with_normalized_errors`, `is_recoverable_mt5_error` |
|
||||||
|
|
||||||
## CLI commands
|
## CLI commands
|
||||||
|
|
||||||
@@ -151,10 +213,34 @@ The Typer application in `mt5cli.cli` exposes file-export commands documented in
|
|||||||
- Require `-o/--output` and write CSV, JSON, Parquet, or SQLite.
|
- Require `-o/--output` and write CSV, JSON, Parquet, or SQLite.
|
||||||
- Accept global MT5 connection options (`--login`, `--password`, `--server`,
|
- Accept global MT5 connection options (`--login`, `--password`, `--server`,
|
||||||
`--path`, `--timeout`).
|
`--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
|
- Delegate to the same Python APIs described here; they are not duplicated
|
||||||
business logic.
|
business logic.
|
||||||
|
|
||||||
`order-send` requires `--yes` before placing live trades.
|
`grafana-schema` initializes Grafana views, indexes, and snapshot tables in the
|
||||||
|
target SQLite database without connecting to MT5. It is idempotent and safe to
|
||||||
|
run repeatedly.
|
||||||
|
|
||||||
|
`snapshot` appends one timestamped row per enabled data type
|
||||||
|
(`--with-account`, `--with-positions`, `--with-orders`, `--with-terminal`) and
|
||||||
|
never places orders or modifies trading state. Both commands require
|
||||||
|
`-o/--output` to point at a `.db` / SQLite file.
|
||||||
|
|
||||||
|
`order-send` is the expert raw-request path; it requires `--yes` and a fully
|
||||||
|
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`. `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)
|
## Internal helpers (not stable)
|
||||||
|
|
||||||
@@ -181,6 +267,9 @@ The following belong in consuming applications, not in mt5cli:
|
|||||||
- Backtesting, walk-forward analysis, or parameter optimization
|
- Backtesting, walk-forward analysis, or parameter optimization
|
||||||
- Strategy-specific risk policy, position sizing systems, or Kelly fractions
|
- Strategy-specific risk policy, position sizing systems, or Kelly fractions
|
||||||
- Entry/exit decision logic or YAML strategy semantics
|
- Entry/exit decision logic or YAML strategy semantics
|
||||||
|
- Entry-deal classification, Kelly fractions, or betting-specific deal transformations
|
||||||
|
(use `fetch_recent_history_deals_for_trading_client` to retrieve raw deal data, then
|
||||||
|
apply downstream transformations in your own adapter layer)
|
||||||
- Application-specific credential schema keys wired into mt5cli internals
|
- Application-specific credential schema keys wired into mt5cli internals
|
||||||
|
|
||||||
mt5cli provides connection lifecycle, normalized data access, SQLite history
|
mt5cli provides connection lifecycle, normalized data access, SQLite history
|
||||||
@@ -191,6 +280,6 @@ their own adapter layer.
|
|||||||
## Contract verification
|
## Contract verification
|
||||||
|
|
||||||
`tests/test_contracts.py` asserts that every name in `STABLE_SDK_EXPORTS` is
|
`tests/test_contracts.py` asserts that every name in `STABLE_SDK_EXPORTS` is
|
||||||
importable from `mt5cli`, documents key closed-bar, rate-view, SQLite loading,
|
importable from `mt5cli`, that all package-root exports are covered by the
|
||||||
account-resolution, and trading-session behaviors, and keeps the contract set
|
stable set, and documents key closed-bar, SQLite loading, account-resolution,
|
||||||
aligned with `__all__`.
|
and trading-session behaviors.
|
||||||
|
|||||||
+10
-8
@@ -12,8 +12,8 @@ application.
|
|||||||
|
|
||||||
`collect_latest_rates_for_accounts_with_retries()` wraps
|
`collect_latest_rates_for_accounts_with_retries()` wraps
|
||||||
`collect_latest_rates_for_accounts()` with bounded exponential backoff. Only
|
`collect_latest_rates_for_accounts()` with bounded exponential backoff. Only
|
||||||
`pdmt5.Mt5TradingError` and `pdmt5.Mt5RuntimeError` are retried; the final
|
`pdmt5.Mt5RuntimeError` is retried; the final failure is re-raised once
|
||||||
failure is re-raised once `retry_count` is exhausted.
|
`retry_count` is exhausted.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from mt5cli import AccountSpec, collect_latest_rates_for_accounts_with_retries
|
from mt5cli import AccountSpec, collect_latest_rates_for_accounts_with_retries
|
||||||
@@ -31,9 +31,10 @@ rates = collect_latest_rates_for_accounts_with_retries(
|
|||||||
### Latest closed rate bars
|
### Latest closed rate bars
|
||||||
|
|
||||||
MetaTrader 5 `start_pos=0` includes the still-forming current bar as the last
|
MetaTrader 5 `start_pos=0` includes the still-forming current bar as the last
|
||||||
row. `fetch_latest_closed_rates()` handles one connected `Mt5CliClient`; use
|
row. `fetch_latest_closed_rates()` handles one connected `MT5Client`; use
|
||||||
`fetch_latest_closed_rates_for_trading_client()` from an active
|
`fetch_latest_closed_rates_for_trading_client()` from an active trading
|
||||||
`Mt5TradingClient` session. Multi-account helpers fetch `count + 1` bars, drop
|
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
|
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
|
oldest-to-newest and may contain fewer than `count` rows only when MT5 returns
|
||||||
fewer closed bars.
|
fewer closed bars.
|
||||||
@@ -117,7 +118,8 @@ call it every iteration without over-fetching.
|
|||||||
```python
|
```python
|
||||||
from pdmt5 import Mt5Config, Mt5DataClient
|
from pdmt5 import Mt5Config, Mt5DataClient
|
||||||
|
|
||||||
from mt5cli import Dataset, ThrottledHistoryUpdater
|
from mt5cli import ThrottledHistoryUpdater
|
||||||
|
from mt5cli.utils import Dataset
|
||||||
|
|
||||||
updater = ThrottledHistoryUpdater(
|
updater = ThrottledHistoryUpdater(
|
||||||
output="history.db",
|
output="history.db",
|
||||||
@@ -170,5 +172,5 @@ resulting `ValueError` is suppressed along with other recoverable errors.
|
|||||||
## Trading-capable sessions
|
## Trading-capable sessions
|
||||||
|
|
||||||
For order placement and trading calculations, use the dedicated
|
For order placement and trading calculations, use the dedicated
|
||||||
[Trading module](trading.md). The read-only `Mt5CliClient` and `mt5_session()`
|
[Trading module](trading.md). Use `mt5_session()` / `MT5Client` for read-only
|
||||||
helpers in this module are unchanged.
|
collection.
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
# Storage
|
|
||||||
|
|
||||||
::: mt5cli.storage
|
|
||||||
@@ -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.
|
||||||
+63
-7
@@ -6,8 +6,14 @@
|
|||||||
|
|
||||||
`create_trading_client()` and `mt5_trading_session()` complement the read-only
|
`create_trading_client()` and `mt5_trading_session()` complement the read-only
|
||||||
`mt5_session()` helper in `sdk.py`. They return or yield an initialized
|
`mt5_session()` helper in `sdk.py`. They return or yield an initialized
|
||||||
`pdmt5.Mt5TradingClient`, use `Mt5Config.path` to launch the terminal when
|
client supporting order execution and account management, use `Mt5Config.path`
|
||||||
configured, and `mt5_trading_session()` always calls `shutdown()` on exit.
|
to launch the terminal when configured, and `mt5_trading_session()` always
|
||||||
|
calls `shutdown()` on exit.
|
||||||
|
|
||||||
|
`create_trading_client()` returns a raw `pdmt5.Mt5DataClient` instance, not the
|
||||||
|
higher-level `MT5Client` wrapper. Use `mt5_session()` / `MT5Client` for
|
||||||
|
read-only data collection; use `mt5_trading_session()` only where order
|
||||||
|
placement or trading calculations are required.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from mt5cli import create_trading_client, mt5_trading_session
|
from mt5cli import create_trading_client, mt5_trading_session
|
||||||
@@ -31,7 +37,7 @@ finally:
|
|||||||
`login` accepts `int`, numeric `str`, or an empty string; empty strings are
|
`login` accepts `int`, numeric `str`, or an empty string; empty strings are
|
||||||
treated as unset. `path`, `password`, `server`, and `timeout` are forwarded to
|
treated as unset. `path`, `password`, `server`, and `timeout` are forwarded to
|
||||||
`pdmt5.Mt5Config`, and omitted `timeout` values keep the lower-level default.
|
`pdmt5.Mt5Config`, and omitted `timeout` values keep the lower-level default.
|
||||||
The read-only `Mt5CliClient` / `mt5_session()` API is unchanged.
|
Use `mt5_session()` / `MT5Client` for read-only data collection.
|
||||||
|
|
||||||
## State and order helpers
|
## State and order helpers
|
||||||
|
|
||||||
@@ -115,19 +121,19 @@ closed = close_open_positions(client, symbols="EURUSD", dry_run=True)
|
|||||||
`detect_position_side()` returns `long` for buy-only exposure, `short` for
|
`detect_position_side()` returns `long` for buy-only exposure, `short` for
|
||||||
sell-only exposure, and `None` for no positions or mixed long/short exposure.
|
sell-only exposure, and `None` for no positions or mixed long/short exposure.
|
||||||
`calculate_spread_ratio()` uses `(ask - bid) / ((ask + bid) / 2)` and raises
|
`calculate_spread_ratio()` uses `(ask - bid) / ((ask + bid) / 2)` and raises
|
||||||
`Mt5TradingError` when bid or ask is missing or non-positive.
|
`Mt5OperationError` when bid or ask is missing or non-positive.
|
||||||
`normalize_order_volume()` returns `0.0` for invalid constraints or
|
`normalize_order_volume()` returns `0.0` for invalid constraints or
|
||||||
sub-minimum requests; check the result before calling `estimate_order_margin()`,
|
sub-minimum requests; check the result before calling `estimate_order_margin()`,
|
||||||
which requires a positive finite volume. `calculate_positions_margin()` silently
|
which requires a positive finite volume. `calculate_positions_margin()` silently
|
||||||
skips rows with missing symbols, non-positive volumes, non-finite volumes, or
|
skips rows with missing symbols, non-positive volumes, non-finite volumes, or
|
||||||
unsupported position types, but propagates `Mt5TradingError` from `estimate_order_margin()` when a valid row
|
unsupported position types, but propagates `Mt5OperationError` from `estimate_order_margin()` when a valid row
|
||||||
encounters invalid tick data or margin results from the broker.
|
encounters invalid tick data or margin results from the broker.
|
||||||
|
|
||||||
SL/TP ratios for `determine_order_limits()` must satisfy `0 <= ratio < 1`; `0`
|
SL/TP ratios for `determine_order_limits()` must satisfy `0 <= ratio < 1`; `0`
|
||||||
omits that level. SL/TP prices are rounded with symbol `digits` metadata when
|
omits that level. SL/TP prices are rounded with symbol `digits` metadata when
|
||||||
available. `determine_order_limits()` pre-validates computed SL/TP prices against
|
available. `determine_order_limits()` pre-validates computed SL/TP prices against
|
||||||
available `trade_stops_level * point` metadata when present; violations raise
|
available `trade_stops_level * point` metadata when present; violations raise
|
||||||
`Mt5TradingError`. This is a planning helper only: it does not guarantee broker
|
`Mt5OperationError`. This is a planning helper only: it does not guarantee broker
|
||||||
acceptance because live validation can still depend on price movement, bid/ask
|
acceptance because live validation can still depend on price movement, bid/ask
|
||||||
side, freeze levels, and server-side rules, and it does not validate
|
side, freeze levels, and server-side rules, and it does not validate
|
||||||
`trade_freeze_level`. When symbol metadata cannot be loaded, protective prices
|
`trade_freeze_level`. When symbol metadata cannot be loaded, protective prices
|
||||||
@@ -181,6 +187,55 @@ updates: list[OrderExecutionResult] = update_sltp_for_open_positions(
|
|||||||
Closes issue #33: strategy-neutral order planning and execution helpers exposed
|
Closes issue #33: strategy-neutral order planning and execution helpers exposed
|
||||||
through the stable package root without embedding entry/exit policy.
|
through the stable package root without embedding entry/exit policy.
|
||||||
|
|
||||||
|
## Retrieving recent history deals
|
||||||
|
|
||||||
|
`fetch_recent_history_deals_for_trading_client()` fetches history deals from an
|
||||||
|
already-connected trading client over a trailing time window. It works directly
|
||||||
|
with the object returned by `create_trading_client()` (a raw
|
||||||
|
`pdmt5.Mt5DataClient`) without requiring any additional wrapping.
|
||||||
|
|
||||||
|
The helper returns a chronologically sorted DataFrame with a `RangeIndex` and
|
||||||
|
all columns from the underlying client (`time`, `symbol`, `type`, `entry`,
|
||||||
|
`volume`, `profit`, `position_id`, etc.). It does **not** apply any
|
||||||
|
strategy-specific transformations — entry/exit classification, Kelly fractions,
|
||||||
|
and betting semantics belong in downstream applications.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mt5cli import (
|
||||||
|
create_trading_client,
|
||||||
|
fetch_recent_history_deals_for_trading_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
client = create_trading_client(login=12345, server="Broker-Demo")
|
||||||
|
try:
|
||||||
|
deals_df = fetch_recent_history_deals_for_trading_client(
|
||||||
|
client,
|
||||||
|
symbol="JP225",
|
||||||
|
hours=24,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
client.shutdown()
|
||||||
|
```
|
||||||
|
|
||||||
|
Or inside a managed session:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mt5cli import fetch_recent_history_deals_for_trading_client, mt5_trading_session
|
||||||
|
|
||||||
|
with mt5_trading_session(login=12345, server="Broker-Demo") as client:
|
||||||
|
deals_df = fetch_recent_history_deals_for_trading_client(
|
||||||
|
client,
|
||||||
|
symbol="JP225",
|
||||||
|
hours=48,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`hours` must be positive; `date_to` defaults to `datetime.now(UTC)`. An empty
|
||||||
|
or `None` result from the underlying client is normalized to an empty DataFrame.
|
||||||
|
|
||||||
|
Downstream packages own all strategy-specific transformations. mt5cli does not
|
||||||
|
provide entry-deal classification, Kelly sizing, or any betting-specific helpers.
|
||||||
|
|
||||||
## Migration from application-local helpers
|
## Migration from application-local helpers
|
||||||
|
|
||||||
| Application-local concern | mt5cli replacement |
|
| Application-local concern | mt5cli replacement |
|
||||||
@@ -191,9 +246,10 @@ through the stable package root without embedding entry/exit policy.
|
|||||||
| Local broker volume step normalization | `normalize_order_volume()` |
|
| Local broker volume step normalization | `normalize_order_volume()` |
|
||||||
| Local order or position margin estimation | `estimate_order_margin()`, `calculate_positions_margin()` |
|
| Local order or position margin estimation | `estimate_order_margin()`, `calculate_positions_margin()` |
|
||||||
| Local closed-bar fetch from a trading session | `fetch_latest_closed_rates_for_trading_client()`, `fetch_latest_closed_rates_indexed()` |
|
| Local closed-bar fetch from a trading session | `fetch_latest_closed_rates_for_trading_client()`, `fetch_latest_closed_rates_indexed()` |
|
||||||
|
| Local recent deal history fetch from a trading session | `fetch_recent_history_deals_for_trading_client()` |
|
||||||
| Local SL/TP price derivation | `determine_order_limits()` |
|
| Local SL/TP price derivation | `determine_order_limits()` |
|
||||||
| Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` |
|
| Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` |
|
||||||
|
|
||||||
Keep read-only data collection on `mt5_session()` / `Mt5CliClient`; use
|
Keep read-only data collection on `mt5_session()` / `MT5Client`; use
|
||||||
`mt5_trading_session()` only where order placement or trading calculations are
|
`mt5_trading_session()` only where order placement or trading calculations are
|
||||||
required.
|
required.
|
||||||
|
|||||||
+56
-39
@@ -27,28 +27,30 @@ mt5cli provides a stable `MT5Client` Python API, standardized dataset schemas, s
|
|||||||
pip install mt5cli
|
pip install mt5cli
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Parquet export is not included by default. To enable it, install the `parquet` extra:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install "mt5cli[parquet]"
|
||||||
|
```
|
||||||
|
|
||||||
## Python API for downstream packages
|
## Python API for downstream packages
|
||||||
|
|
||||||
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives. `Mt5CliClient` remains available as a backward-compatible alias.
|
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from mt5cli import (
|
from mt5cli import (
|
||||||
DataKind,
|
|
||||||
Dataset,
|
|
||||||
MT5Client,
|
MT5Client,
|
||||||
build_config,
|
build_config,
|
||||||
collect_history,
|
collect_history,
|
||||||
export_dataframe,
|
|
||||||
load_rate_data,
|
|
||||||
minimum_margins,
|
|
||||||
mt5_session,
|
mt5_session,
|
||||||
normalize_dataframe,
|
|
||||||
recent_ticks,
|
|
||||||
resolve_rate_view_name,
|
|
||||||
)
|
)
|
||||||
|
from mt5cli.history import load_rate_data, resolve_rate_view_name
|
||||||
|
from mt5cli.schemas import DataKind, normalize_dataframe
|
||||||
|
from mt5cli.sdk import minimum_margins, recent_ticks
|
||||||
|
from mt5cli.utils import Dataset, export_dataframe
|
||||||
|
|
||||||
# Persistent session for multiple calls
|
# Persistent session for multiple calls
|
||||||
with mt5_session(build_config(login=12345, server="Broker-Demo")) as client:
|
with mt5_session(build_config(login=12345, server="Broker-Demo")) as client:
|
||||||
@@ -84,7 +86,7 @@ collect_history(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
Schema contracts live in `mt5cli.schemas` (`DataKind`, `validate_schema`, `normalize_dataframe`). Storage helpers are re-exported from `mt5cli.storage` and the package root.
|
Schema contracts live in `mt5cli.schemas` (`DataKind`, `validate_schema`, `normalize_dataframe`). Export and storage helpers are in `mt5cli.utils` (`Dataset`, `export_dataframe`) and `mt5cli.history`.
|
||||||
|
|
||||||
`MT5Client.order_send()` is a live execution primitive: it can place real trades on the connected account. mt5cli does not implement strategy logic, signal generation, backtesting, or optimization — downstream applications must gate live execution explicitly (the CLI requires `--yes` for `order-send`).
|
`MT5Client.order_send()` is a live execution primitive: it can place real trades on the connected account. mt5cli does not implement strategy logic, signal generation, backtesting, or optimization — downstream applications must gate live execution explicitly (the CLI requires `--yes` for `order-send`).
|
||||||
|
|
||||||
@@ -107,8 +109,10 @@ mt5cli -o ticks.json ticks-from --symbol EURUSD \
|
|||||||
# Export symbols to SQLite3 with custom table name
|
# Export symbols to SQLite3 with custom table name
|
||||||
mt5cli -o data.db --table symbols symbols --group "*USD*"
|
mt5cli -o data.db --table symbols symbols --group "*USD*"
|
||||||
|
|
||||||
# Export with connection credentials
|
# Export with connection credentials from env or placeholders
|
||||||
mt5cli --login 12345 --password mypass --server MyBroker-Demo \
|
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
|
-o positions.csv positions
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -145,26 +149,39 @@ mt5cli --login 12345 --password mypass --server MyBroker-Demo \
|
|||||||
| `minimum-margins` | Export minimum-volume margin summary |
|
| `minimum-margins` | Export minimum-volume margin summary |
|
||||||
| `market-book` | Export market depth (order book) |
|
| `market-book` | Export market depth (order book) |
|
||||||
|
|
||||||
### Trading
|
### Trading State
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ---------------------- | ----------------------------------------------------------- |
|
| ---------------------- | ------------------------------------------------------------------- |
|
||||||
| `orders` | Export active orders |
|
| `orders` | Export active orders |
|
||||||
| `positions` | Export open positions |
|
| `positions` | Export open positions |
|
||||||
| `history-orders` | Export historical orders |
|
| `history-orders` | Export historical orders |
|
||||||
| `history-deals` | Export historical deals |
|
| `history-deals` | Export historical deals |
|
||||||
| `recent-history-deals` | Export historical deals from a trailing window |
|
| `recent-history-deals` | Export historical deals from a trailing window |
|
||||||
| `mt5-summary` | Export terminal/account status summary |
|
| `mt5-summary` | Export terminal/account status summary |
|
||||||
| `order-check` | Check funds sufficiency for a trade request |
|
| `order-check` | Check funds sufficiency for a trade request (read-only, no `--yes`) |
|
||||||
| `order-send` | Send a trade request to the trade server (`--yes` required) |
|
|
||||||
|
|
||||||
Use `order-check` to validate a request payload before running `order-send --yes`.
|
### Execution (live / mutating)
|
||||||
|
|
||||||
|
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; 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
|
||||||
|
requests automatically. `order-send` is the expert raw path — downstream
|
||||||
|
applications should prefer dedicated closing helpers or their own risk controls.
|
||||||
|
|
||||||
### Bulk Collection
|
### Bulk Collection
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `collect-history` | Collect rates, ticks, history-orders, and history-deals for one or more symbols into a single SQLite database (optional cash-event/position views) |
|
| `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
|
```bash
|
||||||
mt5cli -o history.db collect-history \
|
mt5cli -o history.db collect-history \
|
||||||
@@ -176,16 +193,16 @@ mt5cli -o history.db collect-history \
|
|||||||
|
|
||||||
`collect-history` options:
|
`collect-history` options:
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| -------------- | ---------- | --------------------------------------------------------------------------------------------- |
|
| -------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `--symbol/-s` | _required_ | Symbol to collect (repeat for multiple). |
|
| `--symbol/-s` | _required_ | Symbol to collect (repeat for multiple). |
|
||||||
| `--date-from` | _required_ | Start date in ISO 8601. |
|
| `--date-from` | _required_ | Start date in ISO 8601. |
|
||||||
| `--date-to` | _required_ | End date in ISO 8601. |
|
| `--date-to` | _required_ | End date in ISO 8601. |
|
||||||
| `--dataset` | all four | Repeatable: `rates`, `ticks`, `history-orders`, `history-deals`. |
|
| `--dataset` | rates, history-orders, history-deals | Repeatable: `rates`, `ticks`, `history-orders`, `history-deals`. Ticks are opt-in: pass `--dataset ticks` to include them. |
|
||||||
| `--timeframe` | `M1` | Rates timeframe; recorded in a `timeframe` column on the `rates` table. |
|
| `--timeframe` | `M1` | Rates timeframe; recorded in a `timeframe` column on the `rates` table. |
|
||||||
| `--flags` | `ALL` | Tick copy flags forwarded to `copy_ticks_range`. |
|
| `--flags` | `ALL` | Tick copy flags forwarded to `copy_ticks_range`. |
|
||||||
| `--if-exists` | `fail` | `append`, `replace`, or `fail` when a target table already exists. |
|
| `--if-exists` | `fail` | `append`, `replace`, or `fail` when a target table already exists. |
|
||||||
| `--with-views` | off | Add `cash_events` and `positions_reconstructed` views (requires the `history-deals` dataset). |
|
| `--with-views` | off | Add `cash_events` and `positions_reconstructed` views (requires the `history-deals` dataset). |
|
||||||
|
|
||||||
History orders and deals are fetched per symbol and concatenated, so the symbol filter is applied consistently across all datasets. The `cash_events` view is derived from symbol-filtered `history_deals`, so account-level cash events with empty or non-matching symbols may be excluded. The `positions_reconstructed` view excludes positions with no closing deal, uses volume-weighted open/close prices, and reports reversal deals (`DEAL_ENTRY_INOUT`) via `volume_reversal` / `reversal_count`.
|
History orders and deals are fetched per symbol and concatenated, so the symbol filter is applied consistently across all datasets. The `cash_events` view is derived from symbol-filtered `history_deals`, so account-level cash events with empty or non-matching symbols may be excluded. The `positions_reconstructed` view excludes positions with no closing deal, uses volume-weighted open/close prices, and reports reversal deals (`DEAL_ENTRY_INOUT`) via `volume_reversal` / `reversal_count`.
|
||||||
|
|
||||||
@@ -199,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) |
|
| `-f, --format` | Output format (auto-detected from extension if omitted) |
|
||||||
| `--table` | Table name for SQLite3 output (default: "data") |
|
| `--table` | Table name for SQLite3 output (default: "data") |
|
||||||
| `--login` | Trading account login |
|
| `--login` | Trading account login |
|
||||||
| `--password` | Trading account password |
|
| `--password` | Trading account password (`MT5_PASSWORD`) |
|
||||||
| `--server` | Trading server name |
|
| `--server` | Trading server name |
|
||||||
| `--path` | Path to MetaTrader5 terminal EXE file |
|
| `--path` | Path to MetaTrader5 terminal EXE file |
|
||||||
| `--timeout` | Connection timeout in milliseconds |
|
| `--timeout` | Connection timeout in milliseconds |
|
||||||
@@ -215,7 +232,7 @@ See the [History schema diagram](api/history.md#entity-relationship-diagram) for
|
|||||||
|
|
||||||
Browse the API documentation for detailed module information:
|
Browse the API documentation for detailed module information:
|
||||||
|
|
||||||
- [CLI Module](api/cli.md) - CLI application with export commands
|
- [CLI Module](api/cli.md) - CLI application with data export and execution commands
|
||||||
- [SDK Module](api/sdk.md) - Programmatic read-only data collection API
|
- [SDK Module](api/sdk.md) - Programmatic read-only data collection API
|
||||||
- [Utils Module](api/utils.md) - Constants, parameter types, parsers, and export utilities
|
- [Utils Module](api/utils.md) - Constants, parameter types, parsers, and export utilities
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Grafana Integration for mt5cli
|
||||||
|
|
||||||
|
This directory contains example configuration and dashboard files for visualising
|
||||||
|
mt5cli SQLite data in [Grafana](https://grafana.com/) using the
|
||||||
|
[Grafana SQLite datasource plugin](https://grafana.com/grafana/plugins/frser-sqlite-datasource/).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- mt5cli installed and able to connect to MetaTrader 5
|
||||||
|
- Grafana 10+ with the `frser-sqlite-datasource` plugin installed
|
||||||
|
- (Optional) Docker and Docker Compose for the containerised setup
|
||||||
|
|
||||||
|
## Generating the SQLite database
|
||||||
|
|
||||||
|
Collect historical data and snapshot current account state:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Collect OHLCV history
|
||||||
|
mt5cli -o history.db collect-history --symbol EURUSD --date-from 2024-01-01 --date-to 2024-12-31
|
||||||
|
|
||||||
|
# Create Grafana-ready views and indexes
|
||||||
|
mt5cli -o history.db grafana-schema
|
||||||
|
|
||||||
|
# Snapshot current account, positions, and orders
|
||||||
|
mt5cli -o history.db snapshot --with-grafana-schema
|
||||||
|
```
|
||||||
|
|
||||||
|
## Publishing a Grafana-readable copy
|
||||||
|
|
||||||
|
Grafana reads the SQLite file directly. To avoid read/write conflicts, publish
|
||||||
|
a consistent copy after each update:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mt5cli -o history.db grafana-schema --publish-copy history.mt5cli.db
|
||||||
|
mt5cli -o history.db snapshot --publish-copy history.mt5cli.db
|
||||||
|
```
|
||||||
|
|
||||||
|
The `--publish-copy` option uses the SQLite online backup API, which is safe
|
||||||
|
even when the source database uses WAL journal mode.
|
||||||
|
|
||||||
|
## Configuring the datasource path
|
||||||
|
|
||||||
|
Edit `provisioning/datasources/mt5cli-sqlite.yml` and set the `path` field
|
||||||
|
to the absolute path of your published `.db` file:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jsonData:
|
||||||
|
path: /absolute/path/to/history.mt5cli.db
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Grafana on Windows (native)
|
||||||
|
|
||||||
|
1. Download and install Grafana from <https://grafana.com/grafana/download/>.
|
||||||
|
2. Install the SQLite plugin: `grafana-cli plugins install frser-sqlite-datasource`.
|
||||||
|
3. Copy `provisioning/datasources/mt5cli-sqlite.yml` into
|
||||||
|
`%ProgramFiles%\GrafanaLabs\grafana\conf\provisioning\datasources\`.
|
||||||
|
Do not copy `provisioning/dashboards/mt5cli.yml` — it contains a
|
||||||
|
Docker-specific dashboard path that is not valid on Windows.
|
||||||
|
4. Import the dashboards from `dashboards/` via the Grafana UI
|
||||||
|
(Dashboards → Import → Upload JSON file).
|
||||||
|
|
||||||
|
## Running with Docker Compose
|
||||||
|
|
||||||
|
Set `MT5CLI_DB_PATH` to the absolute path of your published `.db` file, then
|
||||||
|
start the stack:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# From the examples/grafana directory
|
||||||
|
MT5CLI_DB_PATH=/absolute/path/to/history.mt5cli.db docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, create a `.env` file in `examples/grafana/` containing
|
||||||
|
`MT5CLI_DB_PATH=/absolute/path/to/history.mt5cli.db` and run
|
||||||
|
`docker compose up -d`. Compose refuses to start if the variable is unset or
|
||||||
|
empty.
|
||||||
|
|
||||||
|
Then open <http://localhost:3000> (default credentials: admin / admin).
|
||||||
|
|
||||||
|
## Dashboard overview
|
||||||
|
|
||||||
|
| Dashboard | Description |
|
||||||
|
| ---------------------- | ------------------------------------------------------- |
|
||||||
|
| `mt5cli-overview.json` | Account balance, equity, margin, and snapshot freshness |
|
||||||
|
| `mt5cli-trades.json` | Trade P/L, win rate, symbol breakdown |
|
||||||
|
| `mt5cli-market.json` | OHLCV rates, spreads, and tick volume |
|
||||||
|
|
||||||
|
All panel queries use the `grafana_*` views; they do not read internal storage
|
||||||
|
tables directly.
|
||||||
|
|
||||||
|
## Importing dashboards
|
||||||
|
|
||||||
|
1. Open Grafana and navigate to **Dashboards → Import**.
|
||||||
|
2. Click **Upload JSON file** and select one of the files in `dashboards/`.
|
||||||
|
3. Select the `mt5cli-SQLite` datasource when prompted.
|
||||||
|
4. Click **Import**.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Docker Compose for Grafana with mt5cli SQLite datasource.
|
||||||
|
#
|
||||||
|
# MT5CLI_DB_PATH must be set to the absolute host path of your published .db
|
||||||
|
# file before running `docker compose up -d`. Compose will refuse to start if
|
||||||
|
# the variable is missing or empty.
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# MT5CLI_DB_PATH=/home/user/history.mt5cli.db docker compose up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:latest
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
GF_PATHS_PROVISIONING: /etc/grafana/provisioning
|
||||||
|
GF_INSTALL_PLUGINS: frser-sqlite-datasource
|
||||||
|
volumes:
|
||||||
|
- ./provisioning:/etc/grafana/provisioning:ro
|
||||||
|
- ./dashboards:/var/lib/grafana/dashboards:ro
|
||||||
|
- grafana-storage:/var/lib/grafana
|
||||||
|
- ${MT5CLI_DB_PATH:?Set MT5CLI_DB_PATH to the path of your published mt5cli SQLite DB}:/data/mt5cli.db:ro
|
||||||
|
user: "472"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
grafana-storage:
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"__inputs": [
|
||||||
|
{
|
||||||
|
"name": "DS_MT5CLI_SQLITE",
|
||||||
|
"label": "mt5cli-SQLite",
|
||||||
|
"description": "",
|
||||||
|
"type": "datasource",
|
||||||
|
"pluginId": "frser-sqlite-datasource",
|
||||||
|
"pluginName": "SQLite"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"__requires": [
|
||||||
|
{
|
||||||
|
"type": "datasource",
|
||||||
|
"id": "frser-sqlite-datasource",
|
||||||
|
"name": "SQLite",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"annotations": { "list": [] },
|
||||||
|
"editable": true,
|
||||||
|
"fiscalYearStartMonth": 0,
|
||||||
|
"graphTooltip": 0,
|
||||||
|
"id": null,
|
||||||
|
"links": [],
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"datasource": "${DS_MT5CLI_SQLITE}",
|
||||||
|
"fieldConfig": { "defaults": {}, "overrides": [] },
|
||||||
|
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 0 },
|
||||||
|
"id": 1,
|
||||||
|
"title": "Close Price Over Time",
|
||||||
|
"type": "timeseries",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"rawSql": "SELECT \"time\" AS time, \"symbol\", \"close\" FROM grafana_rates WHERE \"time\" >= $__from / 1000 AND \"time\" < $__to / 1000 ORDER BY time",
|
||||||
|
"format": "time_series",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": "${DS_MT5CLI_SQLITE}",
|
||||||
|
"fieldConfig": { "defaults": {}, "overrides": [] },
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
|
||||||
|
"id": 2,
|
||||||
|
"title": "Spread Over Time",
|
||||||
|
"type": "timeseries",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"rawSql": "SELECT \"time\" AS time, \"symbol\", \"spread\" FROM grafana_rates WHERE \"time\" >= $__from / 1000 AND \"time\" < $__to / 1000 ORDER BY time",
|
||||||
|
"format": "time_series",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": "${DS_MT5CLI_SQLITE}",
|
||||||
|
"fieldConfig": { "defaults": {}, "overrides": [] },
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
|
||||||
|
"id": 3,
|
||||||
|
"title": "Tick Volume Over Time",
|
||||||
|
"type": "timeseries",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"rawSql": "SELECT \"time\" AS time, \"symbol\", \"tick_volume\" FROM grafana_rates WHERE \"time\" >= $__from / 1000 AND \"time\" < $__to / 1000 ORDER BY time",
|
||||||
|
"format": "time_series",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"refresh": "1m",
|
||||||
|
"schemaVersion": 36,
|
||||||
|
"tags": ["mt5cli", "market"],
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"current": {},
|
||||||
|
"hide": 0,
|
||||||
|
"includeAll": false,
|
||||||
|
"label": "Data Source",
|
||||||
|
"multi": false,
|
||||||
|
"name": "DS_MT5CLI_SQLITE",
|
||||||
|
"options": [],
|
||||||
|
"query": "frser-sqlite-datasource",
|
||||||
|
"refresh": 1,
|
||||||
|
"type": "datasource"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"time": { "from": "now-24h", "to": "now" },
|
||||||
|
"timepicker": {},
|
||||||
|
"timezone": "browser",
|
||||||
|
"title": "MT5CLI - Market Data",
|
||||||
|
"uid": "mt5cli-market",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
{
|
||||||
|
"__inputs": [
|
||||||
|
{
|
||||||
|
"name": "DS_MT5CLI_SQLITE",
|
||||||
|
"label": "mt5cli-SQLite",
|
||||||
|
"description": "",
|
||||||
|
"type": "datasource",
|
||||||
|
"pluginId": "frser-sqlite-datasource",
|
||||||
|
"pluginName": "SQLite"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"__requires": [
|
||||||
|
{
|
||||||
|
"type": "datasource",
|
||||||
|
"id": "frser-sqlite-datasource",
|
||||||
|
"name": "SQLite",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"annotations": {
|
||||||
|
"list": []
|
||||||
|
},
|
||||||
|
"editable": true,
|
||||||
|
"fiscalYearStartMonth": 0,
|
||||||
|
"graphTooltip": 0,
|
||||||
|
"id": null,
|
||||||
|
"links": [],
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"datasource": "${DS_MT5CLI_SQLITE}",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "currencyUSD"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 4,
|
||||||
|
"w": 6,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 1,
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": ["lastNotNull"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "Balance",
|
||||||
|
"type": "stat",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"rawSql": "SELECT \"time\" AS time, \"balance\" FROM grafana_account_snapshots ORDER BY time DESC LIMIT 1",
|
||||||
|
"format": "table",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": "${DS_MT5CLI_SQLITE}",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "currencyUSD"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 4,
|
||||||
|
"w": 6,
|
||||||
|
"x": 6,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 2,
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": ["lastNotNull"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "Equity",
|
||||||
|
"type": "stat",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"rawSql": "SELECT \"time\" AS time, \"equity\" FROM grafana_account_snapshots ORDER BY time DESC LIMIT 1",
|
||||||
|
"format": "table",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": "${DS_MT5CLI_SQLITE}",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "currencyUSD"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 4,
|
||||||
|
"w": 6,
|
||||||
|
"x": 12,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 3,
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": ["lastNotNull"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "Free Margin",
|
||||||
|
"type": "stat",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"rawSql": "SELECT \"time\" AS time, \"margin_free\" FROM grafana_account_snapshots ORDER BY time DESC LIMIT 1",
|
||||||
|
"format": "table",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": "${DS_MT5CLI_SQLITE}",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "percent"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 4,
|
||||||
|
"w": 6,
|
||||||
|
"x": 18,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 4,
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": ["lastNotNull"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "Margin Level",
|
||||||
|
"type": "stat",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"rawSql": "SELECT \"time\" AS time, \"margin_level\" FROM grafana_account_snapshots ORDER BY time DESC LIMIT 1",
|
||||||
|
"format": "table",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": "${DS_MT5CLI_SQLITE}",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "dateTimeFromNow"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 4,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 4
|
||||||
|
},
|
||||||
|
"id": 7,
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": ["lastNotNull"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "Last Snapshot",
|
||||||
|
"type": "stat",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"rawSql": "SELECT MAX(\"time\") * 1000 AS \"Last Snapshot\" FROM grafana_account_snapshots",
|
||||||
|
"format": "table",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": "${DS_MT5CLI_SQLITE}",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 8
|
||||||
|
},
|
||||||
|
"id": 5,
|
||||||
|
"title": "Account Balance Over Time",
|
||||||
|
"type": "timeseries",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"rawSql": "SELECT \"time\" AS time, \"balance\" FROM grafana_account_snapshots ORDER BY time",
|
||||||
|
"format": "time_series",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": "${DS_MT5CLI_SQLITE}",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 16
|
||||||
|
},
|
||||||
|
"id": 6,
|
||||||
|
"title": "Equity Over Time",
|
||||||
|
"type": "timeseries",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"rawSql": "SELECT \"time\" AS time, \"equity\" FROM grafana_account_snapshots ORDER BY time",
|
||||||
|
"format": "time_series",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"refresh": "1m",
|
||||||
|
"schemaVersion": 36,
|
||||||
|
"tags": ["mt5cli", "account"],
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"current": {},
|
||||||
|
"hide": 0,
|
||||||
|
"includeAll": false,
|
||||||
|
"label": "Data Source",
|
||||||
|
"multi": false,
|
||||||
|
"name": "DS_MT5CLI_SQLITE",
|
||||||
|
"options": [],
|
||||||
|
"query": "frser-sqlite-datasource",
|
||||||
|
"refresh": 1,
|
||||||
|
"type": "datasource"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"from": "now-7d",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"timepicker": {},
|
||||||
|
"timezone": "browser",
|
||||||
|
"title": "MT5CLI - Account Overview",
|
||||||
|
"uid": "mt5cli-overview",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
{
|
||||||
|
"__inputs": [
|
||||||
|
{
|
||||||
|
"name": "DS_MT5CLI_SQLITE",
|
||||||
|
"label": "mt5cli-SQLite",
|
||||||
|
"description": "",
|
||||||
|
"type": "datasource",
|
||||||
|
"pluginId": "frser-sqlite-datasource",
|
||||||
|
"pluginName": "SQLite"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"__requires": [
|
||||||
|
{
|
||||||
|
"type": "datasource",
|
||||||
|
"id": "frser-sqlite-datasource",
|
||||||
|
"name": "SQLite",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"annotations": {
|
||||||
|
"list": []
|
||||||
|
},
|
||||||
|
"editable": true,
|
||||||
|
"fiscalYearStartMonth": 0,
|
||||||
|
"graphTooltip": 0,
|
||||||
|
"id": null,
|
||||||
|
"links": [],
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"datasource": "${DS_MT5CLI_SQLITE}",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 1,
|
||||||
|
"title": "Realized P/L by Symbol",
|
||||||
|
"type": "table",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"rawSql": "SELECT \"symbol\", \"cumulative_pnl\", \"deal_count\" FROM grafana_realized_pnl ORDER BY cumulative_pnl DESC",
|
||||||
|
"format": "table",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": "${DS_MT5CLI_SQLITE}",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byName",
|
||||||
|
"options": "win_rate_pct"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "unit",
|
||||||
|
"value": "percent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "displayName",
|
||||||
|
"value": "Win Rate (%)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 2,
|
||||||
|
"title": "Trade Statistics by Symbol",
|
||||||
|
"type": "table",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"rawSql": "SELECT \"symbol\", \"total_deals\", \"winning_deals\", \"losing_deals\", \"total_profit\", \"avg_profit\", 100.0 * \"winning_deals\" / NULLIF(\"total_deals\", 0) AS \"win_rate_pct\" FROM grafana_trade_stats ORDER BY total_profit DESC",
|
||||||
|
"format": "table",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": "${DS_MT5CLI_SQLITE}",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 8
|
||||||
|
},
|
||||||
|
"id": 3,
|
||||||
|
"title": "Open Position Profit Over Time",
|
||||||
|
"type": "timeseries",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"rawSql": "SELECT \"time\" AS time, \"symbol\", SUM(\"profit\") AS profit FROM grafana_position_snapshots GROUP BY time, symbol ORDER BY time",
|
||||||
|
"format": "time_series",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": "${DS_MT5CLI_SQLITE}",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 16
|
||||||
|
},
|
||||||
|
"id": 4,
|
||||||
|
"title": "Cash Events Over Time",
|
||||||
|
"type": "timeseries",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"rawSql": "SELECT \"time\" AS time, \"profit\" FROM grafana_cash_events ORDER BY time",
|
||||||
|
"format": "time_series",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"refresh": "5m",
|
||||||
|
"schemaVersion": 36,
|
||||||
|
"tags": ["mt5cli", "trades"],
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"current": {},
|
||||||
|
"hide": 0,
|
||||||
|
"includeAll": false,
|
||||||
|
"label": "Data Source",
|
||||||
|
"multi": false,
|
||||||
|
"name": "DS_MT5CLI_SQLITE",
|
||||||
|
"options": [],
|
||||||
|
"query": "frser-sqlite-datasource",
|
||||||
|
"refresh": 1,
|
||||||
|
"type": "datasource"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"from": "now-30d",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"timepicker": {},
|
||||||
|
"timezone": "browser",
|
||||||
|
"title": "MT5CLI - Trade Analytics",
|
||||||
|
"uid": "mt5cli-trades",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Grafana dashboard provisioning for mt5cli dashboards.
|
||||||
|
|
||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- name: mt5cli
|
||||||
|
type: file
|
||||||
|
disableDeletion: false
|
||||||
|
updateIntervalSeconds: 30
|
||||||
|
allowUiUpdates: true
|
||||||
|
options:
|
||||||
|
path: /var/lib/grafana/dashboards
|
||||||
|
foldersFromFilesStructure: false
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Grafana datasource provisioning for mt5cli SQLite.
|
||||||
|
#
|
||||||
|
# Requires the frser-sqlite-datasource plugin:
|
||||||
|
# grafana-cli plugins install frser-sqlite-datasource
|
||||||
|
#
|
||||||
|
# Set `path` to the absolute path of your published history.mt5cli.db file.
|
||||||
|
|
||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: mt5cli-SQLite
|
||||||
|
type: frser-sqlite-datasource
|
||||||
|
access: proxy
|
||||||
|
isDefault: true
|
||||||
|
jsonData:
|
||||||
|
path: /data/mt5cli.db
|
||||||
+2
-1
@@ -59,13 +59,14 @@ nav:
|
|||||||
- Public API Contract: api/public-contract.md
|
- Public API Contract: api/public-contract.md
|
||||||
- Client: api/client.md
|
- Client: api/client.md
|
||||||
- Schemas: api/schemas.md
|
- Schemas: api/schemas.md
|
||||||
- Storage: api/storage.md
|
|
||||||
- Converters: api/converters.md
|
- Converters: api/converters.md
|
||||||
- Exceptions: api/exceptions.md
|
- Exceptions: api/exceptions.md
|
||||||
- CLI: api/cli.md
|
- CLI: api/cli.md
|
||||||
- SDK: api/sdk.md
|
- SDK: api/sdk.md
|
||||||
- Trading: api/trading.md
|
- Trading: api/trading.md
|
||||||
- History Collection (SQLite): api/history.md
|
- History Collection (SQLite): api/history.md
|
||||||
|
- Telemetry: api/telemetry.md
|
||||||
|
- Grafana: api/grafana.md
|
||||||
- Utils: api/utils.md
|
- Utils: api/utils.md
|
||||||
|
|
||||||
markdown_extensions:
|
markdown_extensions:
|
||||||
|
|||||||
+24
-148
@@ -8,106 +8,38 @@ strategy responsibilities.
|
|||||||
|
|
||||||
from importlib.metadata import version
|
from importlib.metadata import version
|
||||||
|
|
||||||
from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
|
|
||||||
|
|
||||||
from .client import MT5Client, build_config, mt5_session
|
from .client import MT5Client, build_config, mt5_session
|
||||||
from .contract import STABLE_SDK_EXPORTS
|
from .contract import STABLE_SDK_EXPORTS
|
||||||
from .converters import (
|
|
||||||
ensure_utc,
|
|
||||||
granularity_name,
|
|
||||||
normalize_symbol,
|
|
||||||
normalize_symbols,
|
|
||||||
parse_date_range,
|
|
||||||
recent_window,
|
|
||||||
)
|
|
||||||
from .exceptions import (
|
from .exceptions import (
|
||||||
Mt5CliError,
|
Mt5CliError,
|
||||||
Mt5ConnectionError,
|
Mt5ConnectionError,
|
||||||
Mt5OperationError,
|
Mt5OperationError,
|
||||||
Mt5SchemaError,
|
Mt5SchemaError,
|
||||||
call_with_normalized_errors,
|
|
||||||
is_recoverable_mt5_error,
|
|
||||||
normalize_mt5_exception,
|
|
||||||
)
|
)
|
||||||
from .history import (
|
from .history import (
|
||||||
RateTarget,
|
RateTarget,
|
||||||
build_rate_targets,
|
build_rate_targets,
|
||||||
build_rate_view_name,
|
|
||||||
drop_forming_rate_bar,
|
drop_forming_rate_bar,
|
||||||
load_rate_data,
|
|
||||||
load_rate_data_from_connection,
|
|
||||||
load_rate_series_by_granularity,
|
load_rate_series_by_granularity,
|
||||||
load_rate_series_from_sqlite,
|
load_rate_series_from_sqlite,
|
||||||
resolve_history_datasets,
|
report_rate_gaps,
|
||||||
resolve_history_tick_flags,
|
|
||||||
resolve_history_timeframes,
|
|
||||||
resolve_rate_table_name,
|
|
||||||
resolve_rate_tables,
|
|
||||||
resolve_rate_view_name,
|
|
||||||
resolve_rate_view_names,
|
|
||||||
)
|
|
||||||
from .schemas import (
|
|
||||||
DEDUP_KEYS,
|
|
||||||
KNOWN_MT5_TIME_COLUMNS,
|
|
||||||
REQUIRED_COLUMNS,
|
|
||||||
TIME_COLUMNS,
|
|
||||||
DataKind,
|
|
||||||
normalize_dataframe,
|
|
||||||
normalize_time_columns,
|
|
||||||
schema_columns,
|
|
||||||
validate_schema,
|
|
||||||
)
|
)
|
||||||
from .sdk import (
|
from .sdk import (
|
||||||
AccountSpec,
|
AccountSpec,
|
||||||
Mt5CliClient,
|
|
||||||
ThrottledHistoryUpdater,
|
ThrottledHistoryUpdater,
|
||||||
account_info,
|
|
||||||
collect_history,
|
collect_history,
|
||||||
collect_latest_closed_rates_by_granularity,
|
collect_latest_closed_rates_by_granularity,
|
||||||
collect_latest_closed_rates_for_accounts,
|
collect_latest_closed_rates_for_accounts,
|
||||||
collect_latest_rates,
|
|
||||||
collect_latest_rates_for_accounts,
|
|
||||||
collect_latest_rates_for_accounts_with_retries,
|
collect_latest_rates_for_accounts_with_retries,
|
||||||
copy_rates_from,
|
|
||||||
copy_rates_from_pos,
|
|
||||||
copy_rates_range,
|
|
||||||
copy_ticks_from,
|
|
||||||
copy_ticks_range,
|
|
||||||
fetch_latest_closed_rates,
|
fetch_latest_closed_rates,
|
||||||
history_deals,
|
|
||||||
history_orders,
|
|
||||||
last_error,
|
|
||||||
latest_rates,
|
|
||||||
market_book,
|
|
||||||
minimum_margins,
|
|
||||||
mt5_summary,
|
|
||||||
mt5_summary_as_df,
|
|
||||||
orders,
|
|
||||||
positions,
|
|
||||||
recent_history_deals,
|
|
||||||
recent_ticks,
|
|
||||||
resolve_account_spec,
|
resolve_account_spec,
|
||||||
resolve_account_specs,
|
resolve_account_specs,
|
||||||
substitute_env_placeholders,
|
|
||||||
symbol_info,
|
|
||||||
symbol_info_tick,
|
|
||||||
symbols,
|
|
||||||
terminal_info,
|
|
||||||
update_history,
|
update_history,
|
||||||
update_history_with_config,
|
update_history_with_config,
|
||||||
)
|
update_observability,
|
||||||
from .sdk import (
|
update_observability_with_config,
|
||||||
version as mt5_version,
|
|
||||||
)
|
|
||||||
from .storage import (
|
|
||||||
Dataset,
|
|
||||||
IfExists,
|
|
||||||
detect_format,
|
|
||||||
export_dataframe,
|
|
||||||
export_dataframe_to_sqlite,
|
|
||||||
)
|
)
|
||||||
from .trading import (
|
from .trading import (
|
||||||
POSITION_COLUMNS,
|
|
||||||
ExecutionStatus,
|
ExecutionStatus,
|
||||||
MarginVolume,
|
MarginVolume,
|
||||||
OrderExecutionResult,
|
OrderExecutionResult,
|
||||||
@@ -116,12 +48,17 @@ from .trading import (
|
|||||||
OrderSide,
|
OrderSide,
|
||||||
OrderTimeMode,
|
OrderTimeMode,
|
||||||
PositionSide,
|
PositionSide,
|
||||||
|
ProjectionMode,
|
||||||
|
calculate_account_projected_margin_ratio,
|
||||||
calculate_margin_and_volume,
|
calculate_margin_and_volume,
|
||||||
calculate_new_position_margin_ratio,
|
calculate_new_position_margin_ratio,
|
||||||
calculate_positions_margin,
|
calculate_positions_margin,
|
||||||
calculate_positions_margin_by_symbol,
|
calculate_positions_margin_by_symbol,
|
||||||
calculate_positions_margin_safe,
|
calculate_positions_margin_safe,
|
||||||
|
calculate_projected_margin_ratio,
|
||||||
calculate_spread_ratio,
|
calculate_spread_ratio,
|
||||||
|
calculate_symbol_group_margin_ratio,
|
||||||
|
calculate_trailing_stop_updates,
|
||||||
calculate_volume_by_margin,
|
calculate_volume_by_margin,
|
||||||
close_open_positions,
|
close_open_positions,
|
||||||
create_trading_client,
|
create_trading_client,
|
||||||
@@ -129,8 +66,10 @@ from .trading import (
|
|||||||
determine_order_limits,
|
determine_order_limits,
|
||||||
ensure_symbol_selected,
|
ensure_symbol_selected,
|
||||||
estimate_order_margin,
|
estimate_order_margin,
|
||||||
|
extract_tick_price,
|
||||||
fetch_latest_closed_rates_for_trading_client,
|
fetch_latest_closed_rates_for_trading_client,
|
||||||
fetch_latest_closed_rates_indexed,
|
fetch_latest_closed_rates_indexed,
|
||||||
|
fetch_recent_history_deals_for_trading_client,
|
||||||
get_account_snapshot,
|
get_account_snapshot,
|
||||||
get_positions_frame,
|
get_positions_frame,
|
||||||
get_symbol_snapshot,
|
get_symbol_snapshot,
|
||||||
@@ -138,142 +77,79 @@ from .trading import (
|
|||||||
mt5_trading_session,
|
mt5_trading_session,
|
||||||
normalize_order_volume,
|
normalize_order_volume,
|
||||||
place_market_order,
|
place_market_order,
|
||||||
|
resolve_broker_filling_mode,
|
||||||
update_sltp_for_open_positions,
|
update_sltp_for_open_positions,
|
||||||
)
|
update_trailing_stop_loss_for_open_positions,
|
||||||
from .utils import (
|
|
||||||
TICK_FLAG_MAP,
|
|
||||||
TIMEFRAME_MAP,
|
|
||||||
parse_datetime,
|
|
||||||
parse_tick_flags,
|
|
||||||
parse_timeframe,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
__version__ = version(__package__) if __package__ else None
|
__version__ = version(__package__) if __package__ else None
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DEDUP_KEYS",
|
|
||||||
"KNOWN_MT5_TIME_COLUMNS",
|
|
||||||
"POSITION_COLUMNS",
|
|
||||||
"REQUIRED_COLUMNS",
|
|
||||||
"STABLE_SDK_EXPORTS",
|
"STABLE_SDK_EXPORTS",
|
||||||
"TICK_FLAG_MAP",
|
|
||||||
"TIMEFRAME_MAP",
|
|
||||||
"TIME_COLUMNS",
|
|
||||||
"AccountSpec",
|
"AccountSpec",
|
||||||
"DataKind",
|
|
||||||
"Dataset",
|
|
||||||
"ExecutionStatus",
|
"ExecutionStatus",
|
||||||
"IfExists",
|
|
||||||
"MT5Client",
|
"MT5Client",
|
||||||
"MarginVolume",
|
"MarginVolume",
|
||||||
"Mt5CliClient",
|
|
||||||
"Mt5CliError",
|
"Mt5CliError",
|
||||||
"Mt5Config",
|
|
||||||
"Mt5ConnectionError",
|
"Mt5ConnectionError",
|
||||||
"Mt5OperationError",
|
"Mt5OperationError",
|
||||||
"Mt5RuntimeError",
|
|
||||||
"Mt5SchemaError",
|
"Mt5SchemaError",
|
||||||
"Mt5TradingClient",
|
|
||||||
"Mt5TradingError",
|
|
||||||
"OrderExecutionResult",
|
"OrderExecutionResult",
|
||||||
"OrderFillingMode",
|
"OrderFillingMode",
|
||||||
"OrderLimits",
|
"OrderLimits",
|
||||||
"OrderSide",
|
"OrderSide",
|
||||||
"OrderTimeMode",
|
"OrderTimeMode",
|
||||||
"PositionSide",
|
"PositionSide",
|
||||||
|
"ProjectionMode",
|
||||||
"RateTarget",
|
"RateTarget",
|
||||||
"ThrottledHistoryUpdater",
|
"ThrottledHistoryUpdater",
|
||||||
"account_info",
|
|
||||||
"build_config",
|
"build_config",
|
||||||
"build_rate_targets",
|
"build_rate_targets",
|
||||||
"build_rate_view_name",
|
"calculate_account_projected_margin_ratio",
|
||||||
"calculate_margin_and_volume",
|
"calculate_margin_and_volume",
|
||||||
"calculate_new_position_margin_ratio",
|
"calculate_new_position_margin_ratio",
|
||||||
"calculate_positions_margin",
|
"calculate_positions_margin",
|
||||||
"calculate_positions_margin_by_symbol",
|
"calculate_positions_margin_by_symbol",
|
||||||
"calculate_positions_margin_safe",
|
"calculate_positions_margin_safe",
|
||||||
|
"calculate_projected_margin_ratio",
|
||||||
"calculate_spread_ratio",
|
"calculate_spread_ratio",
|
||||||
|
"calculate_symbol_group_margin_ratio",
|
||||||
|
"calculate_trailing_stop_updates",
|
||||||
"calculate_volume_by_margin",
|
"calculate_volume_by_margin",
|
||||||
"call_with_normalized_errors",
|
|
||||||
"close_open_positions",
|
"close_open_positions",
|
||||||
"collect_history",
|
"collect_history",
|
||||||
"collect_latest_closed_rates_by_granularity",
|
"collect_latest_closed_rates_by_granularity",
|
||||||
"collect_latest_closed_rates_for_accounts",
|
"collect_latest_closed_rates_for_accounts",
|
||||||
"collect_latest_rates",
|
|
||||||
"collect_latest_rates_for_accounts",
|
|
||||||
"collect_latest_rates_for_accounts_with_retries",
|
"collect_latest_rates_for_accounts_with_retries",
|
||||||
"copy_rates_from",
|
|
||||||
"copy_rates_from_pos",
|
|
||||||
"copy_rates_range",
|
|
||||||
"copy_ticks_from",
|
|
||||||
"copy_ticks_range",
|
|
||||||
"create_trading_client",
|
"create_trading_client",
|
||||||
"detect_format",
|
|
||||||
"detect_position_side",
|
"detect_position_side",
|
||||||
"determine_order_limits",
|
"determine_order_limits",
|
||||||
"drop_forming_rate_bar",
|
"drop_forming_rate_bar",
|
||||||
"ensure_symbol_selected",
|
"ensure_symbol_selected",
|
||||||
"ensure_utc",
|
|
||||||
"estimate_order_margin",
|
"estimate_order_margin",
|
||||||
"export_dataframe",
|
"extract_tick_price",
|
||||||
"export_dataframe_to_sqlite",
|
|
||||||
"fetch_latest_closed_rates",
|
"fetch_latest_closed_rates",
|
||||||
"fetch_latest_closed_rates_for_trading_client",
|
"fetch_latest_closed_rates_for_trading_client",
|
||||||
"fetch_latest_closed_rates_indexed",
|
"fetch_latest_closed_rates_indexed",
|
||||||
|
"fetch_recent_history_deals_for_trading_client",
|
||||||
"get_account_snapshot",
|
"get_account_snapshot",
|
||||||
"get_positions_frame",
|
"get_positions_frame",
|
||||||
"get_symbol_snapshot",
|
"get_symbol_snapshot",
|
||||||
"get_tick_snapshot",
|
"get_tick_snapshot",
|
||||||
"granularity_name",
|
|
||||||
"history_deals",
|
|
||||||
"history_orders",
|
|
||||||
"is_recoverable_mt5_error",
|
|
||||||
"last_error",
|
|
||||||
"latest_rates",
|
|
||||||
"load_rate_data",
|
|
||||||
"load_rate_data_from_connection",
|
|
||||||
"load_rate_series_by_granularity",
|
"load_rate_series_by_granularity",
|
||||||
"load_rate_series_from_sqlite",
|
"load_rate_series_from_sqlite",
|
||||||
"market_book",
|
|
||||||
"minimum_margins",
|
|
||||||
"mt5_session",
|
"mt5_session",
|
||||||
"mt5_summary",
|
|
||||||
"mt5_summary_as_df",
|
|
||||||
"mt5_trading_session",
|
"mt5_trading_session",
|
||||||
"mt5_version",
|
|
||||||
"normalize_dataframe",
|
|
||||||
"normalize_mt5_exception",
|
|
||||||
"normalize_order_volume",
|
"normalize_order_volume",
|
||||||
"normalize_symbol",
|
|
||||||
"normalize_symbols",
|
|
||||||
"normalize_time_columns",
|
|
||||||
"orders",
|
|
||||||
"parse_date_range",
|
|
||||||
"parse_datetime",
|
|
||||||
"parse_tick_flags",
|
|
||||||
"parse_timeframe",
|
|
||||||
"place_market_order",
|
"place_market_order",
|
||||||
"positions",
|
"report_rate_gaps",
|
||||||
"recent_history_deals",
|
|
||||||
"recent_ticks",
|
|
||||||
"recent_window",
|
|
||||||
"resolve_account_spec",
|
"resolve_account_spec",
|
||||||
"resolve_account_specs",
|
"resolve_account_specs",
|
||||||
"resolve_history_datasets",
|
"resolve_broker_filling_mode",
|
||||||
"resolve_history_tick_flags",
|
|
||||||
"resolve_history_timeframes",
|
|
||||||
"resolve_rate_table_name",
|
|
||||||
"resolve_rate_tables",
|
|
||||||
"resolve_rate_view_name",
|
|
||||||
"resolve_rate_view_names",
|
|
||||||
"schema_columns",
|
|
||||||
"substitute_env_placeholders",
|
|
||||||
"symbol_info",
|
|
||||||
"symbol_info_tick",
|
|
||||||
"symbols",
|
|
||||||
"terminal_info",
|
|
||||||
"update_history",
|
"update_history",
|
||||||
"update_history_with_config",
|
"update_history_with_config",
|
||||||
|
"update_observability",
|
||||||
|
"update_observability_with_config",
|
||||||
"update_sltp_for_open_positions",
|
"update_sltp_for_open_positions",
|
||||||
"validate_schema",
|
"update_trailing_stop_loss_for_open_positions",
|
||||||
]
|
]
|
||||||
|
|||||||
+432
-49
@@ -1,18 +1,24 @@
|
|||||||
"""Command-line interface for MetaTrader 5 data export."""
|
"""Command-line interface for MetaTrader 5 data and execution utilities."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime # noqa: TC003
|
from datetime import datetime # noqa: TC003
|
||||||
from pathlib import Path # noqa: TC003
|
from pathlib import Path # noqa: TC003
|
||||||
from typing import TYPE_CHECKING, Annotated, Any, cast
|
from typing import TYPE_CHECKING, Annotated, Any, cast
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
import typer
|
import typer
|
||||||
from pdmt5 import Mt5Config
|
|
||||||
|
|
||||||
from . import sdk
|
from . import sdk
|
||||||
from .client import MT5Client
|
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 (
|
from .utils import (
|
||||||
DATETIME_TYPE,
|
DATETIME_TYPE,
|
||||||
REQUEST_TYPE,
|
REQUEST_TYPE,
|
||||||
@@ -29,7 +35,7 @@ from .utils import (
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
import pandas as pd
|
from pdmt5 import Mt5Config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -54,18 +60,66 @@ class _ExportContext:
|
|||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
name="mt5cli",
|
name="mt5cli",
|
||||||
help="Export MetaTrader5 data to CSV, JSON, Parquet, or SQLite3.",
|
help=(
|
||||||
|
"MT5 data and execution utilities — read market data, inspect account"
|
||||||
|
" state, and send trade requests. Data commands write to CSV, JSON,"
|
||||||
|
" Parquet, or SQLite3. Execution commands (order-send, close-positions)"
|
||||||
|
" require --yes for live mutations."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
_REQUEST_OPTION_HELP = (
|
_REQUEST_OPTION_HELP = (
|
||||||
"Order request as a JSON object string, or '@path' to load JSON from a file."
|
"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:
|
def _get_export_context(ctx: typer.Context) -> _ExportContext:
|
||||||
return cast("_ExportContext", ctx.obj)
|
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(
|
def _execute_export(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
fetch_fn: Callable[[], pd.DataFrame],
|
fetch_fn: Callable[[], pd.DataFrame],
|
||||||
@@ -126,20 +180,36 @@ def _callback( # pyright: ignore[reportUnusedFunction]
|
|||||||
typer.Option(help="Table name for SQLite3 output."),
|
typer.Option(help="Table name for SQLite3 output."),
|
||||||
] = "data",
|
] = "data",
|
||||||
login: Annotated[
|
login: Annotated[
|
||||||
int | None,
|
str | None,
|
||||||
typer.Option(help="Trading account login."),
|
typer.Option(
|
||||||
|
help="Trading account login.",
|
||||||
|
envvar=_CLI_ENV_DEFAULTS["login"],
|
||||||
|
show_envvar=True,
|
||||||
|
),
|
||||||
] = None,
|
] = None,
|
||||||
password: Annotated[
|
password: Annotated[
|
||||||
str | None,
|
str | None,
|
||||||
typer.Option(help="Trading account password."),
|
typer.Option(
|
||||||
|
help="Trading account password.",
|
||||||
|
envvar=_CLI_ENV_DEFAULTS["password"],
|
||||||
|
show_envvar=True,
|
||||||
|
),
|
||||||
] = None,
|
] = None,
|
||||||
server: Annotated[
|
server: Annotated[
|
||||||
str | None,
|
str | None,
|
||||||
typer.Option(help="Trading server name."),
|
typer.Option(
|
||||||
|
help="Trading server name.",
|
||||||
|
envvar=_CLI_ENV_DEFAULTS["server"],
|
||||||
|
show_envvar=True,
|
||||||
|
),
|
||||||
] = None,
|
] = None,
|
||||||
path: Annotated[
|
path: Annotated[
|
||||||
str | None,
|
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,
|
] = None,
|
||||||
timeout: Annotated[
|
timeout: Annotated[
|
||||||
int | None,
|
int | None,
|
||||||
@@ -150,7 +220,7 @@ def _callback( # pyright: ignore[reportUnusedFunction]
|
|||||||
typer.Option("--log-level", help="Logging level."),
|
typer.Option("--log-level", help="Logging level."),
|
||||||
] = LogLevel.WARNING,
|
] = LogLevel.WARNING,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Configure shared options for all export commands.
|
"""Configure shared connection and output options.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
typer.BadParameter: If the output format cannot be determined.
|
typer.BadParameter: If the output format cannot be determined.
|
||||||
@@ -163,17 +233,22 @@ def _callback( # pyright: ignore[reportUnusedFunction]
|
|||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise typer.BadParameter(str(exc)) from 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(
|
ctx.obj = _ExportContext(
|
||||||
output=output,
|
output=output,
|
||||||
output_format=output_format,
|
output_format=output_format,
|
||||||
table=table,
|
table=table,
|
||||||
config=Mt5Config(
|
config=config,
|
||||||
path=path,
|
|
||||||
login=login,
|
|
||||||
password=password,
|
|
||||||
server=server,
|
|
||||||
timeout=timeout,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -182,7 +257,7 @@ def _callback( # pyright: ignore[reportUnusedFunction]
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def rates_from(
|
def rates_from(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
@@ -209,7 +284,7 @@ def rates_from(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def rates_from_pos(
|
def rates_from_pos(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
@@ -235,7 +310,7 @@ def rates_from_pos(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def latest_rates(
|
def latest_rates(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
@@ -264,7 +339,7 @@ def latest_rates(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def rates_range(
|
def rates_range(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
@@ -291,7 +366,7 @@ def rates_range(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def ticks_from(
|
def ticks_from(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
@@ -315,7 +390,7 @@ def ticks_from(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def ticks_range(
|
def ticks_range(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
@@ -339,7 +414,7 @@ def ticks_range(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def ticks_recent(
|
def ticks_recent(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
@@ -376,19 +451,19 @@ def ticks_recent(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def account_info(ctx: typer.Context) -> None:
|
def account_info(ctx: typer.Context) -> None:
|
||||||
"""Export account information."""
|
"""Export account information."""
|
||||||
_export_command(ctx, lambda client: client.account_info())
|
_export_command(ctx, lambda client: client.account_info())
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def terminal_info(ctx: typer.Context) -> None:
|
def terminal_info(ctx: typer.Context) -> None:
|
||||||
"""Export terminal information."""
|
"""Export terminal information."""
|
||||||
_export_command(ctx, lambda client: client.terminal_info())
|
_export_command(ctx, lambda client: client.terminal_info())
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def symbols(
|
def symbols(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
group: Annotated[
|
group: Annotated[
|
||||||
@@ -400,7 +475,7 @@ def symbols(
|
|||||||
_export_command(ctx, lambda client: client.symbols(group=group))
|
_export_command(ctx, lambda client: client.symbols(group=group))
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def symbol_info(
|
def symbol_info(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
@@ -409,7 +484,7 @@ def symbol_info(
|
|||||||
_export_command(ctx, lambda client: client.symbol_info(symbol))
|
_export_command(ctx, lambda client: client.symbol_info(symbol))
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def minimum_margins(
|
def minimum_margins(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
@@ -418,7 +493,7 @@ def minimum_margins(
|
|||||||
_export_command(ctx, lambda client: client.minimum_margins(symbol))
|
_export_command(ctx, lambda client: client.minimum_margins(symbol))
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def orders(
|
def orders(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str | None, typer.Option(help="Symbol filter.")] = None,
|
symbol: Annotated[str | None, typer.Option(help="Symbol filter.")] = None,
|
||||||
@@ -432,7 +507,7 @@ def orders(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def positions(
|
def positions(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str | None, typer.Option(help="Symbol filter.")] = None,
|
symbol: Annotated[str | None, typer.Option(help="Symbol filter.")] = None,
|
||||||
@@ -446,7 +521,7 @@ def positions(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def history_orders(
|
def history_orders(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
date_from: Annotated[
|
date_from: Annotated[
|
||||||
@@ -476,7 +551,7 @@ def history_orders(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def history_deals(
|
def history_deals(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
date_from: Annotated[
|
date_from: Annotated[
|
||||||
@@ -506,7 +581,7 @@ def history_deals(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def recent_history_deals(
|
def recent_history_deals(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
hours: Annotated[float, typer.Option(help="Lookback window in hours.")],
|
hours: Annotated[float, typer.Option(help="Lookback window in hours.")],
|
||||||
@@ -529,25 +604,25 @@ def recent_history_deals(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def mt5_summary(ctx: typer.Context) -> None:
|
def mt5_summary(ctx: typer.Context) -> None:
|
||||||
"""Export a compact terminal/account status summary."""
|
"""Export a compact terminal/account status summary."""
|
||||||
_export_command(ctx, lambda client: client.mt5_summary_as_df())
|
_export_command(ctx, lambda client: client.mt5_summary_as_df())
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def version(ctx: typer.Context) -> None:
|
def version(ctx: typer.Context) -> None:
|
||||||
"""Export MetaTrader5 version information."""
|
"""Export MetaTrader5 version information."""
|
||||||
_export_command(ctx, lambda client: client.version())
|
_export_command(ctx, lambda client: client.version())
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def last_error(ctx: typer.Context) -> None:
|
def last_error(ctx: typer.Context) -> None:
|
||||||
"""Export the last error information."""
|
"""Export the last error information."""
|
||||||
_export_command(ctx, lambda client: client.last_error())
|
_export_command(ctx, lambda client: client.last_error())
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def symbol_info_tick(
|
def symbol_info_tick(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
@@ -556,7 +631,7 @@ def symbol_info_tick(
|
|||||||
_export_command(ctx, lambda client: client.symbol_info_tick(symbol))
|
_export_command(ctx, lambda client: client.symbol_info_tick(symbol))
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def market_book(
|
def market_book(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
@@ -565,7 +640,7 @@ def market_book(
|
|||||||
_export_command(ctx, lambda client: client.market_book(symbol))
|
_export_command(ctx, lambda client: client.market_book(symbol))
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def order_check(
|
def order_check(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
request: Annotated[
|
request: Annotated[
|
||||||
@@ -577,7 +652,7 @@ def order_check(
|
|||||||
_export_command(ctx, lambda client: client.order_check(request))
|
_export_command(ctx, lambda client: client.order_check(request))
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Execution")
|
||||||
def order_send(
|
def order_send(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
request: Annotated[
|
request: Annotated[
|
||||||
@@ -589,7 +664,13 @@ def order_send(
|
|||||||
typer.Option("--yes", help="Confirm the live trade request."),
|
typer.Option("--yes", help="Confirm the live trade request."),
|
||||||
] = False,
|
] = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Send a trading operation request to the trade server.
|
"""Send a raw trade request to the trade server (expert path, live execution).
|
||||||
|
|
||||||
|
Passes the request JSON directly to MT5 ``order_send``. This is the
|
||||||
|
low-level expert path — it places real trades on the connected account
|
||||||
|
with no additional validation beyond what MT5 itself performs. Use
|
||||||
|
``order-check`` first to validate funds sufficiency. Prefer
|
||||||
|
``close-positions`` for closing open positions. ``--yes`` is required.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
typer.BadParameter: If --yes is not provided.
|
typer.BadParameter: If --yes is not provided.
|
||||||
@@ -600,7 +681,181 @@ def order_send(
|
|||||||
_export_command(ctx, lambda client: client.order_send(request))
|
_export_command(ctx, lambda client: client.order_send(request))
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
_EXECUTION_RESULT_COLUMNS: list[str] = [
|
||||||
|
"status",
|
||||||
|
"symbol",
|
||||||
|
"order_side",
|
||||||
|
"volume",
|
||||||
|
"retcode",
|
||||||
|
"comment",
|
||||||
|
"request",
|
||||||
|
"response",
|
||||||
|
"dry_run",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _execution_results_to_df(results: list[OrderExecutionResult]) -> pd.DataFrame:
|
||||||
|
if not results:
|
||||||
|
return pd.DataFrame(columns=_EXECUTION_RESULT_COLUMNS)
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
**r,
|
||||||
|
"request": json.dumps(r["request"]),
|
||||||
|
"response": json.dumps(r["response"]),
|
||||||
|
}
|
||||||
|
for r in results
|
||||||
|
]
|
||||||
|
return pd.DataFrame(rows)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(rich_help_panel="Execution")
|
||||||
|
def close_positions(
|
||||||
|
ctx: typer.Context,
|
||||||
|
symbol: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
typer.Option(
|
||||||
|
"--symbol",
|
||||||
|
"-s",
|
||||||
|
help="Symbol to close (repeat for multiple symbols).",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
ticket: Annotated[
|
||||||
|
list[int] | None,
|
||||||
|
typer.Option(
|
||||||
|
"--ticket",
|
||||||
|
"-t",
|
||||||
|
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."),
|
||||||
|
] = False,
|
||||||
|
yes: Annotated[
|
||||||
|
bool,
|
||||||
|
typer.Option("--yes", help="Confirm live position closing."),
|
||||||
|
] = False,
|
||||||
|
) -> None:
|
||||||
|
"""Close open positions by symbol or ticket.
|
||||||
|
|
||||||
|
Delegates to :func:`mt5cli.trading.close_open_positions`. At least one
|
||||||
|
``--symbol`` or ``--ticket`` must be provided to avoid accidentally closing
|
||||||
|
all positions. Use ``--dry-run`` to preview without executing; ``--yes`` is
|
||||||
|
required for live execution.
|
||||||
|
|
||||||
|
``order-send`` is the expert raw-request path. ``close-positions`` is the
|
||||||
|
safer high-level helper that builds correct close requests automatically.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
typer.BadParameter: If neither ``--symbol`` nor ``--ticket`` is given,
|
||||||
|
or if ``--yes`` is missing for a live (non-dry-run) run.
|
||||||
|
"""
|
||||||
|
if not symbol and not ticket:
|
||||||
|
msg = "Provide at least one --symbol or --ticket to close positions."
|
||||||
|
raise typer.BadParameter(msg)
|
||||||
|
if not dry_run and not yes:
|
||||||
|
msg = "Pass --yes to close live positions."
|
||||||
|
raise typer.BadParameter(msg, param_hint="--yes")
|
||||||
|
export_ctx = _get_export_context(ctx)
|
||||||
|
client = create_trading_client(config=export_ctx.config)
|
||||||
|
try:
|
||||||
|
results = close_open_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:
|
||||||
|
client.shutdown()
|
||||||
|
df = _execution_results_to_df(results)
|
||||||
|
_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(
|
def collect_history(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[
|
symbol: Annotated[
|
||||||
@@ -625,7 +880,8 @@ def collect_history(
|
|||||||
"--dataset",
|
"--dataset",
|
||||||
help=(
|
help=(
|
||||||
"Dataset to include (repeat for multiple)."
|
"Dataset to include (repeat for multiple)."
|
||||||
" Defaults to all: rates, ticks, history-orders, history-deals."
|
" Defaults to rates, history-orders, history-deals."
|
||||||
|
" Ticks are opt-in: pass --dataset ticks to include them."
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
] = None,
|
] = None,
|
||||||
@@ -663,10 +919,12 @@ def collect_history(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Collect historical datasets into a single SQLite database.
|
"""Collect historical datasets into a single SQLite database.
|
||||||
|
|
||||||
Tables written depend on ``--dataset``: ``rates``, ``ticks``,
|
Tables written depend on ``--dataset``: ``rates``, ``history_orders``,
|
||||||
``history_orders``, ``history_deals``. History datasets are fetched per
|
``history_deals`` by default. ``ticks`` are opt-in: pass
|
||||||
symbol and concatenated. Rates rows carry the requested ``timeframe`` so
|
``--dataset ticks`` to include them (tick data grows the database quickly).
|
||||||
appended runs at different timeframes remain distinguishable.
|
History datasets are fetched per symbol and concatenated. Rates rows carry
|
||||||
|
the requested ``timeframe`` so appended runs at different timeframes remain
|
||||||
|
distinguishable.
|
||||||
|
|
||||||
With ``--with-views`` (requires the ``history-deals`` dataset), optional
|
With ``--with-views`` (requires the ``history-deals`` dataset), optional
|
||||||
views ``cash_events`` and ``positions_reconstructed`` are derived from
|
views ``cash_events`` and ``positions_reconstructed`` are derived from
|
||||||
@@ -682,7 +940,7 @@ def collect_history(
|
|||||||
" Use a .db/.sqlite/.sqlite3 extension or --format sqlite3."
|
" Use a .db/.sqlite/.sqlite3 extension or --format sqlite3."
|
||||||
)
|
)
|
||||||
raise typer.BadParameter(msg)
|
raise typer.BadParameter(msg)
|
||||||
datasets = set(dataset) if dataset else set(Dataset)
|
datasets = set(dataset) if dataset is not None else None
|
||||||
sdk.collect_history(
|
sdk.collect_history(
|
||||||
output=export_ctx.output,
|
output=export_ctx.output,
|
||||||
symbols=symbol,
|
symbols=symbol,
|
||||||
@@ -697,6 +955,131 @@ def collect_history(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(rich_help_panel="Collection")
|
||||||
|
def grafana_schema(
|
||||||
|
ctx: typer.Context,
|
||||||
|
publish_copy: Annotated[
|
||||||
|
Path | None,
|
||||||
|
typer.Option(
|
||||||
|
"--publish-copy",
|
||||||
|
help=(
|
||||||
|
"Publish a Grafana-ready SQLite copy to this path"
|
||||||
|
" after schema creation."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Create or refresh Grafana-ready views and indexes in a SQLite database.
|
||||||
|
|
||||||
|
Idempotent — safe to run repeatedly on the same database. Requires SQLite
|
||||||
|
output. Does not connect to MetaTrader 5.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
typer.BadParameter: If the output format is not SQLite3.
|
||||||
|
"""
|
||||||
|
import sqlite3 as _sqlite3 # noqa: PLC0415
|
||||||
|
|
||||||
|
from .grafana import ( # noqa: PLC0415
|
||||||
|
create_snapshot_tables,
|
||||||
|
ensure_grafana_schema,
|
||||||
|
publish_grafana_copy,
|
||||||
|
)
|
||||||
|
|
||||||
|
export_ctx = _get_export_context(ctx)
|
||||||
|
if export_ctx.output_format != "sqlite3":
|
||||||
|
msg = (
|
||||||
|
"grafana-schema requires SQLite3 output."
|
||||||
|
" Use a .db/.sqlite/.sqlite3 extension or --format sqlite3."
|
||||||
|
)
|
||||||
|
raise typer.BadParameter(msg)
|
||||||
|
with _sqlite3.connect(export_ctx.output) as conn:
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA synchronous=NORMAL")
|
||||||
|
create_snapshot_tables(conn)
|
||||||
|
ensure_grafana_schema(conn)
|
||||||
|
logger.info("Grafana schema applied to %s", export_ctx.output)
|
||||||
|
if publish_copy is not None:
|
||||||
|
publish_grafana_copy(export_ctx.output, publish_copy)
|
||||||
|
logger.info("Grafana copy published to %s", publish_copy)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(rich_help_panel="Collection")
|
||||||
|
def snapshot(
|
||||||
|
ctx: typer.Context,
|
||||||
|
symbol: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
typer.Option(
|
||||||
|
"--symbol",
|
||||||
|
"-s",
|
||||||
|
help="Symbol filter for positions/orders (repeat for multiple).",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
with_account: Annotated[
|
||||||
|
bool,
|
||||||
|
typer.Option("--with-account/--no-account", help="Snapshot account info."),
|
||||||
|
] = True,
|
||||||
|
with_positions: Annotated[
|
||||||
|
bool,
|
||||||
|
typer.Option(
|
||||||
|
"--with-positions/--no-positions", help="Snapshot open positions."
|
||||||
|
),
|
||||||
|
] = True,
|
||||||
|
with_orders: Annotated[
|
||||||
|
bool,
|
||||||
|
typer.Option("--with-orders/--no-orders", help="Snapshot active orders."),
|
||||||
|
] = True,
|
||||||
|
with_terminal: Annotated[
|
||||||
|
bool,
|
||||||
|
typer.Option("--with-terminal/--no-terminal", help="Snapshot terminal info."),
|
||||||
|
] = True,
|
||||||
|
with_grafana_schema: Annotated[
|
||||||
|
bool,
|
||||||
|
typer.Option(
|
||||||
|
"--with-grafana-schema/--no-grafana-schema",
|
||||||
|
help="Ensure Grafana views and indexes exist.",
|
||||||
|
),
|
||||||
|
] = False,
|
||||||
|
publish_copy: Annotated[
|
||||||
|
Path | None,
|
||||||
|
typer.Option(
|
||||||
|
"--publish-copy",
|
||||||
|
help=("Publish a Grafana-ready SQLite copy to this path after snapshot."),
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Snapshot current account, position, order, and terminal state into SQLite.
|
||||||
|
|
||||||
|
Appends a timestamped snapshot row for each data type. Never places
|
||||||
|
orders or modifies trading state.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
typer.BadParameter: If the output format is not SQLite3.
|
||||||
|
"""
|
||||||
|
export_ctx = _get_export_context(ctx)
|
||||||
|
if export_ctx.output_format != "sqlite3":
|
||||||
|
msg = (
|
||||||
|
"snapshot requires SQLite3 output."
|
||||||
|
" Use a .db/.sqlite/.sqlite3 extension or --format sqlite3."
|
||||||
|
)
|
||||||
|
raise typer.BadParameter(msg)
|
||||||
|
sdk.update_observability_with_config(
|
||||||
|
output=export_ctx.output,
|
||||||
|
config=export_ctx.config,
|
||||||
|
symbols=list(symbol) if symbol else None,
|
||||||
|
include_account=with_account,
|
||||||
|
include_positions=with_positions,
|
||||||
|
include_orders=with_orders,
|
||||||
|
include_terminal=with_terminal,
|
||||||
|
with_grafana_schema=with_grafana_schema,
|
||||||
|
)
|
||||||
|
logger.info("Snapshot written to %s", export_ctx.output)
|
||||||
|
if publish_copy is not None:
|
||||||
|
from .grafana import publish_grafana_copy # noqa: PLC0415
|
||||||
|
|
||||||
|
publish_grafana_copy(export_ctx.output, publish_copy)
|
||||||
|
logger.info("Grafana copy published to %s", publish_copy)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Run the mt5cli CLI."""
|
"""Run the mt5cli CLI."""
|
||||||
app()
|
app()
|
||||||
|
|||||||
+1
-3
@@ -24,9 +24,7 @@ class MT5Client(Mt5CliClient):
|
|||||||
"""Public client for generic MT5 data access and order primitives.
|
"""Public client for generic MT5 data access and order primitives.
|
||||||
|
|
||||||
Extends the read-only SDK client with optional order check/send helpers and
|
Extends the read-only SDK client with optional order check/send helpers and
|
||||||
exposes the same connection lifecycle as :class:`~mt5cli.sdk.Mt5CliClient`.
|
exposes the same connection lifecycle as :func:`mt5_session`.
|
||||||
Downstream applications such as private trading packages should prefer this
|
|
||||||
type over the legacy ``Mt5CliClient`` name.
|
|
||||||
|
|
||||||
mt5cli intentionally exposes minimal execution primitives only. Trading
|
mt5cli intentionally exposes minimal execution primitives only. Trading
|
||||||
decisions, signals, strategies, backtests, and optimization remain the
|
decisions, signals, strategies, backtests, and optimization remain the
|
||||||
|
|||||||
+13
-47
@@ -1,108 +1,74 @@
|
|||||||
"""Stable downstream SDK export names for mt5cli."""
|
"""Downstream SDK export tier for mt5cli."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
||||||
"AccountSpec",
|
"AccountSpec",
|
||||||
"MT5Client",
|
"MT5Client",
|
||||||
"Mt5CliClient",
|
|
||||||
"Mt5CliError",
|
"Mt5CliError",
|
||||||
"Mt5Config",
|
|
||||||
"Mt5ConnectionError",
|
"Mt5ConnectionError",
|
||||||
"Mt5OperationError",
|
"Mt5OperationError",
|
||||||
"Mt5RuntimeError",
|
|
||||||
"Mt5SchemaError",
|
"Mt5SchemaError",
|
||||||
"Mt5TradingClient",
|
|
||||||
"Mt5TradingError",
|
|
||||||
"OrderFillingMode",
|
"OrderFillingMode",
|
||||||
"OrderSide",
|
"OrderSide",
|
||||||
"OrderTimeMode",
|
"OrderTimeMode",
|
||||||
"PositionSide",
|
"PositionSide",
|
||||||
|
"ProjectionMode",
|
||||||
"ExecutionStatus",
|
"ExecutionStatus",
|
||||||
"MarginVolume",
|
"MarginVolume",
|
||||||
"OrderExecutionResult",
|
"OrderExecutionResult",
|
||||||
"OrderLimits",
|
"OrderLimits",
|
||||||
"RateTarget",
|
"RateTarget",
|
||||||
"ThrottledHistoryUpdater",
|
"ThrottledHistoryUpdater",
|
||||||
"account_info",
|
|
||||||
"build_config",
|
"build_config",
|
||||||
"build_rate_targets",
|
"build_rate_targets",
|
||||||
"build_rate_view_name",
|
"calculate_account_projected_margin_ratio",
|
||||||
"calculate_margin_and_volume",
|
"calculate_margin_and_volume",
|
||||||
"calculate_new_position_margin_ratio",
|
"calculate_new_position_margin_ratio",
|
||||||
|
"calculate_projected_margin_ratio",
|
||||||
"calculate_positions_margin",
|
"calculate_positions_margin",
|
||||||
"calculate_positions_margin_by_symbol",
|
"calculate_positions_margin_by_symbol",
|
||||||
"calculate_positions_margin_safe",
|
"calculate_positions_margin_safe",
|
||||||
"calculate_spread_ratio",
|
"calculate_spread_ratio",
|
||||||
|
"calculate_symbol_group_margin_ratio",
|
||||||
|
"calculate_trailing_stop_updates",
|
||||||
"calculate_volume_by_margin",
|
"calculate_volume_by_margin",
|
||||||
"call_with_normalized_errors",
|
|
||||||
"close_open_positions",
|
"close_open_positions",
|
||||||
"collect_history",
|
"collect_history",
|
||||||
"collect_latest_closed_rates_by_granularity",
|
"collect_latest_closed_rates_by_granularity",
|
||||||
"collect_latest_closed_rates_for_accounts",
|
"collect_latest_closed_rates_for_accounts",
|
||||||
"collect_latest_rates",
|
|
||||||
"collect_latest_rates_for_accounts",
|
|
||||||
"collect_latest_rates_for_accounts_with_retries",
|
"collect_latest_rates_for_accounts_with_retries",
|
||||||
"copy_rates_from",
|
|
||||||
"copy_rates_from_pos",
|
|
||||||
"copy_rates_range",
|
|
||||||
"copy_ticks_from",
|
|
||||||
"copy_ticks_range",
|
|
||||||
"create_trading_client",
|
"create_trading_client",
|
||||||
"detect_position_side",
|
"detect_position_side",
|
||||||
"determine_order_limits",
|
"determine_order_limits",
|
||||||
"drop_forming_rate_bar",
|
"drop_forming_rate_bar",
|
||||||
"ensure_symbol_selected",
|
"ensure_symbol_selected",
|
||||||
"estimate_order_margin",
|
"estimate_order_margin",
|
||||||
"export_dataframe",
|
"extract_tick_price",
|
||||||
"export_dataframe_to_sqlite",
|
|
||||||
"fetch_latest_closed_rates",
|
"fetch_latest_closed_rates",
|
||||||
"fetch_latest_closed_rates_for_trading_client",
|
"fetch_latest_closed_rates_for_trading_client",
|
||||||
"fetch_latest_closed_rates_indexed",
|
"fetch_latest_closed_rates_indexed",
|
||||||
|
"fetch_recent_history_deals_for_trading_client",
|
||||||
"get_account_snapshot",
|
"get_account_snapshot",
|
||||||
"get_positions_frame",
|
"get_positions_frame",
|
||||||
"get_symbol_snapshot",
|
"get_symbol_snapshot",
|
||||||
"get_tick_snapshot",
|
"get_tick_snapshot",
|
||||||
"history_deals",
|
|
||||||
"history_orders",
|
|
||||||
"is_recoverable_mt5_error",
|
|
||||||
"last_error",
|
|
||||||
"latest_rates",
|
|
||||||
"load_rate_data",
|
|
||||||
"load_rate_data_from_connection",
|
|
||||||
"load_rate_series_by_granularity",
|
"load_rate_series_by_granularity",
|
||||||
"load_rate_series_from_sqlite",
|
"load_rate_series_from_sqlite",
|
||||||
"market_book",
|
|
||||||
"minimum_margins",
|
|
||||||
"mt5_session",
|
"mt5_session",
|
||||||
"mt5_summary",
|
|
||||||
"mt5_summary_as_df",
|
|
||||||
"mt5_trading_session",
|
"mt5_trading_session",
|
||||||
"mt5_version",
|
|
||||||
"normalize_mt5_exception",
|
|
||||||
"normalize_order_volume",
|
"normalize_order_volume",
|
||||||
"orders",
|
|
||||||
"place_market_order",
|
"place_market_order",
|
||||||
"positions",
|
"report_rate_gaps",
|
||||||
"recent_history_deals",
|
"resolve_broker_filling_mode",
|
||||||
"recent_ticks",
|
|
||||||
"resolve_account_spec",
|
"resolve_account_spec",
|
||||||
"resolve_account_specs",
|
"resolve_account_specs",
|
||||||
"resolve_history_datasets",
|
|
||||||
"resolve_history_tick_flags",
|
|
||||||
"resolve_history_timeframes",
|
|
||||||
"resolve_rate_table_name",
|
|
||||||
"resolve_rate_tables",
|
|
||||||
"resolve_rate_view_name",
|
|
||||||
"resolve_rate_view_names",
|
|
||||||
"substitute_env_placeholders",
|
|
||||||
"symbol_info",
|
|
||||||
"symbol_info_tick",
|
|
||||||
"symbols",
|
|
||||||
"terminal_info",
|
|
||||||
"update_history",
|
"update_history",
|
||||||
"update_history_with_config",
|
"update_history_with_config",
|
||||||
|
"update_observability",
|
||||||
|
"update_observability_with_config",
|
||||||
"update_sltp_for_open_positions",
|
"update_sltp_for_open_positions",
|
||||||
|
"update_trailing_stop_loss_for_open_positions",
|
||||||
})
|
})
|
||||||
|
|
||||||
__all__ = ["STABLE_SDK_EXPORTS"]
|
__all__ = ["STABLE_SDK_EXPORTS"]
|
||||||
|
|||||||
+5
-10
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING, TypeVar
|
from typing import TYPE_CHECKING, TypeVar
|
||||||
|
|
||||||
from pdmt5 import Mt5RuntimeError, Mt5TradingError
|
from pdmt5 import Mt5RuntimeError
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@@ -21,10 +21,7 @@ __all__ = [
|
|||||||
"normalize_mt5_exception",
|
"normalize_mt5_exception",
|
||||||
]
|
]
|
||||||
|
|
||||||
_RECOVERABLE_MT5_ERRORS: tuple[type[BaseException], ...] = (
|
_RECOVERABLE_MT5_ERRORS: tuple[type[BaseException], ...] = (Mt5RuntimeError,)
|
||||||
Mt5TradingError,
|
|
||||||
Mt5RuntimeError,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Mt5CliError(Exception):
|
class Mt5CliError(Exception):
|
||||||
@@ -50,7 +47,7 @@ def is_recoverable_mt5_error(exc: BaseException) -> bool:
|
|||||||
exc: Exception raised by MT5 or pdmt5.
|
exc: Exception raised by MT5 or pdmt5.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True for ``Mt5RuntimeError`` and ``Mt5TradingError``.
|
True for ``Mt5RuntimeError``.
|
||||||
"""
|
"""
|
||||||
return isinstance(exc, _RECOVERABLE_MT5_ERRORS)
|
return isinstance(exc, _RECOVERABLE_MT5_ERRORS)
|
||||||
|
|
||||||
@@ -62,11 +59,9 @@ def normalize_mt5_exception(exc: BaseException) -> Mt5CliError:
|
|||||||
exc: Original exception from MT5 or pdmt5.
|
exc: Original exception from MT5 or pdmt5.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
``Mt5ConnectionError`` for runtime failures, ``Mt5OperationError`` for
|
``Mt5ConnectionError`` for runtime failures, or the original exception
|
||||||
trading failures, or the original exception when it is not recognized.
|
when it is not recognized.
|
||||||
"""
|
"""
|
||||||
if isinstance(exc, Mt5TradingError):
|
|
||||||
return Mt5OperationError(str(exc))
|
|
||||||
if isinstance(exc, Mt5RuntimeError):
|
if isinstance(exc, Mt5RuntimeError):
|
||||||
return Mt5ConnectionError(str(exc))
|
return Mt5ConnectionError(str(exc))
|
||||||
if isinstance(exc, Mt5CliError):
|
if isinstance(exc, Mt5CliError):
|
||||||
|
|||||||
@@ -0,0 +1,682 @@
|
|||||||
|
"""Grafana-oriented SQLite views, indexes, and snapshot tables."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from .history import get_table_columns
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_TRADE_DEAL_TYPES_SQL = "(0, 1)"
|
||||||
|
|
||||||
|
_GRAFANA_VIEW_NAMES = (
|
||||||
|
"grafana_rates",
|
||||||
|
"grafana_ticks",
|
||||||
|
"grafana_history_deals",
|
||||||
|
"grafana_history_orders",
|
||||||
|
"grafana_trade_deals",
|
||||||
|
"grafana_cash_events",
|
||||||
|
"grafana_realized_pnl",
|
||||||
|
"grafana_symbol_pnl",
|
||||||
|
"grafana_trade_stats",
|
||||||
|
"grafana_account_snapshots",
|
||||||
|
"grafana_position_snapshots",
|
||||||
|
"grafana_order_snapshots",
|
||||||
|
"grafana_terminal_snapshots",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_epoch_int(value: object) -> int | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime.datetime):
|
||||||
|
return int(value.timestamp())
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return int(value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _time_col_expr(col: str) -> str:
|
||||||
|
return (
|
||||||
|
f"CASE WHEN typeof(\"{col}\") IN ('integer', 'real')"
|
||||||
|
f' THEN CAST("{col}" AS INTEGER)'
|
||||||
|
f" ELSE CAST(strftime('%s', \"{col}\") AS INTEGER) END"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_view_safe(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
name: str,
|
||||||
|
select_sql: str,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
conn.execute(f'DROP VIEW IF EXISTS "{name}"')
|
||||||
|
conn.execute(f'CREATE VIEW "{name}" AS {select_sql}')
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
logger.warning("Skipping view %s: %s", name, exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _other_cols(all_cols: set[str], exclude: set[str]) -> list[str]:
|
||||||
|
return sorted(all_cols - exclude)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Snapshot table DDL
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SNAPSHOT_TABLE_DDLS: list[str] = [
|
||||||
|
"""CREATE TABLE IF NOT EXISTS snapshot_runs (
|
||||||
|
run_id INTEGER PRIMARY KEY,
|
||||||
|
observed_at INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
detail TEXT
|
||||||
|
)""",
|
||||||
|
"""CREATE TABLE IF NOT EXISTS account_snapshots (
|
||||||
|
run_id INTEGER NOT NULL,
|
||||||
|
login INTEGER,
|
||||||
|
currency TEXT,
|
||||||
|
balance REAL,
|
||||||
|
equity REAL,
|
||||||
|
margin REAL,
|
||||||
|
margin_free REAL,
|
||||||
|
margin_level REAL,
|
||||||
|
profit REAL,
|
||||||
|
leverage INTEGER
|
||||||
|
)""",
|
||||||
|
"""CREATE TABLE IF NOT EXISTS position_snapshots (
|
||||||
|
run_id INTEGER NOT NULL,
|
||||||
|
login INTEGER,
|
||||||
|
ticket INTEGER,
|
||||||
|
position_id INTEGER,
|
||||||
|
symbol TEXT,
|
||||||
|
type INTEGER,
|
||||||
|
volume REAL,
|
||||||
|
price_open REAL,
|
||||||
|
price_current REAL,
|
||||||
|
profit REAL,
|
||||||
|
swap REAL,
|
||||||
|
comment TEXT,
|
||||||
|
magic INTEGER
|
||||||
|
)""",
|
||||||
|
"""CREATE TABLE IF NOT EXISTS order_snapshots (
|
||||||
|
run_id INTEGER NOT NULL,
|
||||||
|
login INTEGER,
|
||||||
|
ticket INTEGER,
|
||||||
|
symbol TEXT,
|
||||||
|
type INTEGER,
|
||||||
|
volume_current REAL,
|
||||||
|
price_open REAL,
|
||||||
|
price_current REAL,
|
||||||
|
state INTEGER,
|
||||||
|
comment TEXT,
|
||||||
|
magic INTEGER,
|
||||||
|
time_setup INTEGER
|
||||||
|
)""",
|
||||||
|
"""CREATE TABLE IF NOT EXISTS terminal_snapshots (
|
||||||
|
run_id INTEGER NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
connected INTEGER,
|
||||||
|
community_account INTEGER,
|
||||||
|
trade_allowed INTEGER,
|
||||||
|
trade_expert INTEGER,
|
||||||
|
path TEXT,
|
||||||
|
company TEXT,
|
||||||
|
language TEXT
|
||||||
|
)""",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def create_snapshot_tables(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Create snapshot tables idempotently."""
|
||||||
|
for ddl in _SNAPSHOT_TABLE_DDLS:
|
||||||
|
conn.execute(ddl)
|
||||||
|
|
||||||
|
|
||||||
|
def start_snapshot_run(conn: sqlite3.Connection, observed_at: int) -> int:
|
||||||
|
"""Insert a snapshot_runs row with status 'running' and return its run_id.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The auto-assigned run_id for the new row.
|
||||||
|
"""
|
||||||
|
cursor = conn.execute(
|
||||||
|
"INSERT INTO snapshot_runs (observed_at, status) VALUES (?, 'running')",
|
||||||
|
(observed_at,),
|
||||||
|
)
|
||||||
|
return cast("int", cursor.lastrowid)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# View builders
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _build_grafana_rates(conn: sqlite3.Connection) -> None:
|
||||||
|
cols = get_table_columns(conn, "rates")
|
||||||
|
required = {"time", "symbol", "timeframe"}
|
||||||
|
if not required.issubset(cols):
|
||||||
|
logger.warning(
|
||||||
|
"Skipping grafana_rates: rates table missing columns %s",
|
||||||
|
sorted(required - cols),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
time_expr = _time_col_expr("time")
|
||||||
|
others = _other_cols(cols, {"time"})
|
||||||
|
other_sql = ", ".join(f'"{c}"' for c in others)
|
||||||
|
_create_view_safe(
|
||||||
|
conn,
|
||||||
|
"grafana_rates",
|
||||||
|
f'SELECT {time_expr} AS "time", {other_sql} FROM "rates"', # noqa: S608
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_grafana_ticks(conn: sqlite3.Connection) -> None:
|
||||||
|
cols = get_table_columns(conn, "ticks")
|
||||||
|
required = {"time", "symbol"}
|
||||||
|
if not required.issubset(cols):
|
||||||
|
logger.warning(
|
||||||
|
"Skipping grafana_ticks: ticks table missing columns %s",
|
||||||
|
sorted(required - cols),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
time_expr = _time_col_expr("time")
|
||||||
|
others = _other_cols(cols, {"time"})
|
||||||
|
other_sql = ", ".join(f'"{c}"' for c in others)
|
||||||
|
_create_view_safe(
|
||||||
|
conn,
|
||||||
|
"grafana_ticks",
|
||||||
|
f'SELECT {time_expr} AS "time", {other_sql} FROM "ticks"', # noqa: S608
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_grafana_history_deals(conn: sqlite3.Connection) -> None:
|
||||||
|
cols = get_table_columns(conn, "history_deals")
|
||||||
|
if "time" not in cols:
|
||||||
|
logger.warning("Skipping grafana_history_deals: history_deals.time is missing")
|
||||||
|
return
|
||||||
|
time_expr = _time_col_expr("time")
|
||||||
|
others = _other_cols(cols, {"time"})
|
||||||
|
other_sql = ", ".join(f'"{c}"' for c in others)
|
||||||
|
_create_view_safe(
|
||||||
|
conn,
|
||||||
|
"grafana_history_deals",
|
||||||
|
f'SELECT {time_expr} AS "time", {other_sql} FROM "history_deals"', # noqa: S608
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_grafana_history_orders(conn: sqlite3.Connection) -> None:
|
||||||
|
cols = get_table_columns(conn, "history_orders")
|
||||||
|
if "time_setup" not in cols:
|
||||||
|
logger.warning(
|
||||||
|
"Skipping grafana_history_orders: history_orders.time_setup is missing"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
time_expr = _time_col_expr("time_setup")
|
||||||
|
others = _other_cols(cols, set())
|
||||||
|
other_sql = ", ".join(f'"{c}"' for c in others)
|
||||||
|
_create_view_safe(
|
||||||
|
conn,
|
||||||
|
"grafana_history_orders",
|
||||||
|
f'SELECT {time_expr} AS "time", {other_sql} FROM "history_orders"', # noqa: S608
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_grafana_trade_deals(conn: sqlite3.Connection) -> None:
|
||||||
|
cols = get_table_columns(conn, "history_deals")
|
||||||
|
required = {"time", "type"}
|
||||||
|
if not required.issubset(cols):
|
||||||
|
logger.warning(
|
||||||
|
"Skipping grafana_trade_deals: history_deals missing columns %s",
|
||||||
|
sorted(required - cols),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
time_expr = _time_col_expr("time")
|
||||||
|
others = _other_cols(cols, {"time"})
|
||||||
|
other_sql = ", ".join(f'"{c}"' for c in others)
|
||||||
|
_create_view_safe(
|
||||||
|
conn,
|
||||||
|
"grafana_trade_deals",
|
||||||
|
f'SELECT {time_expr} AS "time", {other_sql}' # noqa: S608
|
||||||
|
f' FROM "history_deals" WHERE "type" IN {_TRADE_DEAL_TYPES_SQL}',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_grafana_cash_events(conn: sqlite3.Connection) -> None:
|
||||||
|
cols = get_table_columns(conn, "history_deals")
|
||||||
|
required = {"time", "type"}
|
||||||
|
if not required.issubset(cols):
|
||||||
|
logger.warning(
|
||||||
|
"Skipping grafana_cash_events: history_deals missing columns %s",
|
||||||
|
sorted(required - cols),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
time_expr = _time_col_expr("time")
|
||||||
|
others = _other_cols(cols, {"time"})
|
||||||
|
other_sql = ", ".join(f'"{c}"' for c in others)
|
||||||
|
_create_view_safe(
|
||||||
|
conn,
|
||||||
|
"grafana_cash_events",
|
||||||
|
f'SELECT {time_expr} AS "time", {other_sql}' # noqa: S608
|
||||||
|
f' FROM "history_deals" WHERE "type" NOT IN {_TRADE_DEAL_TYPES_SQL}',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_grafana_realized_pnl(conn: sqlite3.Connection) -> None:
|
||||||
|
cols = get_table_columns(conn, "history_deals")
|
||||||
|
required = {"symbol", "profit", "type", "entry"}
|
||||||
|
if not required.issubset(cols):
|
||||||
|
logger.warning(
|
||||||
|
"Skipping grafana_realized_pnl: history_deals missing columns %s",
|
||||||
|
sorted(required - cols),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
_create_view_safe(
|
||||||
|
conn,
|
||||||
|
"grafana_realized_pnl",
|
||||||
|
'SELECT "symbol",' # noqa: S608
|
||||||
|
' SUM("profit") AS cumulative_pnl, COUNT(*) AS deal_count'
|
||||||
|
' FROM "history_deals"'
|
||||||
|
f' WHERE "type" IN {_TRADE_DEAL_TYPES_SQL}'
|
||||||
|
' AND "entry" IN (1, 2, 3)'
|
||||||
|
' AND "symbol" IS NOT NULL AND "symbol" != \'\''
|
||||||
|
' GROUP BY "symbol"',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_grafana_symbol_pnl(conn: sqlite3.Connection) -> None:
|
||||||
|
cols = get_table_columns(conn, "history_deals")
|
||||||
|
required = {"time", "symbol", "profit", "type", "entry"}
|
||||||
|
if not required.issubset(cols):
|
||||||
|
logger.warning(
|
||||||
|
"Skipping grafana_symbol_pnl: history_deals missing columns %s",
|
||||||
|
sorted(required - cols),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
time_expr = _time_col_expr("time")
|
||||||
|
select_parts = [f'{time_expr} AS "time"', '"symbol"', '"profit"']
|
||||||
|
if "volume" in cols:
|
||||||
|
select_parts.append('"volume"')
|
||||||
|
if "price" in cols:
|
||||||
|
select_parts.append('"price"')
|
||||||
|
select_sql = ", ".join(select_parts)
|
||||||
|
_create_view_safe(
|
||||||
|
conn,
|
||||||
|
"grafana_symbol_pnl",
|
||||||
|
f'SELECT {select_sql} FROM "history_deals"' # noqa: S608
|
||||||
|
f' WHERE "type" IN {_TRADE_DEAL_TYPES_SQL}'
|
||||||
|
' AND "entry" IN (1, 2, 3)'
|
||||||
|
' AND "symbol" IS NOT NULL AND "symbol" != \'\'',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_grafana_trade_stats(conn: sqlite3.Connection) -> None:
|
||||||
|
cols = get_table_columns(conn, "history_deals")
|
||||||
|
required = {"symbol", "profit", "type"}
|
||||||
|
if not required.issubset(cols):
|
||||||
|
logger.warning(
|
||||||
|
"Skipping grafana_trade_stats: history_deals missing columns %s",
|
||||||
|
sorted(required - cols),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
has_entry = "entry" in cols
|
||||||
|
entry_filter = ' AND "entry" IN (1, 2, 3)' if has_entry else ""
|
||||||
|
_create_view_safe(
|
||||||
|
conn,
|
||||||
|
"grafana_trade_stats",
|
||||||
|
'SELECT "symbol",' # noqa: S608
|
||||||
|
" COUNT(*) AS total_deals,"
|
||||||
|
' SUM(CASE WHEN "profit" > 0 THEN 1 ELSE 0 END) AS winning_deals,'
|
||||||
|
' SUM(CASE WHEN "profit" <= 0 THEN 1 ELSE 0 END) AS losing_deals,'
|
||||||
|
' SUM("profit") AS total_profit,'
|
||||||
|
' AVG("profit") AS avg_profit,'
|
||||||
|
' MAX("profit") AS max_profit,'
|
||||||
|
' MIN("profit") AS min_profit'
|
||||||
|
' FROM "history_deals"'
|
||||||
|
f' WHERE "type" IN {_TRADE_DEAL_TYPES_SQL}'
|
||||||
|
f"{entry_filter}"
|
||||||
|
' AND "symbol" IS NOT NULL AND "symbol" != \'\''
|
||||||
|
' GROUP BY "symbol"',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_snapshot_view(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
view_name: str,
|
||||||
|
table_name: str,
|
||||||
|
) -> None:
|
||||||
|
cols = get_table_columns(conn, table_name)
|
||||||
|
if not cols:
|
||||||
|
logger.warning("Skipping %s: %s table missing", view_name, table_name)
|
||||||
|
return
|
||||||
|
if "run_id" not in cols:
|
||||||
|
logger.warning("Skipping %s: %s missing run_id column", view_name, table_name)
|
||||||
|
return
|
||||||
|
others = _other_cols(cols, {"run_id"})
|
||||||
|
run_cols = get_table_columns(conn, "snapshot_runs")
|
||||||
|
if {"run_id", "observed_at", "status"}.issubset(run_cols):
|
||||||
|
other_sql = (", " + ", ".join(f's."{c}"' for c in others)) if others else ""
|
||||||
|
select_cols = f'r."observed_at" AS "time", s."run_id"{other_sql}'
|
||||||
|
_create_view_safe(
|
||||||
|
conn,
|
||||||
|
view_name,
|
||||||
|
f'SELECT {select_cols} FROM "{table_name}" s' # noqa: S608
|
||||||
|
f' JOIN "snapshot_runs" r ON s."run_id" = r."run_id"'
|
||||||
|
f" WHERE r.\"status\" = 'ok'",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning("Skipping %s: snapshot_runs missing required columns", view_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_grafana_account_snapshots(conn: sqlite3.Connection) -> None:
|
||||||
|
_build_snapshot_view(conn, "grafana_account_snapshots", "account_snapshots")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_grafana_position_snapshots(conn: sqlite3.Connection) -> None:
|
||||||
|
_build_snapshot_view(conn, "grafana_position_snapshots", "position_snapshots")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_grafana_order_snapshots(conn: sqlite3.Connection) -> None:
|
||||||
|
_build_snapshot_view(conn, "grafana_order_snapshots", "order_snapshots")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_grafana_terminal_snapshots(conn: sqlite3.Connection) -> None:
|
||||||
|
_build_snapshot_view(conn, "grafana_terminal_snapshots", "terminal_snapshots")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def create_grafana_views(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Create all Grafana-facing views idempotently.
|
||||||
|
|
||||||
|
Missing source tables cause the affected view to be skipped with a warning;
|
||||||
|
other views are unaffected. Stale views whose source table or required
|
||||||
|
columns have disappeared are dropped before rebuild.
|
||||||
|
"""
|
||||||
|
for name in _GRAFANA_VIEW_NAMES:
|
||||||
|
conn.execute(f'DROP VIEW IF EXISTS "{name}"')
|
||||||
|
_build_grafana_rates(conn)
|
||||||
|
_build_grafana_ticks(conn)
|
||||||
|
_build_grafana_history_deals(conn)
|
||||||
|
_build_grafana_history_orders(conn)
|
||||||
|
_build_grafana_trade_deals(conn)
|
||||||
|
_build_grafana_cash_events(conn)
|
||||||
|
_build_grafana_realized_pnl(conn)
|
||||||
|
_build_grafana_symbol_pnl(conn)
|
||||||
|
_build_grafana_trade_stats(conn)
|
||||||
|
_build_grafana_account_snapshots(conn)
|
||||||
|
_build_grafana_position_snapshots(conn)
|
||||||
|
_build_grafana_order_snapshots(conn)
|
||||||
|
_build_grafana_terminal_snapshots(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def create_grafana_indexes(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Create Grafana query performance indexes idempotently."""
|
||||||
|
rates_cols = get_table_columns(conn, "rates")
|
||||||
|
if {"time", "symbol", "timeframe"}.issubset(rates_cols):
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_rates_time_symbol_timeframe"
|
||||||
|
' ON "rates"("time", "symbol", "timeframe")',
|
||||||
|
)
|
||||||
|
|
||||||
|
ticks_cols = get_table_columns(conn, "ticks")
|
||||||
|
if {"time", "symbol"}.issubset(ticks_cols):
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_ticks_time_symbol"
|
||||||
|
' ON "ticks"("time", "symbol")',
|
||||||
|
)
|
||||||
|
|
||||||
|
deals_cols = get_table_columns(conn, "history_deals")
|
||||||
|
if {"time", "symbol"}.issubset(deals_cols):
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_history_deals_time_symbol"
|
||||||
|
' ON "history_deals"("time", "symbol")',
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_history_deals_symbol_time"
|
||||||
|
' ON "history_deals"("symbol", "time")',
|
||||||
|
)
|
||||||
|
|
||||||
|
orders_cols = get_table_columns(conn, "history_orders")
|
||||||
|
if {"time_setup", "symbol"}.issubset(orders_cols):
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_history_orders_time_setup_symbol"
|
||||||
|
' ON "history_orders"("time_setup", "symbol")',
|
||||||
|
)
|
||||||
|
|
||||||
|
if {"run_id", "login"}.issubset(get_table_columns(conn, "account_snapshots")):
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_account_snapshots_time_login"
|
||||||
|
' ON "account_snapshots"("run_id", "login")',
|
||||||
|
)
|
||||||
|
if {"run_id", "symbol"}.issubset(get_table_columns(conn, "position_snapshots")):
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_position_snapshots_time_symbol"
|
||||||
|
' ON "position_snapshots"("run_id", "symbol")',
|
||||||
|
)
|
||||||
|
if {"run_id", "symbol"}.issubset(get_table_columns(conn, "order_snapshots")):
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_order_snapshots_time_symbol"
|
||||||
|
' ON "order_snapshots"("run_id", "symbol")',
|
||||||
|
)
|
||||||
|
if {"observed_at", "status"}.issubset(get_table_columns(conn, "snapshot_runs")):
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_snapshot_runs_time_status"
|
||||||
|
' ON "snapshot_runs"("observed_at", "status")',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_grafana_schema(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Create snapshot tables, Grafana views, and indexes idempotently."""
|
||||||
|
create_snapshot_tables(conn)
|
||||||
|
create_grafana_views(conn)
|
||||||
|
create_grafana_indexes(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def publish_grafana_copy(
|
||||||
|
source: str | Path,
|
||||||
|
target: str | Path,
|
||||||
|
) -> Path:
|
||||||
|
"""Publish a consistent SQLite copy for Grafana using the backup API.
|
||||||
|
|
||||||
|
Uses the SQLite online backup API for a WAL-safe, consistent snapshot of
|
||||||
|
the source database. Writes to a temporary file beside the target, then
|
||||||
|
atomically replaces it so that a previous published copy is preserved if
|
||||||
|
publishing fails.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source: Path to the source SQLite database.
|
||||||
|
target: Destination path for the published copy.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The resolved absolute target path.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If the source database does not exist.
|
||||||
|
ValueError: If source and target resolve to the same path.
|
||||||
|
"""
|
||||||
|
source_path = Path(source)
|
||||||
|
target_path = Path(target)
|
||||||
|
if source_path.resolve() == target_path.resolve():
|
||||||
|
msg = "--publish-copy target must differ from the source database: " + str(
|
||||||
|
source_path
|
||||||
|
)
|
||||||
|
raise ValueError(msg)
|
||||||
|
if not source_path.exists():
|
||||||
|
raise FileNotFoundError(source_path)
|
||||||
|
|
||||||
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
tmp_fd, tmp_str = tempfile.mkstemp(
|
||||||
|
dir=target_path.parent,
|
||||||
|
suffix=".tmp",
|
||||||
|
prefix=target_path.name + ".",
|
||||||
|
)
|
||||||
|
tmp_path = Path(tmp_str)
|
||||||
|
try:
|
||||||
|
os.close(tmp_fd)
|
||||||
|
with (
|
||||||
|
contextlib.closing(sqlite3.connect(source_path)) as src,
|
||||||
|
contextlib.closing(sqlite3.connect(tmp_path)) as dst,
|
||||||
|
):
|
||||||
|
src.backup(dst)
|
||||||
|
try:
|
||||||
|
target_mode = target_path.stat().st_mode & 0o777
|
||||||
|
except FileNotFoundError:
|
||||||
|
target_mode = 0o644
|
||||||
|
Path(tmp_path).chmod(target_mode)
|
||||||
|
tmp_path.replace(target_path)
|
||||||
|
except Exception:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
tmp_path.unlink()
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info("Published Grafana copy: %s -> %s", source_path, target_path)
|
||||||
|
return target_path.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Snapshot insert helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def insert_account_snapshot(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
run_id: int,
|
||||||
|
row: dict[str, object],
|
||||||
|
) -> None:
|
||||||
|
"""Append one account state row to account_snapshots."""
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO account_snapshots"
|
||||||
|
" (run_id, login, currency, balance, equity,"
|
||||||
|
" margin, margin_free, margin_level, profit, leverage)"
|
||||||
|
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
run_id,
|
||||||
|
row.get("login"),
|
||||||
|
row.get("currency"),
|
||||||
|
row.get("balance"),
|
||||||
|
row.get("equity"),
|
||||||
|
row.get("margin"),
|
||||||
|
row.get("margin_free"),
|
||||||
|
row.get("margin_level"),
|
||||||
|
row.get("profit"),
|
||||||
|
row.get("leverage"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def insert_position_snapshots(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
run_id: int,
|
||||||
|
login: int | None,
|
||||||
|
rows: list[dict[str, object]],
|
||||||
|
) -> None:
|
||||||
|
"""Append position rows to position_snapshots; no-op when rows is empty."""
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO position_snapshots"
|
||||||
|
" (run_id, login, ticket, position_id, symbol, type, volume,"
|
||||||
|
" price_open, price_current, profit, swap, comment, magic)"
|
||||||
|
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
run_id,
|
||||||
|
login,
|
||||||
|
r.get("ticket"),
|
||||||
|
r.get("position_id"),
|
||||||
|
r.get("symbol"),
|
||||||
|
r.get("type"),
|
||||||
|
r.get("volume"),
|
||||||
|
r.get("price_open"),
|
||||||
|
r.get("price_current"),
|
||||||
|
r.get("profit"),
|
||||||
|
r.get("swap"),
|
||||||
|
r.get("comment"),
|
||||||
|
r.get("magic"),
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def insert_order_snapshots(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
run_id: int,
|
||||||
|
login: int | None,
|
||||||
|
rows: list[dict[str, object]],
|
||||||
|
) -> None:
|
||||||
|
"""Append order rows to order_snapshots; no-op when rows is empty."""
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO order_snapshots"
|
||||||
|
" (run_id, login, ticket, symbol, type, volume_current,"
|
||||||
|
" price_open, price_current, state, comment, magic, time_setup)"
|
||||||
|
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
run_id,
|
||||||
|
login,
|
||||||
|
r.get("ticket"),
|
||||||
|
r.get("symbol"),
|
||||||
|
r.get("type"),
|
||||||
|
r.get("volume_current"),
|
||||||
|
r.get("price_open"),
|
||||||
|
r.get("price_current"),
|
||||||
|
r.get("state"),
|
||||||
|
r.get("comment"),
|
||||||
|
r.get("magic"),
|
||||||
|
_to_epoch_int(r.get("time_setup")),
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def insert_terminal_snapshot(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
run_id: int,
|
||||||
|
row: dict[str, object],
|
||||||
|
) -> None:
|
||||||
|
"""Append one terminal state row to terminal_snapshots."""
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO terminal_snapshots"
|
||||||
|
" (run_id, name, connected, community_account,"
|
||||||
|
" trade_allowed, trade_expert, path, company, language)"
|
||||||
|
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
run_id,
|
||||||
|
row.get("name"),
|
||||||
|
row.get("connected"),
|
||||||
|
row.get("community_account"),
|
||||||
|
row.get("trade_allowed"),
|
||||||
|
row.get("trade_expert"),
|
||||||
|
row.get("path"),
|
||||||
|
row.get("company"),
|
||||||
|
row.get("language"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def record_snapshot_run(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
run_id: int,
|
||||||
|
status: str,
|
||||||
|
detail: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Finalize a snapshot run by setting its status."""
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE snapshot_runs SET status = ?, detail = ? WHERE run_id = ?",
|
||||||
|
(status, detail, run_id),
|
||||||
|
)
|
||||||
+361
-63
@@ -3,16 +3,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
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
|
import pandas as pd
|
||||||
from pdmt5 import get_timeframe_name as _get_timeframe_name
|
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 (
|
from .utils import (
|
||||||
TIMEFRAME_NAMES,
|
TIMEFRAME_NAMES,
|
||||||
Dataset,
|
Dataset,
|
||||||
@@ -29,7 +30,19 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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_TIMEFRAMES: tuple[str, ...] = TIMEFRAME_NAMES
|
||||||
|
DEFAULT_HISTORY_DATASETS: frozenset[Dataset] = frozenset({
|
||||||
|
Dataset.rates,
|
||||||
|
Dataset.history_orders,
|
||||||
|
Dataset.history_deals,
|
||||||
|
})
|
||||||
|
|
||||||
_HISTORY_DEDUP_KEYS: dict[Dataset, tuple[tuple[str, ...], ...]] = {
|
_HISTORY_DEDUP_KEYS: dict[Dataset, tuple[tuple[str, ...], ...]] = {
|
||||||
Dataset.rates: DEDUP_KEYS[DataKind.rates],
|
Dataset.rates: DEDUP_KEYS[DataKind.rates],
|
||||||
@@ -51,6 +64,20 @@ _POSITIONS_VIEW_REQUIRED_COLUMNS: frozenset[str] = frozenset({
|
|||||||
"price",
|
"price",
|
||||||
"profit",
|
"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:
|
def quote_sqlite_identifier(identifier: str) -> str:
|
||||||
@@ -62,11 +89,12 @@ def resolve_history_datasets(datasets: set[Dataset] | None) -> set[Dataset]:
|
|||||||
"""Resolve configured history datasets.
|
"""Resolve configured history datasets.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
All supported datasets when ``datasets`` is None, otherwise the
|
``DEFAULT_HISTORY_DATASETS`` (rates, history-orders, history-deals)
|
||||||
configured selection (which may be empty).
|
when ``datasets`` is None, otherwise the configured selection (which
|
||||||
|
may be empty or explicitly include ``Dataset.ticks``).
|
||||||
"""
|
"""
|
||||||
if datasets is None:
|
if datasets is None:
|
||||||
return set(Dataset)
|
return set(DEFAULT_HISTORY_DATASETS)
|
||||||
return set(datasets)
|
return set(datasets)
|
||||||
|
|
||||||
|
|
||||||
@@ -218,6 +246,155 @@ def _open_existing_sqlite_database(
|
|||||||
return conn, True
|
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:
|
def _validate_rate_load_request(table: str, count: int | None) -> str:
|
||||||
table_name = _require_non_empty_identifier(table, "table or view")
|
table_name = _require_non_empty_identifier(table, "table or view")
|
||||||
if count is not None and count <= 0:
|
if count is not None and count <= 0:
|
||||||
@@ -871,6 +1048,72 @@ def parse_sqlite_timestamp(value: object) -> datetime | None:
|
|||||||
return 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(
|
def get_history_deals_account_event_start_datetime(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
*,
|
*,
|
||||||
@@ -887,10 +1130,11 @@ def get_history_deals_account_event_start_datetime(
|
|||||||
where_clause = "symbol IS NULL OR symbol = ''"
|
where_clause = "symbol IS NULL OR symbol = ''"
|
||||||
else:
|
else:
|
||||||
return fallback_start
|
return fallback_start
|
||||||
row = conn.execute(
|
parsed = _load_latest_parseable_time(
|
||||||
f"SELECT MAX(time) FROM {table} WHERE {where_clause}", # noqa: S608
|
conn,
|
||||||
).fetchone()
|
table,
|
||||||
parsed = parse_sqlite_timestamp(row[0] if row else None)
|
where_clause=where_clause,
|
||||||
|
)
|
||||||
return parsed if parsed is not None else fallback_start
|
return parsed if parsed is not None else fallback_start
|
||||||
|
|
||||||
|
|
||||||
@@ -913,6 +1157,68 @@ def _validate_rates_schema(columns: set[str]) -> None:
|
|||||||
raise ValueError(msg)
|
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(
|
def load_incremental_start_datetimes(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
dataset: Dataset,
|
dataset: Dataset,
|
||||||
@@ -936,55 +1242,28 @@ def load_incremental_start_datetimes(
|
|||||||
}
|
}
|
||||||
return {(symbol, None): fallback_start for symbol in symbols}
|
return {(symbol, None): fallback_start for symbol in symbols}
|
||||||
|
|
||||||
parsed_by_key: dict[tuple[str, int | None], datetime] = {}
|
|
||||||
if (
|
if (
|
||||||
dataset is Dataset.rates
|
dataset is Dataset.rates
|
||||||
and timeframes is not None
|
and timeframes is not None
|
||||||
and {"symbol", "timeframe"}.issubset(columns)
|
and {"symbol", "timeframe"}.issubset(columns)
|
||||||
):
|
):
|
||||||
symbol_placeholders = ", ".join("?" for _ in symbols)
|
return _load_grouped_rate_start_datetimes(
|
||||||
timeframe_placeholders = ", ".join("?" for _ in timeframes)
|
conn,
|
||||||
grouped_rates_query = (
|
table,
|
||||||
"SELECT symbol, timeframe, MAX(time) FROM " # noqa: S608
|
symbols=symbols,
|
||||||
f"{table} WHERE symbol IN ({symbol_placeholders})"
|
timeframes=timeframes,
|
||||||
f" AND timeframe IN ({timeframe_placeholders})"
|
fallback_start=fallback_start,
|
||||||
" GROUP BY symbol, timeframe"
|
|
||||||
)
|
)
|
||||||
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:
|
if "symbol" in columns:
|
||||||
symbol_placeholders = ", ".join("?" for _ in symbols)
|
return _load_symbol_start_datetimes(
|
||||||
rows = conn.execute(
|
conn,
|
||||||
f"SELECT symbol, MAX(time) FROM {table}" # noqa: S608
|
table,
|
||||||
f" WHERE symbol IN ({symbol_placeholders}) GROUP BY symbol",
|
symbols=symbols,
|
||||||
list(symbols),
|
fallback_start=fallback_start,
|
||||||
).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
|
|
||||||
}
|
|
||||||
|
|
||||||
row = conn.execute(f"SELECT MAX(time) FROM {table}").fetchone() # noqa: S608
|
parsed = _load_latest_parseable_time(conn, table)
|
||||||
parsed = parse_sqlite_timestamp(row[0] if row else None)
|
|
||||||
shared_start = parsed if parsed is not None else fallback_start
|
shared_start = parsed if parsed is not None else fallback_start
|
||||||
return {(symbol, None): shared_start for symbol in symbols}
|
return {(symbol, None): shared_start for symbol in symbols}
|
||||||
|
|
||||||
@@ -1023,7 +1302,8 @@ def append_dataframe(
|
|||||||
if len(frame.columns) == 0:
|
if len(frame.columns) == 0:
|
||||||
logger.warning("Skipping %s: dataset returned no columns", table_name)
|
logger.warning("Skipping %s: dataset returned no columns", table_name)
|
||||||
return False
|
return False
|
||||||
frame.to_sql( # type: ignore[reportUnknownMemberType]
|
writable = _canonicalize_sqlite_time_columns(frame)
|
||||||
|
writable.to_sql( # type: ignore[reportUnknownMemberType]
|
||||||
table_name,
|
table_name,
|
||||||
conn,
|
conn,
|
||||||
if_exists=if_exists.value,
|
if_exists=if_exists.value,
|
||||||
@@ -1102,15 +1382,21 @@ def drop_duplicates_in_table(
|
|||||||
if invalid := {column for column in ids if not column.isidentifier()}:
|
if invalid := {column for column in ids if not column.isidentifier()}:
|
||||||
msg = f"Invalid column names: {', '.join(sorted(invalid))}"
|
msg = f"Invalid column names: {', '.join(sorted(invalid))}"
|
||||||
raise ValueError(msg)
|
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"
|
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:
|
if scope_where:
|
||||||
delete_sql = (
|
delete_sql = (
|
||||||
f"DELETE FROM {table} WHERE {scope_where} AND ROWID NOT IN" # noqa: S608
|
f"DELETE FROM {table} WHERE {scope_where} AND ROWID NOT IN" # noqa: S608
|
||||||
f" (SELECT {rowid_selector}(ROWID) FROM {table} WHERE {scope_where}"
|
f" (SELECT {rowid_selector}(ROWID) FROM {table} WHERE {scope_where}"
|
||||||
f" GROUP BY {ids_csv})"
|
f" GROUP BY {ids_csv})"
|
||||||
)
|
)
|
||||||
cursor.execute(delete_sql, scope_params + scope_params)
|
cursor.execute(delete_sql, prepared_scope_params + prepared_scope_params)
|
||||||
return
|
return
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
f"DELETE FROM {table} WHERE ROWID NOT IN" # noqa: S608
|
f"DELETE FROM {table} WHERE ROWID NOT IN" # noqa: S608
|
||||||
@@ -1430,11 +1716,12 @@ def _record_symbol_time_dedup(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Record a symbol-scoped deduplication window after an incremental write."""
|
"""Record a symbol-scoped deduplication window after an incremental write."""
|
||||||
written_tables.add(dataset)
|
written_tables.add(dataset)
|
||||||
|
time_expr = _sqlite_normalized_time_expression("time")
|
||||||
_record_dedup_scope(
|
_record_dedup_scope(
|
||||||
dedup_scopes,
|
dedup_scopes,
|
||||||
dataset,
|
dataset,
|
||||||
"symbol = ? AND time >= ?",
|
f"symbol = ? AND {time_expr} >= ?",
|
||||||
(symbol, start_date),
|
(symbol, _require_serialized_sqlite_timestamp(start_date)),
|
||||||
frozenset({"symbol", "time"}),
|
frozenset({"symbol", "time"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1596,11 +1883,16 @@ def _write_incremental_rates(
|
|||||||
written_columns,
|
written_columns,
|
||||||
):
|
):
|
||||||
written_tables.add(Dataset.rates)
|
written_tables.add(Dataset.rates)
|
||||||
|
time_expr = _sqlite_normalized_time_expression("time")
|
||||||
_record_dedup_scope(
|
_record_dedup_scope(
|
||||||
dedup_scopes,
|
dedup_scopes,
|
||||||
Dataset.rates,
|
Dataset.rates,
|
||||||
"symbol = ? AND timeframe = ? AND time >= ?",
|
f"symbol = ? AND timeframe = ? AND {time_expr} >= ?",
|
||||||
(symbol, timeframe, start_date),
|
(
|
||||||
|
symbol,
|
||||||
|
timeframe,
|
||||||
|
_require_serialized_sqlite_timestamp(start_date),
|
||||||
|
),
|
||||||
frozenset({"symbol", "timeframe", "time"}),
|
frozenset({"symbol", "timeframe", "time"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1724,29 +2016,35 @@ def _write_incremental_history_deals(
|
|||||||
):
|
):
|
||||||
written_tables.add(Dataset.history_deals)
|
written_tables.add(Dataset.history_deals)
|
||||||
columns = get_table_columns(conn, Dataset.history_deals.table_name)
|
columns = get_table_columns(conn, Dataset.history_deals.table_name)
|
||||||
|
time_expr = _sqlite_normalized_time_expression("time")
|
||||||
if "symbol" in columns:
|
if "symbol" in columns:
|
||||||
for symbol in symbols:
|
for symbol in symbols:
|
||||||
_record_dedup_scope(
|
_record_dedup_scope(
|
||||||
dedup_scopes,
|
dedup_scopes,
|
||||||
Dataset.history_deals,
|
Dataset.history_deals,
|
||||||
"symbol = ? AND time >= ?",
|
f"symbol = ? AND {time_expr} >= ?",
|
||||||
(symbol, start_by_symbol[symbol, None]),
|
(
|
||||||
|
symbol,
|
||||||
|
_require_serialized_sqlite_timestamp(
|
||||||
|
start_by_symbol[symbol, None]
|
||||||
|
),
|
||||||
|
),
|
||||||
frozenset({"symbol", "time"}),
|
frozenset({"symbol", "time"}),
|
||||||
)
|
)
|
||||||
if "type" in columns:
|
if "type" in columns:
|
||||||
_record_dedup_scope(
|
_record_dedup_scope(
|
||||||
dedup_scopes,
|
dedup_scopes,
|
||||||
Dataset.history_deals,
|
Dataset.history_deals,
|
||||||
f"type NOT IN {_TRADE_DEAL_TYPES_SQL} AND time >= ?",
|
f"type NOT IN {_TRADE_DEAL_TYPES_SQL} AND {time_expr} >= ?",
|
||||||
(account_event_start,),
|
(_require_serialized_sqlite_timestamp(account_event_start),),
|
||||||
frozenset({"type", "time"}),
|
frozenset({"type", "time"}),
|
||||||
)
|
)
|
||||||
if "type" not in columns and "symbol" in columns:
|
if "type" not in columns and "symbol" in columns:
|
||||||
_record_dedup_scope(
|
_record_dedup_scope(
|
||||||
dedup_scopes,
|
dedup_scopes,
|
||||||
Dataset.history_deals,
|
Dataset.history_deals,
|
||||||
"(symbol IS NULL OR symbol = '') AND time >= ?",
|
f"(symbol IS NULL OR symbol = '') AND {time_expr} >= ?",
|
||||||
(account_event_start,),
|
(_require_serialized_sqlite_timestamp(account_event_start),),
|
||||||
frozenset({"symbol", "time"}),
|
frozenset({"symbol", "time"}),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|||||||
+2
-3
@@ -29,9 +29,8 @@ def retry_with_backoff(
|
|||||||
) -> T:
|
) -> T:
|
||||||
"""Call ``fn`` with bounded exponential backoff on recoverable MT5 errors.
|
"""Call ``fn`` with bounded exponential backoff on recoverable MT5 errors.
|
||||||
|
|
||||||
Only ``pdmt5.Mt5RuntimeError`` and ``pdmt5.Mt5TradingError`` are retried.
|
Only ``pdmt5.Mt5RuntimeError`` is retried. Other exceptions propagate
|
||||||
Other exceptions propagate immediately. The final failure is re-raised once
|
immediately. The final failure is re-raised once retries are exhausted.
|
||||||
retries are exhausted.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
fn: Callable performing MT5 work.
|
fn: Callable performing MT5 work.
|
||||||
|
|||||||
+360
-37
@@ -8,20 +8,32 @@ import os
|
|||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
from contextlib import contextmanager
|
from contextlib import closing, contextmanager
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Self, TypeVar, cast
|
from typing import TYPE_CHECKING, Self, TypeVar, cast
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from pdmt5 import Mt5Config, Mt5DataClient, Mt5RuntimeError, Mt5TradingError
|
from pdmt5 import Mt5Config, Mt5DataClient, Mt5RuntimeError
|
||||||
|
from pydantic import SecretStr
|
||||||
|
|
||||||
|
from .grafana import (
|
||||||
|
create_snapshot_tables,
|
||||||
|
ensure_grafana_schema,
|
||||||
|
insert_account_snapshot,
|
||||||
|
insert_order_snapshots,
|
||||||
|
insert_position_snapshots,
|
||||||
|
insert_terminal_snapshot,
|
||||||
|
record_snapshot_run,
|
||||||
|
start_snapshot_run,
|
||||||
|
)
|
||||||
from .history import (
|
from .history import (
|
||||||
create_cash_events_view,
|
create_cash_events_view,
|
||||||
create_history_indexes,
|
create_history_indexes,
|
||||||
create_positions_reconstructed_view,
|
create_positions_reconstructed_view,
|
||||||
drop_forming_rate_bar,
|
drop_forming_rate_bar,
|
||||||
|
report_rate_gaps,
|
||||||
resolve_granularity_name,
|
resolve_granularity_name,
|
||||||
resolve_history_datasets,
|
resolve_history_datasets,
|
||||||
resolve_history_tick_flags,
|
resolve_history_tick_flags,
|
||||||
@@ -30,6 +42,7 @@ from .history import (
|
|||||||
write_incremental_datasets,
|
write_incremental_datasets,
|
||||||
)
|
)
|
||||||
from .retry import retry_with_backoff
|
from .retry import retry_with_backoff
|
||||||
|
from .telemetry import get_metrics
|
||||||
from .utils import (
|
from .utils import (
|
||||||
Dataset,
|
Dataset,
|
||||||
IfExists,
|
IfExists,
|
||||||
@@ -40,7 +53,7 @@ from .utils import (
|
|||||||
from .utils import coerce_login as _coerce_login
|
from .utils import coerce_login as _coerce_login
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable, Iterator, Sequence
|
from collections.abc import Callable, Collection, Iterator, Sequence
|
||||||
|
|
||||||
UpdateHistoryBackend = Callable[..., None]
|
UpdateHistoryBackend = Callable[..., None]
|
||||||
|
|
||||||
@@ -49,7 +62,6 @@ T = TypeVar("T")
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_RECOVERABLE_HISTORY_UPDATE_ERRORS: tuple[type[BaseException], ...] = (
|
_RECOVERABLE_HISTORY_UPDATE_ERRORS: tuple[type[BaseException], ...] = (
|
||||||
Mt5TradingError,
|
|
||||||
Mt5RuntimeError,
|
Mt5RuntimeError,
|
||||||
sqlite3.Error,
|
sqlite3.Error,
|
||||||
ValueError,
|
ValueError,
|
||||||
@@ -139,15 +151,19 @@ __all__ = [
|
|||||||
"positions",
|
"positions",
|
||||||
"recent_history_deals",
|
"recent_history_deals",
|
||||||
"recent_ticks",
|
"recent_ticks",
|
||||||
|
"report_rate_gaps",
|
||||||
"resolve_account_spec",
|
"resolve_account_spec",
|
||||||
"resolve_account_specs",
|
"resolve_account_specs",
|
||||||
"substitute_env_placeholders",
|
"substitute_env_placeholders",
|
||||||
|
"substitute_mapping_values",
|
||||||
"symbol_info",
|
"symbol_info",
|
||||||
"symbol_info_tick",
|
"symbol_info_tick",
|
||||||
"symbols",
|
"symbols",
|
||||||
"terminal_info",
|
"terminal_info",
|
||||||
"update_history",
|
"update_history",
|
||||||
"update_history_with_config",
|
"update_history_with_config",
|
||||||
|
"update_observability",
|
||||||
|
"update_observability_with_config",
|
||||||
"version",
|
"version",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -305,7 +321,7 @@ def _fetch_minimum_margins(client: Mt5DataClient, symbol: str) -> pd.DataFrame:
|
|||||||
def build_config(
|
def build_config(
|
||||||
*,
|
*,
|
||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
login: int | None = None,
|
login: int | str | None = None,
|
||||||
password: str | None = None,
|
password: str | None = None,
|
||||||
server: str | None = None,
|
server: str | None = None,
|
||||||
timeout: int | None = None,
|
timeout: int | None = None,
|
||||||
@@ -315,14 +331,19 @@ def build_config(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
path: Optional terminal executable path.
|
path: Optional terminal executable path.
|
||||||
login: Optional trading account login.
|
login: Optional trading account login. Integers are preserved. String
|
||||||
|
values are coerced: empty or whitespace-only strings become
|
||||||
|
``None``; numeric strings such as ``"12345"`` are converted to
|
||||||
|
``int``; non-numeric strings raise ``ValueError``. When
|
||||||
|
``allow_whole_dollar_env=True``, ``$ENV_NAME`` and
|
||||||
|
``${ENV_NAME}`` placeholders are expanded before coercion.
|
||||||
password: Optional trading account password.
|
password: Optional trading account password.
|
||||||
server: Optional trading server name.
|
server: Optional trading server name.
|
||||||
timeout: Optional connection timeout in milliseconds.
|
timeout: Optional connection timeout in milliseconds.
|
||||||
allow_whole_dollar_env: When ``True``, string parameters that are
|
allow_whole_dollar_env: When ``True``, string parameters that are
|
||||||
exactly ``$ENV_NAME`` are expanded from the environment. Applies
|
exactly ``$ENV_NAME`` are expanded from the environment. Applies
|
||||||
to ``path``, ``password``, and ``server``. Default ``False``
|
to ``path``, ``login``, ``password``, and ``server``. Default
|
||||||
preserves existing behavior.
|
``False`` preserves existing behavior.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Configured ``Mt5Config`` instance.
|
Configured ``Mt5Config`` instance.
|
||||||
@@ -330,6 +351,8 @@ def build_config(
|
|||||||
if allow_whole_dollar_env:
|
if allow_whole_dollar_env:
|
||||||
if path is not None:
|
if path is not None:
|
||||||
path = substitute_env_placeholders(path, allow_whole_dollar_env=True)
|
path = substitute_env_placeholders(path, allow_whole_dollar_env=True)
|
||||||
|
if isinstance(login, str):
|
||||||
|
login = substitute_env_placeholders(login, allow_whole_dollar_env=True)
|
||||||
if password is not None:
|
if password is not None:
|
||||||
password = substitute_env_placeholders(
|
password = substitute_env_placeholders(
|
||||||
password, allow_whole_dollar_env=True
|
password, allow_whole_dollar_env=True
|
||||||
@@ -338,7 +361,7 @@ def build_config(
|
|||||||
server = substitute_env_placeholders(server, allow_whole_dollar_env=True)
|
server = substitute_env_placeholders(server, allow_whole_dollar_env=True)
|
||||||
return Mt5Config(
|
return Mt5Config(
|
||||||
path=path,
|
path=path,
|
||||||
login=login,
|
login=_coerce_login(login),
|
||||||
password=password,
|
password=password,
|
||||||
server=server,
|
server=server,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
@@ -968,7 +991,8 @@ def update_history( # noqa: PLR0913
|
|||||||
client: Connected MT5 data client.
|
client: Connected MT5 data client.
|
||||||
output: SQLite database path.
|
output: SQLite database path.
|
||||||
symbols: Symbols to update.
|
symbols: Symbols to update.
|
||||||
datasets: Datasets to include (defaults to all).
|
datasets: Datasets to include (defaults to rates, history-orders,
|
||||||
|
history-deals; pass ``{Dataset.ticks}`` to opt in to ticks).
|
||||||
timeframes: Rate timeframes to update (defaults to all fixed MT5
|
timeframes: Rate timeframes to update (defaults to all fixed MT5
|
||||||
timeframes when None).
|
timeframes when None).
|
||||||
flags: Tick copy flags as integer or name (e.g. ``ALL``).
|
flags: Tick copy flags as integer or name (e.g. ``ALL``).
|
||||||
@@ -997,23 +1021,27 @@ def update_history( # noqa: PLR0913
|
|||||||
sorted(dataset.value for dataset in request.selected),
|
sorted(dataset.value for dataset in request.selected),
|
||||||
request.output_path,
|
request.output_path,
|
||||||
)
|
)
|
||||||
with sqlite3.connect(request.output_path) as conn:
|
with closing(sqlite3.connect(request.output_path)) as conn, conn:
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute("PRAGMA synchronous=NORMAL")
|
conn.execute("PRAGMA synchronous=NORMAL")
|
||||||
write_incremental_datasets(
|
m = get_metrics()
|
||||||
conn,
|
with m.record_history_update(dataset="history"):
|
||||||
client,
|
before = conn.total_changes
|
||||||
symbols,
|
write_incremental_datasets(
|
||||||
request.selected,
|
conn,
|
||||||
request.resolved_timeframes,
|
client,
|
||||||
request.resolved_tick_flags,
|
symbols,
|
||||||
request.fallback_start,
|
request.selected,
|
||||||
request.end,
|
request.resolved_timeframes,
|
||||||
deduplicate=deduplicate,
|
request.resolved_tick_flags,
|
||||||
create_rate_views=create_rate_views,
|
request.fallback_start,
|
||||||
with_views=with_views,
|
request.end,
|
||||||
include_account_events=include_account_events,
|
deduplicate=deduplicate,
|
||||||
)
|
create_rate_views=create_rate_views,
|
||||||
|
with_views=with_views,
|
||||||
|
include_account_events=include_account_events,
|
||||||
|
)
|
||||||
|
m.add_history_rows(conn.total_changes - before, dataset="history")
|
||||||
|
|
||||||
|
|
||||||
def update_history_with_config( # noqa: PLR0913
|
def update_history_with_config( # noqa: PLR0913
|
||||||
@@ -1097,7 +1125,8 @@ class ThrottledHistoryUpdater:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
output: SQLite database path.
|
output: SQLite database path.
|
||||||
datasets: Datasets to include (defaults to all).
|
datasets: Datasets to include (defaults to rates, history-orders,
|
||||||
|
history-deals; pass ``{Dataset.ticks}`` to opt in to ticks).
|
||||||
timeframes: Rate timeframes to update (defaults to all fixed MT5
|
timeframes: Rate timeframes to update (defaults to all fixed MT5
|
||||||
timeframes).
|
timeframes).
|
||||||
flags: Tick copy flags as integer or name (e.g. ``ALL``).
|
flags: Tick copy flags as integer or name (e.g. ``ALL``).
|
||||||
@@ -1107,8 +1136,8 @@ class ThrottledHistoryUpdater:
|
|||||||
include_account_events: Include account-level cash events.
|
include_account_events: Include account-level cash events.
|
||||||
interval_seconds: Minimum seconds between successful updates. Values
|
interval_seconds: Minimum seconds between successful updates. Values
|
||||||
``<= 0`` update on every call.
|
``<= 0`` update on every call.
|
||||||
suppress_errors: When True, recoverable errors (``Mt5TradingError``,
|
suppress_errors: When True, recoverable errors (``Mt5RuntimeError``,
|
||||||
``Mt5RuntimeError``, ``sqlite3.Error``, ``ValueError``,
|
``sqlite3.Error``, ``ValueError``,
|
||||||
``OSError``, and MT5 client capability ``AttributeError`` /
|
``OSError``, and MT5 client capability ``AttributeError`` /
|
||||||
``TypeError`` for history API methods) raised during an update
|
``TypeError`` for history API methods) raised during an update
|
||||||
are swallowed and :meth:`update` returns False without advancing
|
are swallowed and :meth:`update` returns False without advancing
|
||||||
@@ -1229,7 +1258,8 @@ def collect_history(
|
|||||||
symbols: Symbols to collect.
|
symbols: Symbols to collect.
|
||||||
date_from: Start date.
|
date_from: Start date.
|
||||||
date_to: End date.
|
date_to: End date.
|
||||||
datasets: Datasets to include (defaults to all).
|
datasets: Datasets to include (defaults to rates, history-orders,
|
||||||
|
history-deals; pass ``{Dataset.ticks}`` to opt in to ticks).
|
||||||
timeframe: Rates timeframe as integer or name (e.g. ``M1``).
|
timeframe: Rates timeframe as integer or name (e.g. ``M1``).
|
||||||
flags: Tick copy flags as integer or name (e.g. ``ALL``).
|
flags: Tick copy flags as integer or name (e.g. ``ALL``).
|
||||||
if_exists: Behavior when a target table already exists.
|
if_exists: Behavior when a target table already exists.
|
||||||
@@ -1238,11 +1268,15 @@ def collect_history(
|
|||||||
"""
|
"""
|
||||||
start = _require_datetime(date_from)
|
start = _require_datetime(date_from)
|
||||||
end = _require_datetime(date_to)
|
end = _require_datetime(date_to)
|
||||||
selected = datasets if datasets is not None else set(Dataset)
|
selected = resolve_history_datasets(datasets)
|
||||||
tf = _coerce_timeframe(timeframe)
|
tf = _coerce_timeframe(timeframe)
|
||||||
tick_flags = _coerce_tick_flags(flags)
|
tick_flags = _coerce_tick_flags(flags)
|
||||||
mt5_config = config or build_config()
|
mt5_config = config or build_config()
|
||||||
with connected_client(mt5_config) as client, sqlite3.connect(output) as conn:
|
with (
|
||||||
|
connected_client(mt5_config) as client,
|
||||||
|
closing(sqlite3.connect(output)) as conn,
|
||||||
|
conn,
|
||||||
|
):
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute("PRAGMA synchronous=NORMAL")
|
conn.execute("PRAGMA synchronous=NORMAL")
|
||||||
written_tables, written_columns = write_collected_datasets(
|
written_tables, written_columns = write_collected_datasets(
|
||||||
@@ -1442,6 +1476,75 @@ def substitute_env_placeholders(
|
|||||||
return "".join(parts)
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def substitute_mapping_values(
|
||||||
|
data: object,
|
||||||
|
*,
|
||||||
|
keys: Collection[str],
|
||||||
|
allow_whole_dollar_env: bool = False,
|
||||||
|
blank_string_keys_as_none: Collection[str] = (),
|
||||||
|
) -> object:
|
||||||
|
"""Recursively substitute environment placeholders for selected mapping keys.
|
||||||
|
|
||||||
|
Traverses nested dicts and lists, expanding ``${ENV_VAR}`` (and
|
||||||
|
``$ENV_NAME`` when ``allow_whole_dollar_env=True``) in string values
|
||||||
|
whose immediate parent dict key is in ``keys``. Fields whose key is
|
||||||
|
not in ``keys`` are preserved exactly, including literal dollar signs.
|
||||||
|
Strings that are direct elements of a list are never substituted;
|
||||||
|
substitution only applies to strings that are immediate dict values.
|
||||||
|
|
||||||
|
This is a generic downstream config utility. Key names such as
|
||||||
|
``mt5_login`` or ``mt5_password`` must be supplied by the caller;
|
||||||
|
mt5cli does not hard-code any application-specific key names.
|
||||||
|
Callers are responsible for ensuring ``data`` has bounded nesting depth;
|
||||||
|
deeply nested or self-referential structures will hit Python's recursion
|
||||||
|
limit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Arbitrarily nested dict/list/scalar value to process.
|
||||||
|
keys: Mapping keys whose string values receive placeholder
|
||||||
|
substitution.
|
||||||
|
allow_whole_dollar_env: When ``True``, a string that is exactly
|
||||||
|
``$ENV_NAME`` (whole value) is also expanded from the
|
||||||
|
environment in addition to ``${ENV_NAME}`` placeholders.
|
||||||
|
Default ``False`` expands ``${ENV_NAME}`` only.
|
||||||
|
blank_string_keys_as_none: Mapping keys for which blank strings
|
||||||
|
(after any substitution) are normalised to ``None``. A key
|
||||||
|
may appear in ``blank_string_keys_as_none`` without also
|
||||||
|
appearing in ``keys``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The processed value. Dicts and lists are rebuilt into new
|
||||||
|
containers with selected string values substituted and
|
||||||
|
blank-normalised. Scalar inputs (non-dict, non-list) are
|
||||||
|
returned as-is.
|
||||||
|
"""
|
||||||
|
keys_set: frozenset[str] = frozenset(keys)
|
||||||
|
blank_keys_set: frozenset[str] = frozenset(blank_string_keys_as_none)
|
||||||
|
|
||||||
|
def _visit(node: object, current_key: str | None) -> object:
|
||||||
|
if isinstance(node, dict):
|
||||||
|
typed = cast("dict[object, object]", node)
|
||||||
|
return {
|
||||||
|
k: _visit(v, k if isinstance(k, str) else None)
|
||||||
|
for k, v in typed.items()
|
||||||
|
}
|
||||||
|
if isinstance(node, list):
|
||||||
|
typed_list = cast("list[object]", node)
|
||||||
|
return [_visit(item, None) for item in typed_list]
|
||||||
|
if not isinstance(node, str):
|
||||||
|
return node
|
||||||
|
text = node
|
||||||
|
if current_key in keys_set:
|
||||||
|
text = substitute_env_placeholders(
|
||||||
|
node, allow_whole_dollar_env=allow_whole_dollar_env
|
||||||
|
)
|
||||||
|
if current_key in blank_keys_set and not text.strip():
|
||||||
|
return None
|
||||||
|
return text
|
||||||
|
|
||||||
|
return _visit(data, None)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_field(
|
def _resolve_field(
|
||||||
override: str | None,
|
override: str | None,
|
||||||
account_value: str | None,
|
account_value: str | None,
|
||||||
@@ -1597,10 +1700,13 @@ def _build_account_config(
|
|||||||
login = _coerce_login(account.login)
|
login = _coerce_login(account.login)
|
||||||
if login is None and base_config is not None:
|
if login is None and base_config is not None:
|
||||||
login = base_config.login
|
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(
|
return build_config(
|
||||||
path=account.path or (base_config.path if base_config else None),
|
path=account.path or (base_config.path if base_config else None),
|
||||||
login=login,
|
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),
|
server=account.server or (base_config.server if base_config else None),
|
||||||
timeout=account.timeout
|
timeout=account.timeout
|
||||||
if account.timeout is not None
|
if account.timeout is not None
|
||||||
@@ -1676,9 +1782,9 @@ def collect_latest_rates_for_accounts_with_retries(
|
|||||||
"""Collect latest rates across accounts, retrying transient MT5 failures.
|
"""Collect latest rates across accounts, retrying transient MT5 failures.
|
||||||
|
|
||||||
Wraps :func:`collect_latest_rates_for_accounts` with bounded exponential
|
Wraps :func:`collect_latest_rates_for_accounts` with bounded exponential
|
||||||
backoff. Only ``pdmt5.Mt5TradingError`` and ``pdmt5.Mt5RuntimeError`` are
|
backoff. Only ``pdmt5.Mt5RuntimeError`` is retried; other exceptions
|
||||||
retried; other exceptions propagate immediately. The final failure is
|
propagate immediately. The final failure is re-raised once retries are
|
||||||
re-raised once retries are exhausted.
|
exhausted.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
accounts: Account groups to read. Each must define at least one symbol.
|
accounts: Account groups to read. Each must define at least one symbol.
|
||||||
@@ -1695,8 +1801,8 @@ def collect_latest_rates_for_accounts_with_retries(
|
|||||||
Returns:
|
Returns:
|
||||||
Mapping keyed by ``(symbol, timeframe_int)``. Propagates ``ValueError``
|
Mapping keyed by ``(symbol, timeframe_int)``. Propagates ``ValueError``
|
||||||
for invalid inputs (see :func:`collect_latest_rates_for_accounts`) and
|
for invalid inputs (see :func:`collect_latest_rates_for_accounts`) and
|
||||||
re-raises the last ``pdmt5.Mt5TradingError`` or ``pdmt5.Mt5RuntimeError``
|
re-raises the last ``pdmt5.Mt5RuntimeError`` once retries are
|
||||||
once retries are exhausted.
|
exhausted.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _collect() -> dict[tuple[str, int], pd.DataFrame]:
|
def _collect() -> dict[tuple[str, int], pd.DataFrame]:
|
||||||
@@ -2063,3 +2169,220 @@ def mt5_summary(*, config: Mt5Config | None = None) -> dict[str, object]:
|
|||||||
def mt5_summary_as_df(*, config: Mt5Config | None = None) -> pd.DataFrame:
|
def mt5_summary_as_df(*, config: Mt5Config | None = None) -> pd.DataFrame:
|
||||||
"""Return an export-safe terminal/account status summary DataFrame."""
|
"""Return an export-safe terminal/account status summary DataFrame."""
|
||||||
return _make_client(config=config).mt5_summary_as_df()
|
return _make_client(config=config).mt5_summary_as_df()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Observability: account / position / order / terminal snapshots
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_account_metrics(row: dict[str, object]) -> None:
|
||||||
|
login = str(row.get("login", ""))
|
||||||
|
server = str(row.get("server", ""))
|
||||||
|
get_metrics().record_account_state(
|
||||||
|
login=login,
|
||||||
|
server=server,
|
||||||
|
balance=float(row.get("balance") or 0.0), # type: ignore[arg-type]
|
||||||
|
equity=float(row.get("equity") or 0.0), # type: ignore[arg-type]
|
||||||
|
margin=float(row.get("margin") or 0.0), # type: ignore[arg-type]
|
||||||
|
margin_free=float(row.get("margin_free") or 0.0), # type: ignore[arg-type]
|
||||||
|
margin_level=float(row.get("margin_level") or 0.0), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_position_metrics(
|
||||||
|
rows: list[dict[str, object]],
|
||||||
|
login: int | None,
|
||||||
|
) -> None:
|
||||||
|
m = get_metrics()
|
||||||
|
login_str = str(login) if login is not None else ""
|
||||||
|
# Aggregate profit and volume by symbol so hedging accounts (multiple open
|
||||||
|
# positions sharing the same symbol) emit a single gauge value per symbol
|
||||||
|
# instead of overwriting with each row's value.
|
||||||
|
totals: dict[str, tuple[float, float]] = {}
|
||||||
|
for r in rows:
|
||||||
|
symbol = str(r.get("symbol", ""))
|
||||||
|
profit = float(r.get("profit") or 0.0) # type: ignore[arg-type]
|
||||||
|
volume = float(r.get("volume") or 0.0) # type: ignore[arg-type]
|
||||||
|
if symbol in totals:
|
||||||
|
prev_p, prev_v = totals[symbol]
|
||||||
|
totals[symbol] = (prev_p + profit, prev_v + volume)
|
||||||
|
else:
|
||||||
|
totals[symbol] = (profit, volume)
|
||||||
|
for symbol, (profit, volume) in totals.items():
|
||||||
|
m.record_position_state(
|
||||||
|
login=login_str,
|
||||||
|
server="",
|
||||||
|
symbol=symbol,
|
||||||
|
profit=profit,
|
||||||
|
volume=volume,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_account(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
client: Mt5DataClient,
|
||||||
|
run_id: int,
|
||||||
|
) -> int | None:
|
||||||
|
df = client.account_info_as_df()
|
||||||
|
if df.empty:
|
||||||
|
logger.warning(
|
||||||
|
"account_info_as_df returned empty frame; skipping account snapshot"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
row = cast("dict[str, object]", df.iloc[0].to_dict())
|
||||||
|
insert_account_snapshot(conn, run_id, row)
|
||||||
|
_emit_account_metrics(row)
|
||||||
|
login_val = row.get("login")
|
||||||
|
return int(login_val) if login_val is not None else None # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_positions(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
client: Mt5DataClient,
|
||||||
|
run_id: int,
|
||||||
|
login: int | None,
|
||||||
|
symbols: Sequence[str] | None,
|
||||||
|
) -> None:
|
||||||
|
df: pd.DataFrame = client.positions_get_as_df()
|
||||||
|
if symbols is not None and not df.empty and "symbol" in df.columns:
|
||||||
|
df = df[df["symbol"].isin(symbols)].reset_index(drop=True)
|
||||||
|
raw = df.to_dict(orient="records") if not df.empty else []
|
||||||
|
rows = cast("list[dict[str, object]]", raw)
|
||||||
|
insert_position_snapshots(conn, run_id, login, rows)
|
||||||
|
_emit_position_metrics(rows, login)
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_orders(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
client: Mt5DataClient,
|
||||||
|
run_id: int,
|
||||||
|
login: int | None,
|
||||||
|
symbols: Sequence[str] | None,
|
||||||
|
) -> None:
|
||||||
|
df: pd.DataFrame = client.orders_get_as_df()
|
||||||
|
if symbols is not None and not df.empty and "symbol" in df.columns:
|
||||||
|
df = df[df["symbol"].isin(symbols)].reset_index(drop=True)
|
||||||
|
raw = df.to_dict(orient="records") if not df.empty else []
|
||||||
|
rows = cast("list[dict[str, object]]", raw)
|
||||||
|
insert_order_snapshots(conn, run_id, login, rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_terminal_metrics(row: dict[str, object]) -> None:
|
||||||
|
get_metrics().record_terminal_state(
|
||||||
|
connected=float(row.get("connected") or 0.0), # type: ignore[arg-type]
|
||||||
|
trade_allowed=float(row.get("trade_allowed") or 0.0), # type: ignore[arg-type]
|
||||||
|
trade_expert=float(row.get("trade_expert") or 0.0), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_terminal(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
client: Mt5DataClient,
|
||||||
|
run_id: int,
|
||||||
|
) -> None:
|
||||||
|
df = client.terminal_info_as_df()
|
||||||
|
if df.empty:
|
||||||
|
logger.warning(
|
||||||
|
"terminal_info_as_df returned empty frame; skipping terminal snapshot"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
row = cast("dict[str, object]", df.iloc[0].to_dict())
|
||||||
|
insert_terminal_snapshot(conn, run_id, row)
|
||||||
|
_emit_terminal_metrics(row)
|
||||||
|
|
||||||
|
|
||||||
|
def update_observability(
|
||||||
|
*,
|
||||||
|
client: Mt5DataClient,
|
||||||
|
output: Path | str,
|
||||||
|
symbols: Sequence[str] | None = None,
|
||||||
|
include_account: bool = True,
|
||||||
|
include_positions: bool = True,
|
||||||
|
include_orders: bool = True,
|
||||||
|
include_terminal: bool = True,
|
||||||
|
with_grafana_schema: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Snapshot current account/position/order/terminal state into SQLite.
|
||||||
|
|
||||||
|
Reads the current MT5 state and appends timestamped snapshot rows. Never
|
||||||
|
places orders or modifies trading state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Connected MT5 data client.
|
||||||
|
output: SQLite database path.
|
||||||
|
symbols: Optional symbol filter for positions and orders. When None,
|
||||||
|
all positions and orders are snapshotted.
|
||||||
|
include_account: Snapshot account info into ``account_snapshots``.
|
||||||
|
include_positions: Snapshot open positions into ``position_snapshots``.
|
||||||
|
include_orders: Snapshot active orders into ``order_snapshots``.
|
||||||
|
include_terminal: Snapshot terminal info into ``terminal_snapshots``.
|
||||||
|
with_grafana_schema: Ensure Grafana views and indexes exist. Defaults
|
||||||
|
to ``False``; run ``grafana-schema`` once to set up the schema,
|
||||||
|
then use ``snapshot`` repeatedly without this flag.
|
||||||
|
"""
|
||||||
|
observed_at = int(datetime.now(UTC).timestamp())
|
||||||
|
with closing(sqlite3.connect(Path(output))) as conn, conn:
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA synchronous=NORMAL")
|
||||||
|
if with_grafana_schema:
|
||||||
|
ensure_grafana_schema(conn)
|
||||||
|
else:
|
||||||
|
create_snapshot_tables(conn)
|
||||||
|
with get_metrics().record_snapshot_update():
|
||||||
|
run_id = start_snapshot_run(conn, observed_at)
|
||||||
|
login: int | None = None
|
||||||
|
try:
|
||||||
|
if include_account:
|
||||||
|
login = _snapshot_account(conn, client, run_id)
|
||||||
|
if include_positions:
|
||||||
|
_snapshot_positions(conn, client, run_id, login, symbols)
|
||||||
|
if include_orders:
|
||||||
|
_snapshot_orders(conn, client, run_id, login, symbols)
|
||||||
|
if include_terminal:
|
||||||
|
_snapshot_terminal(conn, client, run_id)
|
||||||
|
record_snapshot_run(conn, run_id, "ok")
|
||||||
|
except Exception:
|
||||||
|
record_snapshot_run(conn, run_id, "error")
|
||||||
|
conn.commit()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def update_observability_with_config(
|
||||||
|
*,
|
||||||
|
output: Path | str,
|
||||||
|
config: Mt5Config | None = None,
|
||||||
|
symbols: Sequence[str] | None = None,
|
||||||
|
include_account: bool = True,
|
||||||
|
include_positions: bool = True,
|
||||||
|
include_orders: bool = True,
|
||||||
|
include_terminal: bool = True,
|
||||||
|
with_grafana_schema: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Snapshot current MT5 state, opening and closing the MT5 connection.
|
||||||
|
|
||||||
|
Convenience wrapper around :func:`update_observability` for standalone use.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output: SQLite database path.
|
||||||
|
config: MT5 connection configuration. Defaults to an empty config that
|
||||||
|
attaches to a running terminal.
|
||||||
|
symbols: Optional symbol filter for positions and orders.
|
||||||
|
include_account: Snapshot account info.
|
||||||
|
include_positions: Snapshot open positions.
|
||||||
|
include_orders: Snapshot active orders.
|
||||||
|
include_terminal: Snapshot terminal info.
|
||||||
|
with_grafana_schema: Ensure Grafana views and indexes exist.
|
||||||
|
"""
|
||||||
|
mt5_config = config or build_config()
|
||||||
|
with connected_client(mt5_config) as client:
|
||||||
|
update_observability(
|
||||||
|
client=client,
|
||||||
|
output=output,
|
||||||
|
symbols=symbols,
|
||||||
|
include_account=include_account,
|
||||||
|
include_positions=include_positions,
|
||||||
|
include_orders=include_orders,
|
||||||
|
include_terminal=include_terminal,
|
||||||
|
with_grafana_schema=with_grafana_schema,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
"""Generic storage helpers for MT5 market and account history."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .history import (
|
|
||||||
RateTarget,
|
|
||||||
build_rate_targets,
|
|
||||||
build_rate_view_name,
|
|
||||||
drop_forming_rate_bar,
|
|
||||||
load_rate_data,
|
|
||||||
load_rate_data_from_connection,
|
|
||||||
load_rate_series_by_granularity,
|
|
||||||
load_rate_series_from_sqlite,
|
|
||||||
resolve_rate_tables,
|
|
||||||
resolve_rate_view_name,
|
|
||||||
resolve_rate_view_names,
|
|
||||||
)
|
|
||||||
from .sdk import collect_history, update_history, update_history_with_config
|
|
||||||
from .utils import (
|
|
||||||
Dataset,
|
|
||||||
IfExists,
|
|
||||||
OutputFormat,
|
|
||||||
detect_format,
|
|
||||||
export_dataframe,
|
|
||||||
export_dataframe_to_sqlite,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"Dataset",
|
|
||||||
"IfExists",
|
|
||||||
"OutputFormat",
|
|
||||||
"RateTarget",
|
|
||||||
"build_rate_targets",
|
|
||||||
"build_rate_view_name",
|
|
||||||
"collect_history",
|
|
||||||
"detect_format",
|
|
||||||
"drop_forming_rate_bar",
|
|
||||||
"export_dataframe",
|
|
||||||
"export_dataframe_to_sqlite",
|
|
||||||
"load_rate_data",
|
|
||||||
"load_rate_data_from_connection",
|
|
||||||
"load_rate_series_by_granularity",
|
|
||||||
"load_rate_series_from_sqlite",
|
|
||||||
"resolve_rate_tables",
|
|
||||||
"resolve_rate_view_name",
|
|
||||||
"resolve_rate_view_names",
|
|
||||||
"update_history",
|
|
||||||
"update_history_with_config",
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
"""Optional OpenTelemetry metrics for MT5 history and snapshot observability."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_otel_available_flag = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
import opentelemetry.metrics as _otel_metrics_mod
|
||||||
|
from opentelemetry.sdk.metrics import MeterProvider as _OtelMeterProvider
|
||||||
|
from opentelemetry.sdk.metrics.export import (
|
||||||
|
PeriodicExportingMetricReader as _OtelPeriodicReader,
|
||||||
|
)
|
||||||
|
from opentelemetry.sdk.resources import Resource as _OtelResource
|
||||||
|
|
||||||
|
_otel_available_flag = True
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
_otel_metrics_mod = None # type: ignore[assignment]
|
||||||
|
_OtelMeterProvider = None # type: ignore[assignment]
|
||||||
|
_OtelPeriodicReader = None # type: ignore[assignment]
|
||||||
|
_OtelResource = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
_OTEL_AVAILABLE: bool = _otel_available_flag
|
||||||
|
|
||||||
|
try:
|
||||||
|
from opentelemetry.exporter.otlp.proto.http.metric_exporter import ( # type: ignore[import]
|
||||||
|
OTLPMetricExporter as _OtelOTLPExporter, # type: ignore[reportUnknownVariableType]
|
||||||
|
)
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
_OtelOTLPExporter = None # type: ignore[assignment, misc]
|
||||||
|
|
||||||
|
|
||||||
|
class _NoOp:
|
||||||
|
"""No-op instrument that silently ignores all calls."""
|
||||||
|
|
||||||
|
def add(
|
||||||
|
self,
|
||||||
|
amount: float,
|
||||||
|
attributes: dict[str, str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""No-op add."""
|
||||||
|
|
||||||
|
def set(
|
||||||
|
self,
|
||||||
|
amount: float,
|
||||||
|
attributes: dict[str, str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""No-op set."""
|
||||||
|
|
||||||
|
def record(
|
||||||
|
self,
|
||||||
|
amount: float,
|
||||||
|
attributes: dict[str, str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""No-op record."""
|
||||||
|
|
||||||
|
|
||||||
|
_NOOP: _NoOp = _NoOp()
|
||||||
|
|
||||||
|
|
||||||
|
class _Mt5Metrics:
|
||||||
|
"""MT5 metric instrument registry.
|
||||||
|
|
||||||
|
Holds references to OTel instruments. All instruments are no-op until
|
||||||
|
:meth:`configure` is called with a compatible meter object.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._history_duration: Any = _NOOP
|
||||||
|
self._history_rows: Any = _NOOP
|
||||||
|
self._history_failures: Any = _NOOP
|
||||||
|
self._snapshot_duration: Any = _NOOP
|
||||||
|
self._snapshot_failures: Any = _NOOP
|
||||||
|
self._account_balance: Any = _NOOP
|
||||||
|
self._account_equity: Any = _NOOP
|
||||||
|
self._account_margin: Any = _NOOP
|
||||||
|
self._account_margin_free: Any = _NOOP
|
||||||
|
self._account_margin_level: Any = _NOOP
|
||||||
|
self._position_profit: Any = _NOOP
|
||||||
|
self._position_volume: Any = _NOOP
|
||||||
|
self._terminal_connected: Any = _NOOP
|
||||||
|
self._terminal_trade_allowed: Any = _NOOP
|
||||||
|
self._terminal_trade_expert: Any = _NOOP
|
||||||
|
self._last_successful_update: Any = _NOOP
|
||||||
|
|
||||||
|
def configure(self, meter: Any) -> None: # noqa: ANN401
|
||||||
|
"""Set up metric instruments from a meter object.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
meter: An OpenTelemetry ``Meter`` or duck-typed compatible object
|
||||||
|
that supports ``create_counter``, ``create_histogram``, and
|
||||||
|
``create_gauge``.
|
||||||
|
"""
|
||||||
|
self._history_duration = meter.create_histogram(
|
||||||
|
"mt5_history_update_duration_seconds",
|
||||||
|
unit="s",
|
||||||
|
description="Duration of incremental history update operations.",
|
||||||
|
)
|
||||||
|
self._history_rows = meter.create_counter(
|
||||||
|
"mt5_history_update_rows_total",
|
||||||
|
description="Rows written during incremental history updates.",
|
||||||
|
)
|
||||||
|
self._history_failures = meter.create_counter(
|
||||||
|
"mt5_history_update_failures_total",
|
||||||
|
description="Number of incremental history update failures.",
|
||||||
|
)
|
||||||
|
self._snapshot_duration = meter.create_histogram(
|
||||||
|
"mt5_snapshot_update_duration_seconds",
|
||||||
|
unit="s",
|
||||||
|
description="Duration of snapshot update operations.",
|
||||||
|
)
|
||||||
|
self._snapshot_failures = meter.create_counter(
|
||||||
|
"mt5_snapshot_update_failures_total",
|
||||||
|
description="Number of snapshot update failures.",
|
||||||
|
)
|
||||||
|
self._account_balance = meter.create_gauge(
|
||||||
|
"mt5_account_balance",
|
||||||
|
description="Account balance.",
|
||||||
|
)
|
||||||
|
self._account_equity = meter.create_gauge(
|
||||||
|
"mt5_account_equity",
|
||||||
|
description="Account equity.",
|
||||||
|
)
|
||||||
|
self._account_margin = meter.create_gauge(
|
||||||
|
"mt5_account_margin",
|
||||||
|
description="Account margin used.",
|
||||||
|
)
|
||||||
|
self._account_margin_free = meter.create_gauge(
|
||||||
|
"mt5_account_margin_free",
|
||||||
|
description="Account free margin.",
|
||||||
|
)
|
||||||
|
self._account_margin_level = meter.create_gauge(
|
||||||
|
"mt5_account_margin_level",
|
||||||
|
description="Account margin level as a percentage.",
|
||||||
|
)
|
||||||
|
self._position_profit = meter.create_gauge(
|
||||||
|
"mt5_position_profit",
|
||||||
|
description="Floating profit for an open position.",
|
||||||
|
)
|
||||||
|
self._position_volume = meter.create_gauge(
|
||||||
|
"mt5_position_volume",
|
||||||
|
description="Volume of an open position.",
|
||||||
|
)
|
||||||
|
self._terminal_connected = meter.create_gauge(
|
||||||
|
"mt5_terminal_connected",
|
||||||
|
description="1 if the terminal is connected to the broker, 0 otherwise.",
|
||||||
|
)
|
||||||
|
self._terminal_trade_allowed = meter.create_gauge(
|
||||||
|
"mt5_terminal_trade_allowed",
|
||||||
|
description="1 if trading is allowed by the broker server, 0 otherwise.",
|
||||||
|
)
|
||||||
|
self._terminal_trade_expert = meter.create_gauge(
|
||||||
|
"mt5_terminal_trade_expert",
|
||||||
|
description="1 if Expert Advisor trading is enabled, 0 otherwise.",
|
||||||
|
)
|
||||||
|
self._last_successful_update = meter.create_gauge(
|
||||||
|
"mt5_last_successful_update_timestamp",
|
||||||
|
description="Unix timestamp of the last successful history update.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def record_history_update(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
dataset: str,
|
||||||
|
) -> Iterator[None]:
|
||||||
|
"""Context manager recording history update duration and failures.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dataset: Dataset label (e.g. ``"rates"``).
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
None inside the update operation.
|
||||||
|
"""
|
||||||
|
attrs = {"dataset": dataset}
|
||||||
|
start = time.monotonic()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
self._history_duration.record(time.monotonic() - start, attrs)
|
||||||
|
self._last_successful_update.set(time.time(), attrs)
|
||||||
|
except Exception:
|
||||||
|
self._history_failures.add(1, attrs)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def add_history_rows(self, count: int, *, dataset: str) -> None:
|
||||||
|
"""Increment the history rows-written counter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
count: Number of rows written during this update.
|
||||||
|
dataset: Dataset label (e.g. ``"rates"``).
|
||||||
|
"""
|
||||||
|
self._history_rows.add(count, {"dataset": dataset})
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def record_snapshot_update(self) -> Iterator[None]:
|
||||||
|
"""Context manager recording snapshot update duration and failures.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
None inside the snapshot operation.
|
||||||
|
"""
|
||||||
|
start = time.monotonic()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
self._snapshot_duration.record(time.monotonic() - start, {})
|
||||||
|
except Exception:
|
||||||
|
self._snapshot_failures.add(1, {})
|
||||||
|
raise
|
||||||
|
|
||||||
|
def record_account_state(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
login: str,
|
||||||
|
server: str,
|
||||||
|
balance: float,
|
||||||
|
equity: float,
|
||||||
|
margin: float,
|
||||||
|
margin_free: float,
|
||||||
|
margin_level: float,
|
||||||
|
) -> None:
|
||||||
|
"""Emit account metric gauges.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
login: Account login number (as string; not a password or secret).
|
||||||
|
server: Broker server name.
|
||||||
|
balance: Account balance.
|
||||||
|
equity: Account equity.
|
||||||
|
margin: Margin used.
|
||||||
|
margin_free: Free margin.
|
||||||
|
margin_level: Margin level percentage.
|
||||||
|
"""
|
||||||
|
attrs: dict[str, str] = {"login": login, "server": server}
|
||||||
|
self._account_balance.set(balance, attrs)
|
||||||
|
self._account_equity.set(equity, attrs)
|
||||||
|
self._account_margin.set(margin, attrs)
|
||||||
|
self._account_margin_free.set(margin_free, attrs)
|
||||||
|
self._account_margin_level.set(margin_level, attrs)
|
||||||
|
|
||||||
|
def record_position_state(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
login: str,
|
||||||
|
server: str,
|
||||||
|
symbol: str,
|
||||||
|
profit: float,
|
||||||
|
volume: float,
|
||||||
|
) -> None:
|
||||||
|
"""Emit position metric gauges.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
login: Account login number (as string).
|
||||||
|
server: Broker server name.
|
||||||
|
symbol: Position symbol.
|
||||||
|
profit: Floating profit/loss.
|
||||||
|
volume: Position volume.
|
||||||
|
"""
|
||||||
|
attrs: dict[str, str] = {"login": login, "server": server, "symbol": symbol}
|
||||||
|
self._position_profit.set(profit, attrs)
|
||||||
|
self._position_volume.set(volume, attrs)
|
||||||
|
|
||||||
|
def record_terminal_state(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
connected: float,
|
||||||
|
trade_allowed: float,
|
||||||
|
trade_expert: float,
|
||||||
|
) -> None:
|
||||||
|
"""Emit terminal connection and trading status gauges.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
connected: 1.0 if connected to the broker, 0.0 otherwise.
|
||||||
|
trade_allowed: 1.0 if broker server allows trading, 0.0 otherwise.
|
||||||
|
trade_expert: 1.0 if Expert Advisor trading is enabled, 0.0 otherwise.
|
||||||
|
"""
|
||||||
|
self._terminal_connected.set(connected, {})
|
||||||
|
self._terminal_trade_allowed.set(trade_allowed, {})
|
||||||
|
self._terminal_trade_expert.set(trade_expert, {})
|
||||||
|
|
||||||
|
|
||||||
|
_metrics = _Mt5Metrics()
|
||||||
|
|
||||||
|
|
||||||
|
def configure_metrics(meter: Any) -> None: # noqa: ANN401
|
||||||
|
"""Configure MT5 metrics using the provided meter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
meter: An OpenTelemetry ``Meter`` or duck-typed compatible object.
|
||||||
|
"""
|
||||||
|
_metrics.configure(meter)
|
||||||
|
|
||||||
|
|
||||||
|
def enable_otel_metrics(
|
||||||
|
service_name: str = "mt5cli",
|
||||||
|
readers: list[Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Enable OTel metrics by wiring up an SDK ``MeterProvider`` pipeline.
|
||||||
|
|
||||||
|
Requires the ``otel`` optional dependency group:
|
||||||
|
``pip install "mt5cli[otel]"``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service_name: OTel meter/service name used for the ``Resource`` and
|
||||||
|
the meter itself.
|
||||||
|
readers: Optional list of metric readers. When *None* (the default),
|
||||||
|
a :class:`~opentelemetry.sdk.metrics.export.PeriodicExportingMetricReader`
|
||||||
|
backed by an OTLP HTTP exporter is created automatically
|
||||||
|
(reads the endpoint from ``OTEL_EXPORTER_OTLP_ENDPOINT``).
|
||||||
|
Pass a custom list (e.g. ``InMemoryMetricReader`` for tests)
|
||||||
|
to override.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ImportError: If ``opentelemetry-api`` is not installed, or if
|
||||||
|
``readers`` is *None* and
|
||||||
|
``opentelemetry-exporter-otlp-proto-http`` is not installed.
|
||||||
|
"""
|
||||||
|
if not _OTEL_AVAILABLE:
|
||||||
|
msg = (
|
||||||
|
"opentelemetry-api is not installed. "
|
||||||
|
'Install it with: pip install "mt5cli[otel]"'
|
||||||
|
)
|
||||||
|
raise ImportError(msg)
|
||||||
|
if readers is None:
|
||||||
|
if _OtelOTLPExporter is None:
|
||||||
|
msg = (
|
||||||
|
"opentelemetry-exporter-otlp-proto-http is required for the "
|
||||||
|
"default OTLP export pipeline. "
|
||||||
|
'Install it with: pip install "mt5cli[otel]" or pass a '
|
||||||
|
"custom readers list."
|
||||||
|
)
|
||||||
|
raise ImportError(msg)
|
||||||
|
readers = [_OtelPeriodicReader(_OtelOTLPExporter())] # type: ignore[misc]
|
||||||
|
resource = _OtelResource.create({"service.name": service_name}) # type: ignore[union-attr]
|
||||||
|
provider = _OtelMeterProvider(resource=resource, metric_readers=readers) # type: ignore[misc]
|
||||||
|
_otel_metrics_mod.set_meter_provider(provider) # type: ignore[union-attr]
|
||||||
|
meter = provider.get_meter(service_name)
|
||||||
|
configure_metrics(meter)
|
||||||
|
|
||||||
|
|
||||||
|
def get_metrics() -> _Mt5Metrics:
|
||||||
|
"""Return the global :class:`_Mt5Metrics` instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The global metric registry (no-op until :func:`configure_metrics` is
|
||||||
|
called).
|
||||||
|
"""
|
||||||
|
return _metrics
|
||||||
+697
-98
File diff suppressed because it is too large
Load Diff
+15
-7
@@ -4,13 +4,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from contextlib import closing
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, TypeGuard
|
from typing import TYPE_CHECKING, Any, TypeGuard
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from pdmt5 import COPY_TICKS_MAP, TIMEFRAME_MAP
|
from pdmt5 import COPY_TICKS_MAP as _COPY_TICKS_MAP
|
||||||
|
from pdmt5 import TIMEFRAME_MAP as _TIMEFRAME_MAP
|
||||||
from pdmt5 import parse_copy_ticks as _parse_copy_ticks
|
from pdmt5 import parse_copy_ticks as _parse_copy_ticks
|
||||||
from pdmt5 import parse_timeframe as _parse_timeframe
|
from pdmt5 import parse_timeframe as _parse_timeframe
|
||||||
|
|
||||||
@@ -23,14 +25,11 @@ if TYPE_CHECKING:
|
|||||||
# Constants
|
# Constants
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Backward-compatible snapshot; prefer ``COPY_TICKS_MAP`` from pdmt5 directly.
|
|
||||||
TICK_FLAG_MAP: dict[str, int] = dict(COPY_TICKS_MAP)
|
|
||||||
|
|
||||||
TIMEFRAME_NAMES: tuple[str, ...] = tuple(
|
TIMEFRAME_NAMES: tuple[str, ...] = tuple(
|
||||||
name for name in TIMEFRAME_MAP if not name.startswith("TIMEFRAME_")
|
name for name in _TIMEFRAME_MAP if not name.startswith("TIMEFRAME_")
|
||||||
)
|
)
|
||||||
_TICK_FLAG_NAMES: tuple[str, ...] = tuple(
|
_TICK_FLAG_NAMES: tuple[str, ...] = tuple(
|
||||||
name for name in COPY_TICKS_MAP if not name.startswith("COPY_TICKS_")
|
name for name in _COPY_TICKS_MAP if not name.startswith("COPY_TICKS_")
|
||||||
)
|
)
|
||||||
|
|
||||||
_FORMAT_EXTENSIONS: dict[str, str] = {
|
_FORMAT_EXTENSIONS: dict[str, str] = {
|
||||||
@@ -279,7 +278,7 @@ def export_dataframe_to_sqlite(
|
|||||||
full table, so repeated appends cost O(table size); index the key
|
full table, so repeated appends cost O(table size); index the key
|
||||||
columns when appending frequently.
|
columns when appending frequently.
|
||||||
"""
|
"""
|
||||||
with sqlite3.connect(output_path) as conn:
|
with closing(sqlite3.connect(output_path)) as conn, conn:
|
||||||
df.to_sql( # type: ignore[reportUnknownMemberType]
|
df.to_sql( # type: ignore[reportUnknownMemberType]
|
||||||
table_name,
|
table_name,
|
||||||
conn,
|
conn,
|
||||||
@@ -314,6 +313,7 @@ def export_dataframe(
|
|||||||
table_name: Table name for SQLite3 output.
|
table_name: Table name for SQLite3 output.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
|
ImportError: If the parquet format is requested but pyarrow is not installed.
|
||||||
ValueError: If the output format is not supported.
|
ValueError: If the output format is not supported.
|
||||||
"""
|
"""
|
||||||
if output_format == "csv":
|
if output_format == "csv":
|
||||||
@@ -326,6 +326,14 @@ def export_dataframe(
|
|||||||
indent=2,
|
indent=2,
|
||||||
)
|
)
|
||||||
elif output_format == "parquet":
|
elif output_format == "parquet":
|
||||||
|
try:
|
||||||
|
__import__("pyarrow")
|
||||||
|
except ImportError as exc:
|
||||||
|
msg = (
|
||||||
|
"Parquet export requires the optional dependency pyarrow. "
|
||||||
|
'Install it with: pip install "mt5cli[parquet]"'
|
||||||
|
)
|
||||||
|
raise ImportError(msg) from exc
|
||||||
df.to_parquet(output_path, index=False)
|
df.to_parquet(output_path, index=False)
|
||||||
elif output_format == "sqlite3":
|
elif output_format == "sqlite3":
|
||||||
export_dataframe_to_sqlite(
|
export_dataframe_to_sqlite(
|
||||||
|
|||||||
+19
-4
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mt5cli"
|
name = "mt5cli"
|
||||||
version = "0.9.2"
|
version = "1.1.3"
|
||||||
description = "Generic MT5 data and execution infrastructure for Python applications"
|
description = "Generic MT5 data and execution infrastructure for Python applications"
|
||||||
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||||
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||||
@@ -9,9 +9,10 @@ license-files = ["LICENSE"]
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">= 3.11, < 3.14"
|
requires-python = ">= 3.11, < 3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pdmt5>=0.3.0",
|
"pdmt5 >= 1.1.0",
|
||||||
|
"pandas >= 2.2.2",
|
||||||
|
"pydantic >= 2.13.4",
|
||||||
"click >= 8.1.0",
|
"click >= 8.1.0",
|
||||||
"pyarrow >= 19.0.0",
|
|
||||||
"typer >= 0.15.0",
|
"typer >= 0.15.0",
|
||||||
]
|
]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
@@ -25,6 +26,14 @@ classifiers = [
|
|||||||
"Topic :: Office/Business :: Financial :: Investment",
|
"Topic :: Office/Business :: Financial :: Investment",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
parquet = ["pyarrow >= 19.0.0"]
|
||||||
|
otel = [
|
||||||
|
"opentelemetry-api",
|
||||||
|
"opentelemetry-sdk",
|
||||||
|
"opentelemetry-exporter-otlp-proto-http",
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
mt5cli = "mt5cli.cli:main"
|
mt5cli = "mt5cli.cli:main"
|
||||||
|
|
||||||
@@ -42,6 +51,9 @@ dev = [
|
|||||||
"pytest-mock >= 3.12.0",
|
"pytest-mock >= 3.12.0",
|
||||||
"pytest-cov >= 5.0.0",
|
"pytest-cov >= 5.0.0",
|
||||||
"pandas-stubs >= 2.2.3.250527",
|
"pandas-stubs >= 2.2.3.250527",
|
||||||
|
"pyarrow >= 19.0.0",
|
||||||
|
"opentelemetry-api",
|
||||||
|
"opentelemetry-sdk",
|
||||||
"mkdocs >= 1.6.1",
|
"mkdocs >= 1.6.1",
|
||||||
"mkdocs-material >= 9.7.6",
|
"mkdocs-material >= 9.7.6",
|
||||||
"mkdocstrings[python] >= 1.0.4",
|
"mkdocstrings[python] >= 1.0.4",
|
||||||
@@ -175,7 +187,10 @@ omit = [
|
|||||||
[tool.coverage.report]
|
[tool.coverage.report]
|
||||||
show_missing = true
|
show_missing = true
|
||||||
fail_under = 100
|
fail_under = 100
|
||||||
exclude_lines = ["if TYPE_CHECKING:"]
|
exclude_also = [
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
"^\\s+\\.\\.\\.$",
|
||||||
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ Global options MUST precede the subcommand.
|
|||||||
| `-o, --output PATH` | Output file path (required). |
|
| `-o, --output PATH` | Output file path (required). |
|
||||||
| `-f, --format FORMAT` | `csv`, `json`, `parquet`, or `sqlite3` (auto from extension). |
|
| `-f, --format FORMAT` | `csv`, `json`, `parquet`, or `sqlite3` (auto from extension). |
|
||||||
| `--table NAME` | Table name for SQLite3 output (default: `data`). |
|
| `--table NAME` | Table name for SQLite3 output (default: `data`). |
|
||||||
| `--login INT` | MT5 trading account login. |
|
| `--login INT` | MT5 trading account login (`MT5_LOGIN`). |
|
||||||
| `--password TEXT` | MT5 trading account password. |
|
| `--password TEXT` | MT5 trading account password (`MT5_PASSWORD`). |
|
||||||
| `--server TEXT` | MT5 trading server name. |
|
| `--server TEXT` | MT5 trading server name (`MT5_SERVER`). |
|
||||||
| `--path TEXT` | Path to MetaTrader 5 terminal EXE. |
|
| `--path TEXT` | Path to MetaTrader 5 terminal EXE (`MT5_PATH`). |
|
||||||
| `--timeout INT` | Connection timeout in milliseconds. |
|
| `--timeout INT` | Connection timeout in milliseconds. |
|
||||||
| `--log-level LEVEL` | `DEBUG`, `INFO`, `WARNING` (default), `ERROR`. |
|
| `--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.
|
local MT5 terminal is already logged in.
|
||||||
- Avoid passing `--password` on the command line in shared or logged
|
- Avoid passing `--password` on the command line in shared or logged
|
||||||
environments — it is visible in `ps`, shell history, and CI logs. Prefer
|
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
|
- Reach for `--log-level DEBUG` when a command fails silently — MT5
|
||||||
connection errors surface there.
|
connection errors surface there.
|
||||||
- If the user asks to run from source in this repo, prefix with `uv run`
|
- If the user asks to run from source in this repo, prefix with `uv run`
|
||||||
|
|||||||
@@ -2,12 +2,17 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import pytest
|
import pytest
|
||||||
from pytest_mock import MockerFixture # noqa: TC002
|
from pytest_mock import MockerFixture # noqa: TC002
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from types import TracebackType
|
||||||
|
|
||||||
_DATAFRAME_METHODS = (
|
_DATAFRAME_METHODS = (
|
||||||
"copy_rates_from_as_df",
|
"copy_rates_from_as_df",
|
||||||
"copy_rates_from_pos_as_df",
|
"copy_rates_from_pos_as_df",
|
||||||
@@ -30,6 +35,25 @@ _DATAFRAME_METHODS = (
|
|||||||
"order_send_as_df",
|
"order_send_as_df",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_ORIGINAL_SQLITE_CONNECT = sqlite3.connect
|
||||||
|
|
||||||
|
|
||||||
|
class ClosingSqliteConnection(sqlite3.Connection):
|
||||||
|
"""SQLite connection that closes after context-manager exit in tests."""
|
||||||
|
|
||||||
|
def __exit__(
|
||||||
|
self,
|
||||||
|
exc_type: type[BaseException] | None,
|
||||||
|
exc_value: BaseException | None,
|
||||||
|
traceback: TracebackType | None,
|
||||||
|
) -> Literal[False]:
|
||||||
|
"""Commit or roll back the transaction, then close the connection."""
|
||||||
|
try:
|
||||||
|
super().__exit__(exc_type, exc_value, traceback)
|
||||||
|
finally:
|
||||||
|
self.close()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def build_mock_mt5_data_client() -> MagicMock:
|
def build_mock_mt5_data_client() -> MagicMock:
|
||||||
"""Return a MagicMock Mt5DataClient with common DataFrame stubs."""
|
"""Return a MagicMock Mt5DataClient with common DataFrame stubs."""
|
||||||
@@ -50,3 +74,17 @@ def mock_client(mocker: MockerFixture) -> MagicMock:
|
|||||||
client = build_mock_mt5_data_client()
|
client = build_mock_mt5_data_client()
|
||||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client)
|
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client)
|
||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def close_sqlite_context_connections(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Make test SQLite context managers close their connection handles."""
|
||||||
|
|
||||||
|
def connect(
|
||||||
|
*args: Any, # noqa: ANN401
|
||||||
|
**kwargs: Any, # noqa: ANN401
|
||||||
|
) -> sqlite3.Connection:
|
||||||
|
kwargs.setdefault("factory", ClosingSqliteConnection)
|
||||||
|
return _ORIGINAL_SQLITE_CONNECT(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(sqlite3, "connect", connect)
|
||||||
|
|||||||
+1063
-561
File diff suppressed because it is too large
Load Diff
+215
-114
@@ -2,75 +2,92 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from importlib.metadata import requires
|
||||||
from typing import TYPE_CHECKING, get_type_hints
|
from typing import TYPE_CHECKING, get_type_hints
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import pytest
|
import pytest
|
||||||
from pdmt5 import Mt5RuntimeError, Mt5TradingError
|
from pdmt5 import Mt5RuntimeError
|
||||||
from pytest_mock import MockerFixture # noqa: TC002
|
from pytest_mock import MockerFixture # noqa: TC002
|
||||||
|
|
||||||
import mt5cli
|
import mt5cli
|
||||||
from mt5cli import (
|
from mt5cli import (
|
||||||
DEDUP_KEYS,
|
|
||||||
REQUIRED_COLUMNS,
|
|
||||||
STABLE_SDK_EXPORTS,
|
STABLE_SDK_EXPORTS,
|
||||||
TIME_COLUMNS,
|
|
||||||
AccountSpec,
|
AccountSpec,
|
||||||
DataKind,
|
|
||||||
Dataset,
|
|
||||||
ExecutionStatus,
|
ExecutionStatus,
|
||||||
MarginVolume,
|
MarginVolume,
|
||||||
MT5Client,
|
MT5Client,
|
||||||
Mt5CliError,
|
Mt5CliError,
|
||||||
Mt5ConnectionError,
|
Mt5ConnectionError,
|
||||||
Mt5OperationError,
|
|
||||||
Mt5SchemaError,
|
Mt5SchemaError,
|
||||||
OrderExecutionResult,
|
OrderExecutionResult,
|
||||||
OrderLimits,
|
OrderLimits,
|
||||||
RateTarget,
|
RateTarget,
|
||||||
build_config,
|
build_config,
|
||||||
build_rate_targets,
|
build_rate_targets,
|
||||||
|
calculate_account_projected_margin_ratio,
|
||||||
calculate_margin_and_volume,
|
calculate_margin_and_volume,
|
||||||
calculate_positions_margin,
|
calculate_positions_margin,
|
||||||
call_with_normalized_errors,
|
calculate_projected_margin_ratio,
|
||||||
detect_format,
|
calculate_symbol_group_margin_ratio,
|
||||||
|
calculate_trailing_stop_updates,
|
||||||
drop_forming_rate_bar,
|
drop_forming_rate_bar,
|
||||||
ensure_symbol_selected,
|
ensure_symbol_selected,
|
||||||
ensure_utc,
|
extract_tick_price,
|
||||||
export_dataframe,
|
|
||||||
export_dataframe_to_sqlite,
|
|
||||||
fetch_latest_closed_rates,
|
fetch_latest_closed_rates,
|
||||||
fetch_latest_closed_rates_for_trading_client,
|
fetch_latest_closed_rates_for_trading_client,
|
||||||
fetch_latest_closed_rates_indexed,
|
fetch_latest_closed_rates_indexed,
|
||||||
granularity_name,
|
|
||||||
is_recoverable_mt5_error,
|
|
||||||
load_rate_data,
|
|
||||||
load_rate_series_from_sqlite,
|
load_rate_series_from_sqlite,
|
||||||
mt5_session,
|
mt5_session,
|
||||||
mt5_trading_session,
|
mt5_trading_session,
|
||||||
normalize_dataframe,
|
|
||||||
normalize_mt5_exception,
|
|
||||||
normalize_order_volume,
|
normalize_order_volume,
|
||||||
|
place_market_order,
|
||||||
|
resolve_account_spec,
|
||||||
|
resolve_account_specs,
|
||||||
|
)
|
||||||
|
from mt5cli.converters import (
|
||||||
|
ensure_utc,
|
||||||
|
granularity_name,
|
||||||
normalize_symbol,
|
normalize_symbol,
|
||||||
normalize_symbols,
|
normalize_symbols,
|
||||||
parse_date_range,
|
parse_date_range,
|
||||||
place_market_order,
|
|
||||||
recent_window,
|
recent_window,
|
||||||
resolve_account_spec,
|
)
|
||||||
resolve_account_specs,
|
from mt5cli.exceptions import (
|
||||||
|
call_with_normalized_errors,
|
||||||
|
is_recoverable_mt5_error,
|
||||||
|
normalize_mt5_exception,
|
||||||
|
)
|
||||||
|
from mt5cli.history import (
|
||||||
|
create_rate_compatibility_views,
|
||||||
|
load_rate_data,
|
||||||
resolve_rate_view_name,
|
resolve_rate_view_name,
|
||||||
|
)
|
||||||
|
from mt5cli.retry import retry_with_backoff
|
||||||
|
from mt5cli.schemas import (
|
||||||
|
DEDUP_KEYS,
|
||||||
|
REQUIRED_COLUMNS,
|
||||||
|
TIME_COLUMNS,
|
||||||
|
DataKind,
|
||||||
|
ensure_utc_columns,
|
||||||
|
normalize_dataframe,
|
||||||
|
normalize_time_columns,
|
||||||
schema_columns,
|
schema_columns,
|
||||||
validate_schema,
|
validate_schema,
|
||||||
)
|
)
|
||||||
from mt5cli.history import create_rate_compatibility_views
|
from mt5cli.utils import (
|
||||||
from mt5cli.retry import retry_with_backoff
|
Dataset,
|
||||||
from mt5cli.schemas import ensure_utc_columns, normalize_time_columns
|
detect_format,
|
||||||
|
export_dataframe,
|
||||||
if TYPE_CHECKING:
|
export_dataframe_to_sqlite,
|
||||||
from pathlib import Path
|
)
|
||||||
|
|
||||||
|
|
||||||
def _sample_frame(kind: DataKind) -> pd.DataFrame:
|
def _sample_frame(kind: DataKind) -> pd.DataFrame:
|
||||||
@@ -206,10 +223,18 @@ def test_parse_date_range_rejects_inverted_bounds() -> None:
|
|||||||
parse_date_range("2024-02-01", "2024-01-01")
|
parse_date_range("2024-02-01", "2024-01-01")
|
||||||
|
|
||||||
|
|
||||||
def test_recent_window_builds_trailing_bounds() -> None:
|
@pytest.mark.parametrize(
|
||||||
"""Recent windows end at the provided timestamp."""
|
"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)
|
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 resolved_end == end
|
||||||
assert start < end
|
assert start < end
|
||||||
|
|
||||||
@@ -221,23 +246,25 @@ def test_granularity_name_maps_timeframe_alias() -> None:
|
|||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"exc",
|
"exc",
|
||||||
[Mt5RuntimeError("init failed"), Mt5TradingError("trade failed")],
|
[Mt5RuntimeError("init failed")],
|
||||||
)
|
)
|
||||||
def test_is_recoverable_mt5_error(exc: Exception) -> None:
|
def test_is_recoverable_mt5_error(exc: Exception) -> None:
|
||||||
"""Recoverable MT5 errors are classified consistently."""
|
"""Recoverable MT5 errors are classified consistently."""
|
||||||
assert is_recoverable_mt5_error(exc)
|
assert is_recoverable_mt5_error(exc)
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_mt5_exception_maps_types() -> None:
|
@pytest.mark.parametrize(
|
||||||
|
("exc", "expected_type"),
|
||||||
|
[
|
||||||
|
(Mt5RuntimeError("x"), Mt5ConnectionError),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_normalize_mt5_exception_maps_types(
|
||||||
|
exc: Exception,
|
||||||
|
expected_type: type[Mt5ConnectionError],
|
||||||
|
) -> None:
|
||||||
"""MT5 exceptions map to stable mt5cli types."""
|
"""MT5 exceptions map to stable mt5cli types."""
|
||||||
assert isinstance(
|
assert isinstance(normalize_mt5_exception(exc), expected_type)
|
||||||
normalize_mt5_exception(Mt5RuntimeError("x")),
|
|
||||||
Mt5ConnectionError,
|
|
||||||
)
|
|
||||||
assert isinstance(
|
|
||||||
normalize_mt5_exception(Mt5TradingError("x")),
|
|
||||||
Mt5OperationError,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_call_with_normalized_errors_reraises_mapped_type() -> None:
|
def test_call_with_normalized_errors_reraises_mapped_type() -> None:
|
||||||
@@ -318,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
|
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."""
|
"""Recent window helpers validate mutually exclusive length arguments."""
|
||||||
with pytest.raises(ValueError, match="exactly one"):
|
with pytest.raises(ValueError, match=match):
|
||||||
recent_window()
|
recent_window(**kwargs) # type: ignore[arg-type]
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_date_range_returns_ordered_bounds() -> None:
|
def test_parse_date_range_returns_ordered_bounds() -> None:
|
||||||
@@ -414,26 +441,24 @@ def test_normalize_time_columns_skips_absent_time_fields() -> None:
|
|||||||
assert list(result.columns) == ["open"]
|
assert list(result.columns) == ["open"]
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_time_columns_converts_unix_seconds() -> None:
|
@pytest.mark.parametrize(
|
||||||
"""Numeric MT5 ``time`` values are interpreted as Unix seconds."""
|
("col", "value", "kind"),
|
||||||
frame = pd.DataFrame({"time": [1704067200]})
|
[
|
||||||
result = normalize_time_columns(frame, DataKind.rates)
|
("time", 1704067200, DataKind.rates),
|
||||||
assert result.loc[0, "time"] == pd.Timestamp("2024-01-01T00:00:00+00:00")
|
("time_msc", 1704067200000, DataKind.ticks),
|
||||||
|
("time", datetime(2024, 1, 1, tzinfo=UTC), DataKind.rates),
|
||||||
|
("time", "2024-01-01T00:00:00+00:00", DataKind.rates),
|
||||||
def test_normalize_time_columns_converts_unix_milliseconds() -> None:
|
],
|
||||||
"""Numeric MT5 ``time_msc`` values are interpreted as Unix milliseconds."""
|
)
|
||||||
frame = pd.DataFrame({"time_msc": [1704067200000]})
|
def test_normalize_time_columns_coerces_value(
|
||||||
result = normalize_time_columns(frame, DataKind.ticks)
|
col: str,
|
||||||
assert result.loc[0, "time_msc"] == pd.Timestamp("2024-01-01T00:00:00+00:00")
|
value: object,
|
||||||
|
kind: DataKind,
|
||||||
|
) -> None:
|
||||||
def test_normalize_time_columns_preserves_utc_datetimes() -> None:
|
"""Time column values are coerced to UTC timestamps regardless of input type."""
|
||||||
"""Already-converted datetime values remain UTC-normalized."""
|
frame = pd.DataFrame({col: [value]})
|
||||||
aware = datetime(2024, 1, 1, tzinfo=UTC)
|
result = normalize_time_columns(frame, kind)
|
||||||
frame = pd.DataFrame({"time": [aware]})
|
assert result.loc[0, col] == pd.Timestamp("2024-01-01T00:00:00+00:00")
|
||||||
result = normalize_time_columns(frame, DataKind.rates)
|
|
||||||
assert result.loc[0, "time"] == pd.Timestamp("2024-01-01T00:00:00+00:00")
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_time_columns_handles_optional_order_times() -> None:
|
def test_normalize_time_columns_handles_optional_order_times() -> None:
|
||||||
@@ -483,13 +508,6 @@ def test_ensure_utc_columns_skips_missing_columns() -> None:
|
|||||||
assert "time" in result.columns
|
assert "time" in result.columns
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_time_columns_coerces_string_timestamps() -> None:
|
|
||||||
"""String timestamps are parsed with timezone-aware datetime coercion."""
|
|
||||||
frame = pd.DataFrame({"time": ["2024-01-01T00:00:00+00:00"]})
|
|
||||||
result = normalize_time_columns(frame, DataKind.rates)
|
|
||||||
assert result.loc[0, "time"] == pd.Timestamp("2024-01-01T00:00:00+00:00")
|
|
||||||
|
|
||||||
|
|
||||||
def test_ensure_utc_columns_coerces_non_mt5_columns() -> None:
|
def test_ensure_utc_columns_coerces_non_mt5_columns() -> None:
|
||||||
"""Non-MT5 columns still coerce to UTC datetimes."""
|
"""Non-MT5 columns still coerce to UTC datetimes."""
|
||||||
frame = pd.DataFrame({"created_at": ["2024-01-01T00:00:00+00:00"]})
|
frame = pd.DataFrame({"created_at": ["2024-01-01T00:00:00+00:00"]})
|
||||||
@@ -539,6 +557,12 @@ def test_storage_export_round_trip_sqlite(tmp_path: Path) -> None:
|
|||||||
assert count == 1
|
assert count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_storage_module_does_not_exist() -> None:
|
||||||
|
"""mt5cli.storage re-export module has been removed."""
|
||||||
|
with pytest.raises(ModuleNotFoundError):
|
||||||
|
importlib.import_module("mt5cli.storage")
|
||||||
|
|
||||||
|
|
||||||
class TestStableSdkContract:
|
class TestStableSdkContract:
|
||||||
"""Tests for the documented stable downstream SDK contract."""
|
"""Tests for the documented stable downstream SDK contract."""
|
||||||
|
|
||||||
@@ -547,6 +571,21 @@ class TestStableSdkContract:
|
|||||||
missing = sorted(STABLE_SDK_EXPORTS - set(mt5cli.__all__))
|
missing = sorted(STABLE_SDK_EXPORTS - set(mt5cli.__all__))
|
||||||
assert not missing, f"STABLE_SDK_EXPORTS missing from __all__: {missing}"
|
assert not missing, f"STABLE_SDK_EXPORTS missing from __all__: {missing}"
|
||||||
|
|
||||||
|
def test_stable_exports_cover_root_api(self) -> None:
|
||||||
|
"""STABLE_SDK_EXPORTS classifies every package-root symbol."""
|
||||||
|
tier_metadata = {"STABLE_SDK_EXPORTS"}
|
||||||
|
root_exports = set(mt5cli.__all__)
|
||||||
|
|
||||||
|
missing_from_root = sorted(STABLE_SDK_EXPORTS - root_exports)
|
||||||
|
assert not missing_from_root, (
|
||||||
|
f"STABLE_SDK_EXPORTS missing from __all__: {missing_from_root}"
|
||||||
|
)
|
||||||
|
|
||||||
|
unclassified = sorted(root_exports - STABLE_SDK_EXPORTS - tier_metadata)
|
||||||
|
assert not unclassified, (
|
||||||
|
f"Root exports not in STABLE_SDK_EXPORTS: {unclassified}"
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.mark.parametrize("name", sorted(STABLE_SDK_EXPORTS))
|
@pytest.mark.parametrize("name", sorted(STABLE_SDK_EXPORTS))
|
||||||
def test_stable_exports_are_importable_from_package_root(self, name: str) -> None:
|
def test_stable_exports_are_importable_from_package_root(self, name: str) -> None:
|
||||||
"""Stable SDK names resolve through ``from mt5cli import ...``."""
|
"""Stable SDK names resolve through ``from mt5cli import ...``."""
|
||||||
@@ -615,37 +654,15 @@ class TestStableSdkContract:
|
|||||||
|
|
||||||
assert calculate_positions_margin(client) == 0
|
assert calculate_positions_margin(client) == 0
|
||||||
|
|
||||||
def test_resolve_rate_view_name_from_package_root(self, tmp_path: Path) -> None:
|
def test_generic_trading_helpers_from_package_root(self) -> None:
|
||||||
"""Rate view resolution is importable and honors require_existing."""
|
"""New generic trading helpers resolve through the stable surface."""
|
||||||
db_path = tmp_path / "rates.db"
|
price = extract_tick_price({"bid": "1.2"}, "bid")
|
||||||
with sqlite3.connect(db_path) as conn:
|
assert price is not None
|
||||||
conn.execute(
|
assert abs(price - 1.2) < 1e-9
|
||||||
"CREATE TABLE rates("
|
assert callable(calculate_trailing_stop_updates)
|
||||||
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
|
assert callable(calculate_account_projected_margin_ratio)
|
||||||
)
|
assert callable(calculate_projected_margin_ratio)
|
||||||
conn.execute(
|
assert callable(calculate_symbol_group_margin_ratio)
|
||||||
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
|
|
||||||
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
|
|
||||||
)
|
|
||||||
create_rate_compatibility_views(conn)
|
|
||||||
|
|
||||||
assert resolve_rate_view_name(db_path, "EURUSD", "M1") == "rate_EURUSD__1"
|
|
||||||
missing = tmp_path / "missing.db"
|
|
||||||
with pytest.raises(ValueError, match="SQLite database not found"):
|
|
||||||
resolve_rate_view_name(missing, "EURUSD", "M1", require_existing=True)
|
|
||||||
|
|
||||||
def test_load_rate_data_from_package_root(self, tmp_path: Path) -> None:
|
|
||||||
"""SQLite rate loading normalizes timestamps through the stable API."""
|
|
||||||
db_path = tmp_path / "view.db"
|
|
||||||
with sqlite3.connect(db_path) as conn:
|
|
||||||
conn.execute(
|
|
||||||
'CREATE VIEW "rate_EURUSD__1" AS'
|
|
||||||
" SELECT '2024-01-01T00:00:00+00:00' AS time, 1.1 AS close",
|
|
||||||
)
|
|
||||||
|
|
||||||
frame = load_rate_data(db_path, "rate_EURUSD__1")
|
|
||||||
assert frame.index.name == "time"
|
|
||||||
assert abs(float(frame.iloc[0]["close"]) - 1.1) < 1e-9
|
|
||||||
|
|
||||||
def test_load_rate_series_from_sqlite_requires_managed_views(
|
def test_load_rate_series_from_sqlite_requires_managed_views(
|
||||||
self,
|
self,
|
||||||
@@ -694,7 +711,7 @@ class TestStableSdkContract:
|
|||||||
"""Trading session helper initializes and always shuts down."""
|
"""Trading session helper initializes and always shuts down."""
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"mt5cli.trading.Mt5TradingClient",
|
"mt5cli.trading.Mt5DataClient",
|
||||||
return_value=mock_client,
|
return_value=mock_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -723,7 +740,7 @@ class TestStableSdkContract:
|
|||||||
"""Trading session helper shuts down even when the body raises."""
|
"""Trading session helper shuts down even when the body raises."""
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"mt5cli.trading.Mt5TradingClient",
|
"mt5cli.trading.Mt5DataClient",
|
||||||
return_value=mock_client,
|
return_value=mock_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -764,3 +781,87 @@ class TestStableSdkContract:
|
|||||||
assert result.index.tz is not None
|
assert result.index.tz is not None
|
||||||
assert "time" not in result.columns
|
assert "time" not in result.columns
|
||||||
assert "close" in result.columns
|
assert "close" in result.columns
|
||||||
|
|
||||||
|
def test_rate_view_helpers_in_history_module(self, tmp_path: Path) -> None:
|
||||||
|
"""Rate view helpers are available from mt5cli.history."""
|
||||||
|
db_path = tmp_path / "rates.db"
|
||||||
|
with sqlite3.connect(db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE rates("
|
||||||
|
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
|
||||||
|
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
|
||||||
|
)
|
||||||
|
create_rate_compatibility_views(conn)
|
||||||
|
|
||||||
|
assert resolve_rate_view_name(db_path, "EURUSD", "M1") == "rate_EURUSD__1"
|
||||||
|
missing = tmp_path / "missing.db"
|
||||||
|
with pytest.raises(ValueError, match="SQLite database not found"):
|
||||||
|
resolve_rate_view_name(missing, "EURUSD", "M1", require_existing=True)
|
||||||
|
|
||||||
|
def test_load_rate_data_in_history_module(self, tmp_path: Path) -> None:
|
||||||
|
"""SQLite rate loading normalizes timestamps through mt5cli.history."""
|
||||||
|
db_path = tmp_path / "view.db"
|
||||||
|
with sqlite3.connect(db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
'CREATE VIEW "rate_EURUSD__1" AS'
|
||||||
|
" SELECT '2024-01-01T00:00:00+00:00' AS time, 1.1 AS close",
|
||||||
|
)
|
||||||
|
|
||||||
|
frame = load_rate_data(db_path, "rate_EURUSD__1")
|
||||||
|
assert frame.index.name == "time"
|
||||||
|
assert abs(float(frame.iloc[0]["close"]) - 1.1) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"name",
|
||||||
|
[
|
||||||
|
"Mt5Config",
|
||||||
|
"Mt5RuntimeError",
|
||||||
|
"Mt5TradingClient",
|
||||||
|
"Mt5TradingError",
|
||||||
|
"TICK_FLAG_MAP",
|
||||||
|
"TIMEFRAME_MAP",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_pdmt5_pass_through_names_removed_from_public_contract(name: str) -> None:
|
||||||
|
"""Removed pdmt5 pass-through names are not part of the public contract."""
|
||||||
|
assert name not in STABLE_SDK_EXPORTS, (
|
||||||
|
f"{name!r} should not be in STABLE_SDK_EXPORTS"
|
||||||
|
)
|
||||||
|
assert name not in mt5cli.__all__, f"{name!r} should not be in mt5cli.__all__"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mt5cli_does_not_import_high_level_trading_symbols() -> None:
|
||||||
|
"""mt5cli doesn't import Mt5TradingClient or Mt5TradingError at module level."""
|
||||||
|
trading_module = importlib.import_module("mt5cli.trading")
|
||||||
|
module_dict = vars(trading_module)
|
||||||
|
assert "Mt5TradingClient" not in module_dict, (
|
||||||
|
"mt5cli.trading should not import Mt5TradingClient at module level"
|
||||||
|
)
|
||||||
|
assert "Mt5TradingError" not in module_dict, (
|
||||||
|
"mt5cli.trading should not import Mt5TradingError at module level"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Packaging metadata
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_parquet_extra_declares_pyarrow() -> None:
|
||||||
|
"""Package metadata lists pyarrow under the parquet optional extra."""
|
||||||
|
reqs = requires("mt5cli") or []
|
||||||
|
parquet_reqs = [r for r in reqs if "pyarrow" in r and "parquet" in r]
|
||||||
|
assert parquet_reqs, "pyarrow not found in parquet optional extra"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pyarrow_not_in_core_dependencies() -> None:
|
||||||
|
"""Pyarrow is not a core dependency; it belongs only in the parquet extra."""
|
||||||
|
reqs = requires("mt5cli") or []
|
||||||
|
core_reqs = [r for r in reqs if "extra ==" not in r]
|
||||||
|
assert not any("pyarrow" in r for r in core_reqs), (
|
||||||
|
"pyarrow should not appear in core dependencies"
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Tests for example files in examples/grafana/."""
|
||||||
|
|
||||||
|
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_present(self) -> None:
|
||||||
|
"""At least one dashboard JSON file is bundled."""
|
||||||
|
assert _dashboard_json_files(), "No dashboard JSON files found"
|
||||||
|
|
||||||
|
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."""
|
||||||
|
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, 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
@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 _dashboard_json_files()}
|
||||||
|
assert "mt5cli-overview.json" in names
|
||||||
|
assert "mt5cli-trades.json" in names
|
||||||
|
assert "mt5cli-market.json" in names
|
||||||
|
|
||||||
|
@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()
|
||||||
@@ -0,0 +1,943 @@
|
|||||||
|
"""Tests for mt5cli.grafana module."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable, Iterator
|
||||||
|
|
||||||
|
from mt5cli.grafana import (
|
||||||
|
_build_snapshot_view, # type: ignore[reportPrivateUsage]
|
||||||
|
_create_view_safe, # type: ignore[reportPrivateUsage]
|
||||||
|
create_grafana_indexes,
|
||||||
|
create_grafana_views,
|
||||||
|
create_snapshot_tables,
|
||||||
|
ensure_grafana_schema,
|
||||||
|
insert_account_snapshot,
|
||||||
|
insert_order_snapshots,
|
||||||
|
insert_position_snapshots,
|
||||||
|
insert_terminal_snapshot,
|
||||||
|
publish_grafana_copy,
|
||||||
|
record_snapshot_run,
|
||||||
|
start_snapshot_run,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def conn() -> Iterator[sqlite3.Connection]:
|
||||||
|
"""Yield an in-memory SQLite connection for each test."""
|
||||||
|
with sqlite3.connect(":memory:") as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def _get_names(conn: sqlite3.Connection, type_: str) -> set[str]:
|
||||||
|
return {
|
||||||
|
row[0]
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type=?",
|
||||||
|
(type_,),
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_rates_table(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE rates"
|
||||||
|
" (time TEXT, symbol TEXT, timeframe INTEGER,"
|
||||||
|
" open REAL, high REAL, low REAL, close REAL)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_ticks_table(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.execute("CREATE TABLE ticks (time TEXT, symbol TEXT, bid REAL, ask REAL)")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_history_deals_full(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE history_deals"
|
||||||
|
" (time TEXT, symbol TEXT, profit REAL, type INTEGER,"
|
||||||
|
" entry INTEGER, volume REAL, price REAL, ticket INTEGER, position_id INTEGER)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_history_deals_minimal(conn: sqlite3.Connection) -> None:
|
||||||
|
"""history_deals with only time, type, symbol, profit — no entry/volume/price."""
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE history_deals (time TEXT, symbol TEXT, profit REAL, type INTEGER)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
" (time_setup TEXT, symbol TEXT, ticket INTEGER, type INTEGER)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_TIMESTAMP_TIME_SETUP: pd.Timestamp = pd.Timestamp("2024-01-15 10:30:00", tz="UTC")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestSnapshotTables
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSnapshotTables:
|
||||||
|
"""Tests for create_snapshot_tables."""
|
||||||
|
|
||||||
|
def test_creates_all_five_tables(self, conn: sqlite3.Connection) -> None:
|
||||||
|
"""All five snapshot tables are created."""
|
||||||
|
create_snapshot_tables(conn)
|
||||||
|
tables = _get_names(conn, "table")
|
||||||
|
assert "snapshot_runs" in tables
|
||||||
|
assert "account_snapshots" in tables
|
||||||
|
assert "position_snapshots" in tables
|
||||||
|
assert "order_snapshots" in tables
|
||||||
|
assert "terminal_snapshots" in tables
|
||||||
|
|
||||||
|
def test_is_idempotent(self, conn: sqlite3.Connection) -> None:
|
||||||
|
"""Calling create_snapshot_tables twice does not raise."""
|
||||||
|
create_snapshot_tables(conn)
|
||||||
|
create_snapshot_tables(conn)
|
||||||
|
tables = _get_names(conn, "table")
|
||||||
|
assert "snapshot_runs" in tables
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestCreateViewSafe
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateViewSafe:
|
||||||
|
"""Tests for _create_view_safe."""
|
||||||
|
|
||||||
|
def test_creates_view_successfully(self, conn: sqlite3.Connection) -> None:
|
||||||
|
"""A valid select SQL creates the named view."""
|
||||||
|
_create_view_safe(conn, "test_view", "SELECT 1 AS val")
|
||||||
|
views = _get_names(conn, "view")
|
||||||
|
assert "test_view" in views
|
||||||
|
|
||||||
|
def test_replaces_existing_view(self, conn: sqlite3.Connection) -> None:
|
||||||
|
"""Calling again with a new SQL replaces the existing view."""
|
||||||
|
_create_view_safe(conn, "test_view", "SELECT 1 AS val")
|
||||||
|
_create_view_safe(conn, "test_view", "SELECT 2 AS val")
|
||||||
|
result = conn.execute("SELECT val FROM test_view").fetchone()
|
||||||
|
assert result == (2,)
|
||||||
|
|
||||||
|
def test_logs_warning_on_sqlite_error(
|
||||||
|
self,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""sqlite3.Error during CREATE VIEW logs a warning instead of raising."""
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute.side_effect = [
|
||||||
|
None,
|
||||||
|
sqlite3.OperationalError("parse error"),
|
||||||
|
]
|
||||||
|
with caplog.at_level(logging.WARNING, logger="mt5cli.grafana"):
|
||||||
|
_create_view_safe(mock_conn, "bad_view", "SELECT 1")
|
||||||
|
assert "Skipping view bad_view" in caplog.text
|
||||||
|
assert "parse error" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestGrafanaViews
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGrafanaViews:
|
||||||
|
"""Tests for create_grafana_views and individual view builders."""
|
||||||
|
|
||||||
|
def test_all_views_created_with_full_schema(
|
||||||
|
self,
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
) -> None:
|
||||||
|
"""All 13 Grafana views are created when all source tables are present."""
|
||||||
|
_make_rates_table(conn)
|
||||||
|
_make_ticks_table(conn)
|
||||||
|
_make_history_deals_full(conn)
|
||||||
|
_make_history_orders_table(conn)
|
||||||
|
create_snapshot_tables(conn)
|
||||||
|
create_grafana_views(conn)
|
||||||
|
views = _get_names(conn, "view")
|
||||||
|
expected = {
|
||||||
|
"grafana_rates",
|
||||||
|
"grafana_ticks",
|
||||||
|
"grafana_history_deals",
|
||||||
|
"grafana_history_orders",
|
||||||
|
"grafana_trade_deals",
|
||||||
|
"grafana_cash_events",
|
||||||
|
"grafana_realized_pnl",
|
||||||
|
"grafana_symbol_pnl",
|
||||||
|
"grafana_trade_stats",
|
||||||
|
"grafana_account_snapshots",
|
||||||
|
"grafana_position_snapshots",
|
||||||
|
"grafana_order_snapshots",
|
||||||
|
"grafana_terminal_snapshots",
|
||||||
|
}
|
||||||
|
assert expected.issubset(views)
|
||||||
|
|
||||||
|
def test_stale_view_dropped_when_source_table_disappears(
|
||||||
|
self,
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
) -> None:
|
||||||
|
"""create_grafana_views drops a previously created view whose source is gone."""
|
||||||
|
_make_ticks_table(conn)
|
||||||
|
create_grafana_views(conn)
|
||||||
|
assert "grafana_ticks" in _get_names(conn, "view")
|
||||||
|
conn.execute("DROP TABLE ticks")
|
||||||
|
create_grafana_views(conn)
|
||||||
|
assert "grafana_ticks" not in _get_names(conn, "view")
|
||||||
|
|
||||||
|
def test_grafana_rates_skipped_when_table_absent(
|
||||||
|
self,
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""grafana_rates is skipped when rates table is missing."""
|
||||||
|
with caplog.at_level(logging.WARNING, logger="mt5cli.grafana"):
|
||||||
|
create_grafana_views(conn)
|
||||||
|
assert "grafana_rates" not in _get_names(conn, "view")
|
||||||
|
|
||||||
|
@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:
|
||||||
|
"""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 view_name not in _get_names(conn, "view")
|
||||||
|
assert f"Skipping {view_name}" in caplog.text
|
||||||
|
|
||||||
|
@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,
|
||||||
|
setup_deals: Callable[[sqlite3.Connection], None],
|
||||||
|
optional_cols: set[str],
|
||||||
|
) -> None:
|
||||||
|
"""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")
|
||||||
|
cols = {row[1] for row in conn.execute("PRAGMA table_info(grafana_symbol_pnl)")}
|
||||||
|
assert optional_cols.issubset(cols)
|
||||||
|
|
||||||
|
@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."""
|
||||||
|
setup_deals(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_snapshot_views_skipped_when_snapshot_tables_absent(
|
||||||
|
self,
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Snapshot views are skipped when snapshot tables are not created."""
|
||||||
|
with caplog.at_level(logging.WARNING, logger="mt5cli.grafana"):
|
||||||
|
create_grafana_views(conn)
|
||||||
|
views = _get_names(conn, "view")
|
||||||
|
assert "grafana_account_snapshots" not in views
|
||||||
|
assert "grafana_position_snapshots" not in views
|
||||||
|
assert "grafana_order_snapshots" not in views
|
||||||
|
assert "grafana_terminal_snapshots" not in views
|
||||||
|
|
||||||
|
def test_build_snapshot_view_with_only_run_id_col(
|
||||||
|
self,
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
) -> None:
|
||||||
|
"""_build_snapshot_view exposes time and run_id when table has only run_id."""
|
||||||
|
create_snapshot_tables(conn)
|
||||||
|
conn.execute("CREATE TABLE only_run (run_id INTEGER NOT NULL)")
|
||||||
|
run_id = start_snapshot_run(conn, 1000)
|
||||||
|
record_snapshot_run(conn, run_id, "ok")
|
||||||
|
conn.execute("INSERT INTO only_run (run_id) VALUES (?)", (run_id,))
|
||||||
|
_build_snapshot_view(conn, "test_view", "only_run")
|
||||||
|
assert "test_view" in _get_names(conn, "view")
|
||||||
|
cols = {row[1] for row in conn.execute("PRAGMA table_info(test_view)")}
|
||||||
|
assert "time" in cols
|
||||||
|
assert "run_id" in cols
|
||||||
|
|
||||||
|
@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 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", table_name)
|
||||||
|
assert "test_view" not in _get_names(conn, "view")
|
||||||
|
assert log_fragment in caplog.text
|
||||||
|
|
||||||
|
@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 expose rows only from successful runs."""
|
||||||
|
create_snapshot_tables(conn)
|
||||||
|
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, status, message)
|
||||||
|
create_grafana_views(conn)
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT time, run_id, login FROM grafana_account_snapshots"
|
||||||
|
).fetchall()
|
||||||
|
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 == []
|
||||||
|
|
||||||
|
@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:
|
||||||
|
"""Snapshot views join same-second rows by run_id."""
|
||||||
|
create_snapshot_tables(conn)
|
||||||
|
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 == expected_rows
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestGrafanaIndexes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGrafanaIndexes:
|
||||||
|
"""Tests for create_grafana_indexes."""
|
||||||
|
|
||||||
|
def test_all_indexes_created_with_full_schema(
|
||||||
|
self,
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
) -> None:
|
||||||
|
"""All 9 indexes are created when all source tables are present."""
|
||||||
|
_make_rates_table(conn)
|
||||||
|
_make_ticks_table(conn)
|
||||||
|
_make_history_deals_full(conn)
|
||||||
|
_make_history_orders_table(conn)
|
||||||
|
create_snapshot_tables(conn)
|
||||||
|
create_grafana_indexes(conn)
|
||||||
|
indexes = _get_names(conn, "index")
|
||||||
|
assert "idx_rates_time_symbol_timeframe" in indexes
|
||||||
|
assert "idx_ticks_time_symbol" in indexes
|
||||||
|
assert "idx_history_deals_time_symbol" in indexes
|
||||||
|
assert "idx_history_deals_symbol_time" in indexes
|
||||||
|
assert "idx_history_orders_time_setup_symbol" in indexes
|
||||||
|
assert "idx_account_snapshots_time_login" in indexes
|
||||||
|
assert "idx_position_snapshots_time_symbol" in indexes
|
||||||
|
assert "idx_order_snapshots_time_symbol" in indexes
|
||||||
|
assert "idx_snapshot_runs_time_status" in indexes
|
||||||
|
|
||||||
|
def test_no_indexes_created_when_tables_absent(
|
||||||
|
self,
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
) -> None:
|
||||||
|
"""No indexes are created when tables are absent."""
|
||||||
|
create_grafana_indexes(conn)
|
||||||
|
indexes = _get_names(conn, "index")
|
||||||
|
assert not any(name.startswith("idx_") for name in indexes)
|
||||||
|
|
||||||
|
def test_indexes_for_snapshot_tables_skipped_when_absent(
|
||||||
|
self,
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
) -> None:
|
||||||
|
"""Snapshot table indexes are skipped when snapshot tables don't exist."""
|
||||||
|
_make_history_deals_full(conn)
|
||||||
|
create_grafana_indexes(conn)
|
||||||
|
indexes = _get_names(conn, "index")
|
||||||
|
assert "idx_account_snapshots_time_login" not in indexes
|
||||||
|
assert "idx_position_snapshots_time_symbol" not in indexes
|
||||||
|
assert "idx_order_snapshots_time_symbol" not in indexes
|
||||||
|
assert "idx_snapshot_runs_time_status" not in indexes
|
||||||
|
|
||||||
|
@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:
|
||||||
|
"""An index is skipped when the source table lacks required columns."""
|
||||||
|
conn.execute(ddl)
|
||||||
|
create_grafana_indexes(conn)
|
||||||
|
assert index_name not in _get_names(conn, "index")
|
||||||
|
|
||||||
|
def test_snapshot_indexes_skipped_when_cols_missing(
|
||||||
|
self,
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
) -> None:
|
||||||
|
"""Snapshot table indexes are skipped when required columns are absent."""
|
||||||
|
conn.execute("CREATE TABLE account_snapshots (foo TEXT)")
|
||||||
|
conn.execute("CREATE TABLE position_snapshots (foo TEXT)")
|
||||||
|
conn.execute("CREATE TABLE order_snapshots (foo TEXT)")
|
||||||
|
conn.execute("CREATE TABLE snapshot_runs (foo TEXT)")
|
||||||
|
create_grafana_indexes(conn)
|
||||||
|
indexes = _get_names(conn, "index")
|
||||||
|
assert "idx_account_snapshots_time_login" not in indexes
|
||||||
|
assert "idx_position_snapshots_time_symbol" not in indexes
|
||||||
|
assert "idx_order_snapshots_time_symbol" not in indexes
|
||||||
|
assert "idx_snapshot_runs_time_status" not in indexes
|
||||||
|
|
||||||
|
def test_indexes_are_idempotent(self, conn: sqlite3.Connection) -> None:
|
||||||
|
"""Creating indexes twice does not raise (IF NOT EXISTS)."""
|
||||||
|
_make_rates_table(conn)
|
||||||
|
create_grafana_indexes(conn)
|
||||||
|
create_grafana_indexes(conn)
|
||||||
|
indexes = _get_names(conn, "index")
|
||||||
|
assert "idx_rates_time_symbol_timeframe" in indexes
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestEnsureGrafanaSchema
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnsureGrafanaSchema:
|
||||||
|
"""Tests for ensure_grafana_schema."""
|
||||||
|
|
||||||
|
def test_creates_all_tables_views_and_indexes(
|
||||||
|
self,
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
) -> None:
|
||||||
|
"""ensure_grafana_schema creates snapshot tables, views, and indexes."""
|
||||||
|
_make_rates_table(conn)
|
||||||
|
_make_history_deals_full(conn)
|
||||||
|
ensure_grafana_schema(conn)
|
||||||
|
tables = _get_names(conn, "table")
|
||||||
|
assert "snapshot_runs" in tables
|
||||||
|
assert "account_snapshots" in tables
|
||||||
|
views = _get_names(conn, "view")
|
||||||
|
assert "grafana_rates" in views
|
||||||
|
assert "grafana_account_snapshots" in views
|
||||||
|
indexes = _get_names(conn, "index")
|
||||||
|
assert "idx_rates_time_symbol_timeframe" in indexes
|
||||||
|
|
||||||
|
def test_is_idempotent(self, conn: sqlite3.Connection) -> None:
|
||||||
|
"""Calling ensure_grafana_schema twice does not raise."""
|
||||||
|
ensure_grafana_schema(conn)
|
||||||
|
ensure_grafana_schema(conn)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestSnapshotInserts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSnapshotInserts:
|
||||||
|
"""Tests for snapshot insert helpers."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup_tables(self, conn: sqlite3.Connection) -> None:
|
||||||
|
"""Create snapshot tables before each insert test."""
|
||||||
|
create_snapshot_tables(conn)
|
||||||
|
|
||||||
|
@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)
|
||||||
|
insert_func(conn, run_id, row)
|
||||||
|
result = conn.execute(select_sql).fetchone()
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
def test_insert_account_snapshot_partial_row(
|
||||||
|
self,
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
) -> None:
|
||||||
|
"""insert_account_snapshot works when some fields are missing (uses None)."""
|
||||||
|
run_id = start_snapshot_run(conn, 1700000000)
|
||||||
|
insert_account_snapshot(conn, run_id, {"login": 1})
|
||||||
|
result = conn.execute(
|
||||||
|
"SELECT login, currency FROM account_snapshots"
|
||||||
|
).fetchone()
|
||||||
|
assert result == (1, None)
|
||||||
|
|
||||||
|
@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_*_snapshots appends each row and is a no-op when empty."""
|
||||||
|
run_id = start_snapshot_run(conn, 1700000000)
|
||||||
|
insert_func(conn, run_id, 12345, rows)
|
||||||
|
count = conn.execute(
|
||||||
|
f"SELECT COUNT(*) FROM {table}" # noqa: S608
|
||||||
|
).fetchone()[0]
|
||||||
|
assert count == expected_count
|
||||||
|
|
||||||
|
@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_order_snapshots stores epoch int, int as-is, or None for unknown."""
|
||||||
|
run_id = start_snapshot_run(conn, 1700000000)
|
||||||
|
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 == expected_stored
|
||||||
|
|
||||||
|
def test_start_snapshot_run_returns_incrementing_ids(
|
||||||
|
self,
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
) -> None:
|
||||||
|
"""start_snapshot_run returns a unique run_id for each call."""
|
||||||
|
run1 = start_snapshot_run(conn, 1700000000)
|
||||||
|
run2 = start_snapshot_run(conn, 1700000000)
|
||||||
|
assert run1 != run2
|
||||||
|
|
||||||
|
@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 optional detail text."""
|
||||||
|
run_id = start_snapshot_run(conn, 1700000000)
|
||||||
|
record_snapshot_run(conn, run_id, status, detail)
|
||||||
|
row = conn.execute("SELECT status, detail FROM snapshot_runs").fetchone()
|
||||||
|
assert row == expected
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestPublishGrafanaCopy
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_source_db(path: Path) -> None:
|
||||||
|
"""Create a minimal source SQLite database with snapshot tables."""
|
||||||
|
with sqlite3.connect(path) as conn:
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
create_snapshot_tables(conn)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO snapshot_runs (observed_at, status) VALUES (?, 'ok')",
|
||||||
|
(1700000000,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPublishGrafanaCopy:
|
||||||
|
"""Tests for publish_grafana_copy."""
|
||||||
|
|
||||||
|
@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 / 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()
|
||||||
|
with sqlite3.connect(target) as conn:
|
||||||
|
tables = {
|
||||||
|
row[0]
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
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."""
|
||||||
|
source = tmp_path / "src.db"
|
||||||
|
target = tmp_path / "grafana.db"
|
||||||
|
_make_source_db(source)
|
||||||
|
publish_grafana_copy(source, target)
|
||||||
|
uri = f"file:{target}?mode=ro"
|
||||||
|
with sqlite3.connect(uri, uri=True) as conn:
|
||||||
|
row = conn.execute("SELECT status FROM snapshot_runs").fetchone()
|
||||||
|
assert row == ("ok",)
|
||||||
|
|
||||||
|
def test_same_path_raises(self, tmp_path: Path) -> None:
|
||||||
|
"""publish_grafana_copy raises ValueError when source equals target."""
|
||||||
|
db = tmp_path / "history.db"
|
||||||
|
_make_source_db(db)
|
||||||
|
with pytest.raises(ValueError, match="must differ from the source"):
|
||||||
|
publish_grafana_copy(db, db)
|
||||||
|
|
||||||
|
def test_source_not_found_raises(self, tmp_path: Path) -> None:
|
||||||
|
"""publish_grafana_copy raises FileNotFoundError when source is absent."""
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
publish_grafana_copy(tmp_path / "missing.db", tmp_path / "out.db")
|
||||||
|
|
||||||
|
def test_preserve_old_target_on_backup_failure(self, tmp_path: Path) -> None:
|
||||||
|
"""Old target is preserved when the backup fails."""
|
||||||
|
source = tmp_path / "src.db"
|
||||||
|
target = tmp_path / "grafana.db"
|
||||||
|
_make_source_db(source)
|
||||||
|
original_content = b"original_data"
|
||||||
|
target.write_bytes(original_content)
|
||||||
|
with patch("sqlite3.connect") as mock_connect:
|
||||||
|
mock_src = MagicMock()
|
||||||
|
mock_src.__enter__ = MagicMock(return_value=mock_src)
|
||||||
|
mock_src.__exit__ = MagicMock(return_value=False)
|
||||||
|
mock_src.backup.side_effect = sqlite3.OperationalError("backup failed")
|
||||||
|
mock_connect.return_value = mock_src
|
||||||
|
with pytest.raises(sqlite3.OperationalError, match="backup failed"):
|
||||||
|
publish_grafana_copy(source, target)
|
||||||
|
assert target.read_bytes() == original_content
|
||||||
|
|
||||||
|
def test_temp_file_cleaned_up_on_failure(self, tmp_path: Path) -> None:
|
||||||
|
"""Temporary file is removed when backup raises an exception."""
|
||||||
|
source = tmp_path / "src.db"
|
||||||
|
target = tmp_path / "grafana.db"
|
||||||
|
_make_source_db(source)
|
||||||
|
with patch("sqlite3.connect") as mock_connect:
|
||||||
|
mock_src = MagicMock()
|
||||||
|
mock_src.__enter__ = MagicMock(return_value=mock_src)
|
||||||
|
mock_src.__exit__ = MagicMock(return_value=False)
|
||||||
|
mock_src.backup.side_effect = sqlite3.OperationalError("fail")
|
||||||
|
mock_connect.return_value = mock_src
|
||||||
|
with pytest.raises(sqlite3.OperationalError):
|
||||||
|
publish_grafana_copy(source, target)
|
||||||
|
tmp_files = list(tmp_path.glob("grafana.db.*.tmp"))
|
||||||
|
assert not tmp_files, "Temp file should be cleaned up on failure"
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
source = tmp_path / "src.db"
|
||||||
|
target = tmp_path / "grafana.db"
|
||||||
|
_make_source_db(source)
|
||||||
|
publish_grafana_copy(source, target)
|
||||||
|
mode = target.stat().st_mode & 0o777
|
||||||
|
assert bool(mode & _stat.S_IRUSR), "owner must be able to read"
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
__import__("sys").platform == "win32",
|
||||||
|
reason="Windows does not support Unix-style group/other permission bits",
|
||||||
|
)
|
||||||
|
def test_overwrite_preserves_existing_target_mode(self, tmp_path: Path) -> None:
|
||||||
|
"""Overwriting an existing target preserves that target's file mode."""
|
||||||
|
source = tmp_path / "src.db"
|
||||||
|
target = tmp_path / "grafana.db"
|
||||||
|
_make_source_db(source)
|
||||||
|
target.write_bytes(b"old")
|
||||||
|
target.chmod(0o640)
|
||||||
|
publish_grafana_copy(source, target)
|
||||||
|
mode = target.stat().st_mode & 0o777
|
||||||
|
assert mode == 0o640
|
||||||
+1378
-380
File diff suppressed because it is too large
Load Diff
+1235
-388
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,312 @@
|
|||||||
|
"""Tests for mt5cli.telemetry module."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
|
||||||
|
|
||||||
|
from mt5cli.telemetry import (
|
||||||
|
_OTEL_AVAILABLE, # type: ignore[reportPrivateUsage]
|
||||||
|
_Mt5Metrics, # type: ignore[reportPrivateUsage]
|
||||||
|
_NoOp, # type: ignore[reportPrivateUsage]
|
||||||
|
configure_metrics,
|
||||||
|
enable_otel_metrics,
|
||||||
|
get_metrics,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoOp:
|
||||||
|
"""Tests for _NoOp no-op instrument."""
|
||||||
|
|
||||||
|
@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()
|
||||||
|
bound = getattr(noop, method)
|
||||||
|
bound(1.0)
|
||||||
|
bound(1.0, {"key": "val"})
|
||||||
|
|
||||||
|
|
||||||
|
class TestMt5Metrics:
|
||||||
|
"""Tests for _Mt5Metrics."""
|
||||||
|
|
||||||
|
def test_default_instruments_are_noop(self) -> None:
|
||||||
|
"""Default _Mt5Metrics methods do not raise before configure is called."""
|
||||||
|
m = _Mt5Metrics()
|
||||||
|
m.record_account_state(
|
||||||
|
login="123",
|
||||||
|
server="demo",
|
||||||
|
balance=1000.0,
|
||||||
|
equity=1050.0,
|
||||||
|
margin=100.0,
|
||||||
|
margin_free=950.0,
|
||||||
|
margin_level=1050.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_configure_calls_meter(self) -> None:
|
||||||
|
"""configure() calls create_histogram, create_counter, create_gauge on meter."""
|
||||||
|
meter = MagicMock()
|
||||||
|
m = _Mt5Metrics()
|
||||||
|
m.configure(meter)
|
||||||
|
assert meter.create_histogram.called
|
||||||
|
assert meter.create_counter.called
|
||||||
|
assert meter.create_gauge.called
|
||||||
|
|
||||||
|
@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 getattr(m, method)(**kwargs):
|
||||||
|
pass
|
||||||
|
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]
|
||||||
|
|
||||||
|
@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)
|
||||||
|
with (
|
||||||
|
pytest.raises(type(exc), match=str(exc)),
|
||||||
|
getattr(m, method)(**kwargs),
|
||||||
|
):
|
||||||
|
raise exc
|
||||||
|
getattr(m, failures_attr).add.assert_called_once_with( # type: ignore[reportPrivateUsage]
|
||||||
|
1,
|
||||||
|
failure_labels,
|
||||||
|
)
|
||||||
|
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."""
|
||||||
|
meter = MagicMock()
|
||||||
|
m = _Mt5Metrics()
|
||||||
|
m.configure(meter)
|
||||||
|
m.add_history_rows(42, dataset="rates")
|
||||||
|
m._history_rows.add.assert_called_once_with( # type: ignore[reportPrivateUsage]
|
||||||
|
42, {"dataset": "rates"}
|
||||||
|
)
|
||||||
|
|
||||||
|
@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)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
@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()
|
||||||
|
with getattr(m, method)(**kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigureMetrics:
|
||||||
|
"""Tests for configure_metrics and get_metrics."""
|
||||||
|
|
||||||
|
def test_configure_metrics_updates_global(self) -> None:
|
||||||
|
"""configure_metrics wires up the global singleton."""
|
||||||
|
meter = MagicMock()
|
||||||
|
configure_metrics(meter)
|
||||||
|
assert get_metrics() is get_metrics()
|
||||||
|
|
||||||
|
def test_get_metrics_returns_mt5metrics(self) -> None:
|
||||||
|
"""get_metrics returns the global _Mt5Metrics instance."""
|
||||||
|
assert isinstance(get_metrics(), _Mt5Metrics)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnableOtelMetrics:
|
||||||
|
"""Tests for enable_otel_metrics."""
|
||||||
|
|
||||||
|
def test_enable_raises_when_unavailable(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""enable_otel_metrics raises ImportError when OTel is not installed."""
|
||||||
|
monkeypatch.setattr("mt5cli.telemetry._OTEL_AVAILABLE", False)
|
||||||
|
with pytest.raises(ImportError, match="opentelemetry-api"):
|
||||||
|
enable_otel_metrics()
|
||||||
|
|
||||||
|
def test_enable_configures_sdk_pipeline_with_readers(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""enable_otel_metrics wires up an SDK MeterProvider with supplied readers."""
|
||||||
|
mock_mod = MagicMock()
|
||||||
|
monkeypatch.setattr("mt5cli.telemetry._OTEL_AVAILABLE", True)
|
||||||
|
monkeypatch.setattr("mt5cli.telemetry._otel_metrics_mod", mock_mod)
|
||||||
|
reader = InMemoryMetricReader()
|
||||||
|
enable_otel_metrics("my-service", readers=[reader])
|
||||||
|
mock_mod.set_meter_provider.assert_called_once()
|
||||||
|
provider = mock_mod.set_meter_provider.call_args[0][0]
|
||||||
|
assert provider.get_meter("my-service") is not None
|
||||||
|
|
||||||
|
def test_enable_default_readers_uses_otlp(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""enable_otel_metrics with no readers creates an OTLP pipeline by default."""
|
||||||
|
mock_mod = MagicMock()
|
||||||
|
monkeypatch.setattr("mt5cli.telemetry._OTEL_AVAILABLE", True)
|
||||||
|
monkeypatch.setattr("mt5cli.telemetry._otel_metrics_mod", mock_mod)
|
||||||
|
monkeypatch.setattr("mt5cli.telemetry._OtelOTLPExporter", MagicMock())
|
||||||
|
enable_otel_metrics("my-service")
|
||||||
|
mock_mod.set_meter_provider.assert_called_once()
|
||||||
|
|
||||||
|
def test_enable_default_readers_raises_when_otlp_missing(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""enable_otel_metrics raises ImportError when the OTLP exporter is missing."""
|
||||||
|
mock_mod = MagicMock()
|
||||||
|
monkeypatch.setattr("mt5cli.telemetry._OTEL_AVAILABLE", True)
|
||||||
|
monkeypatch.setattr("mt5cli.telemetry._otel_metrics_mod", mock_mod)
|
||||||
|
monkeypatch.setattr("mt5cli.telemetry._OtelOTLPExporter", None)
|
||||||
|
with pytest.raises(ImportError, match="opentelemetry-exporter-otlp-proto-http"):
|
||||||
|
enable_otel_metrics()
|
||||||
|
|
||||||
|
def test_otel_available_flag_is_bool(self) -> None:
|
||||||
|
"""_OTEL_AVAILABLE is a boolean."""
|
||||||
|
assert isinstance(_OTEL_AVAILABLE, bool)
|
||||||
+1935
-1127
File diff suppressed because it is too large
Load Diff
+180
-149
@@ -4,21 +4,22 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import sys
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import mt5cli.utils
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from mt5cli.utils import (
|
from mt5cli.utils import (
|
||||||
DATETIME_TYPE,
|
DATETIME_TYPE,
|
||||||
REQUEST_TYPE,
|
REQUEST_TYPE,
|
||||||
TICK_FLAG_MAP,
|
|
||||||
TICK_FLAGS_TYPE,
|
TICK_FLAGS_TYPE,
|
||||||
TIMEFRAME_MAP,
|
|
||||||
TIMEFRAME_TYPE,
|
TIMEFRAME_TYPE,
|
||||||
Dataset,
|
Dataset,
|
||||||
IfExists,
|
IfExists,
|
||||||
@@ -88,39 +89,56 @@ class TestExportDataframe:
|
|||||||
"""Create a sample DataFrame for testing."""
|
"""Create a sample DataFrame for testing."""
|
||||||
return pd.DataFrame({"a": [1, 2, 3], "b": ["x", "y", "z"]})
|
return pd.DataFrame({"a": [1, 2, 3], "b": ["x", "y", "z"]})
|
||||||
|
|
||||||
def test_export_csv(self, tmp_path: Path, sample_df: pd.DataFrame) -> None:
|
@pytest.mark.parametrize(
|
||||||
"""Test CSV export."""
|
("filename", "output_format", "reader"),
|
||||||
output = tmp_path / "out.csv"
|
[
|
||||||
export_dataframe(sample_df, output, "csv")
|
("out.csv", "csv", "csv"),
|
||||||
result = pd.read_csv(output)
|
("out.json", "json", "json"),
|
||||||
pd.testing.assert_frame_equal(result, sample_df)
|
("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_json(self, tmp_path: Path, sample_df: pd.DataFrame) -> None:
|
def test_export_parquet_without_pyarrow(
|
||||||
"""Test JSON export."""
|
self,
|
||||||
output = tmp_path / "out.json"
|
tmp_path: Path,
|
||||||
export_dataframe(sample_df, output, "json")
|
sample_df: pd.DataFrame,
|
||||||
with output.open() as f:
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
records = json.load(f)
|
) -> None:
|
||||||
assert len(records) == 3
|
"""Test that a clear error is raised when pyarrow is not installed."""
|
||||||
assert records[0]["a"] == 1
|
monkeypatch.setitem(sys.modules, "pyarrow", None)
|
||||||
|
with pytest.raises(ImportError, match="mt5cli\\[parquet\\]"):
|
||||||
def test_export_parquet(self, tmp_path: Path, sample_df: pd.DataFrame) -> None:
|
export_dataframe(sample_df, tmp_path / "out.parquet", "parquet")
|
||||||
"""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)
|
|
||||||
|
|
||||||
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(
|
def test_unsupported_format_raises(
|
||||||
self,
|
self,
|
||||||
@@ -135,13 +153,41 @@ class TestExportDataframe:
|
|||||||
class TestExportDataframeToSqlite:
|
class TestExportDataframeToSqlite:
|
||||||
"""Tests for export_dataframe_to_sqlite."""
|
"""Tests for export_dataframe_to_sqlite."""
|
||||||
|
|
||||||
def test_append_preserves_existing_rows(self, tmp_path: Path) -> None:
|
@pytest.mark.parametrize(
|
||||||
"""Test append mode keeps prior rows in the SQLite table."""
|
("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"
|
output = tmp_path / "append.db"
|
||||||
first = pd.DataFrame({"id": [1], "value": ["a"]})
|
first = pd.DataFrame({"id": [1], "value": ["a"]})
|
||||||
second = pd.DataFrame({"id": [2], "value": ["b"]})
|
second = pd.DataFrame({"id": [2], "value": ["b"]})
|
||||||
export_dataframe_to_sqlite(first, output, "items", if_exists=IfExists.REPLACE)
|
if first_if_exists is None:
|
||||||
export_dataframe_to_sqlite(second, output, "items", if_exists=IfExists.APPEND)
|
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:
|
with sqlite3.connect(output) as conn:
|
||||||
result = pd.read_sql( # type: ignore[reportUnknownMemberType]
|
result = pd.read_sql( # type: ignore[reportUnknownMemberType]
|
||||||
"SELECT id, value FROM items ORDER BY id",
|
"SELECT id, value FROM items ORDER BY id",
|
||||||
@@ -193,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:
|
def test_writes_index_with_label(self, tmp_path: Path) -> None:
|
||||||
"""Test optional index export with a custom label."""
|
"""Test optional index export with a custom label."""
|
||||||
output = tmp_path / "index.db"
|
output = tmp_path / "index.db"
|
||||||
@@ -246,15 +272,17 @@ class TestExportDataframeToSqlite:
|
|||||||
class TestParseDatetime:
|
class TestParseDatetime:
|
||||||
"""Tests for parse_datetime."""
|
"""Tests for parse_datetime."""
|
||||||
|
|
||||||
def test_valid_date(self) -> None:
|
@pytest.mark.parametrize(
|
||||||
"""Test parsing a date string."""
|
("value", "expected"),
|
||||||
result = parse_datetime("2024-01-15")
|
[
|
||||||
assert result == datetime(2024, 1, 15, tzinfo=UTC)
|
("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)),
|
||||||
def test_valid_datetime_with_tz(self) -> None:
|
],
|
||||||
"""Test parsing a datetime with timezone."""
|
ids=["date", "datetime-with-tz"],
|
||||||
result = parse_datetime("2024-01-15T12:00:00+00:00")
|
)
|
||||||
assert result == datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC)
|
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:
|
def test_invalid_format_raises(self) -> None:
|
||||||
"""Test that invalid format raises ValueError."""
|
"""Test that invalid format raises ValueError."""
|
||||||
@@ -267,26 +295,29 @@ class TestParseTimeframe:
|
|||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("value", "expected"),
|
("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:
|
def test_valid_timeframe(self, value: str | int, expected: int) -> None:
|
||||||
"""Test parsing named timeframes."""
|
"""Test parsing valid string and integer timeframe values."""
|
||||||
assert parse_timeframe(value) == expected
|
assert parse_timeframe(value) == expected
|
||||||
|
|
||||||
def test_integer_timeframe(self) -> None:
|
@pytest.mark.parametrize(
|
||||||
"""Test parsing supported integer timeframes."""
|
"value",
|
||||||
assert parse_timeframe("1") == 1
|
["42", "INVALID"],
|
||||||
assert parse_timeframe(16385) == 16385
|
ids=["unsupported-integer", "invalid-string"],
|
||||||
|
)
|
||||||
def test_unsupported_integer_timeframe_raises(self) -> None:
|
def test_invalid_timeframe_raises(self, value: str) -> None:
|
||||||
"""Test that unsupported integer timeframes raise ValueError."""
|
"""Test that invalid timeframe values raise ValueError."""
|
||||||
with pytest.raises(ValueError, match="Invalid timeframe"):
|
with pytest.raises(ValueError, match="Invalid timeframe"):
|
||||||
parse_timeframe("42")
|
parse_timeframe(value)
|
||||||
|
|
||||||
def test_invalid_timeframe_raises(self) -> None:
|
|
||||||
"""Test that invalid timeframe raises ValueError."""
|
|
||||||
with pytest.raises(ValueError, match="Invalid timeframe"):
|
|
||||||
parse_timeframe("INVALID")
|
|
||||||
|
|
||||||
|
|
||||||
class TestParseTickFlags:
|
class TestParseTickFlags:
|
||||||
@@ -294,26 +325,29 @@ class TestParseTickFlags:
|
|||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("value", "expected"),
|
("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:
|
def test_valid_flag(self, value: str | int, expected: int) -> None:
|
||||||
"""Test parsing named tick flags."""
|
"""Test parsing valid string and integer tick flag values."""
|
||||||
assert parse_tick_flags(value) == expected
|
assert parse_tick_flags(value) == expected
|
||||||
|
|
||||||
def test_integer_flag(self) -> None:
|
@pytest.mark.parametrize(
|
||||||
"""Test parsing supported integer tick flags."""
|
"value",
|
||||||
assert parse_tick_flags("-1") == -1
|
["7", "INVALID"],
|
||||||
assert parse_tick_flags(2) == 2
|
ids=["unsupported-integer", "invalid-string"],
|
||||||
|
)
|
||||||
def test_unsupported_integer_flag_raises(self) -> None:
|
def test_invalid_flag_raises(self, value: str) -> None:
|
||||||
"""Test that unsupported integer tick flags raise ValueError."""
|
"""Test that invalid tick flag values raise ValueError."""
|
||||||
with pytest.raises(ValueError, match="Invalid tick flags"):
|
with pytest.raises(ValueError, match="Invalid tick flags"):
|
||||||
parse_tick_flags("7")
|
parse_tick_flags(value)
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -336,15 +370,17 @@ class TestParseRequest:
|
|||||||
result = parse_request(f"@{path}")
|
result = parse_request(f"@{path}")
|
||||||
assert result == {"action": 2}
|
assert result == {"action": 2}
|
||||||
|
|
||||||
def test_invalid_json_raises(self) -> None:
|
@pytest.mark.parametrize(
|
||||||
"""Test that invalid JSON raises ValueError."""
|
("value", "match"),
|
||||||
with pytest.raises(ValueError, match="Invalid JSON request"):
|
[
|
||||||
parse_request("not json")
|
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_non_object_raises(self) -> None:
|
],
|
||||||
"""Test that a non-object JSON raises ValueError."""
|
)
|
||||||
with pytest.raises(ValueError, match="must be a JSON object"):
|
def test_invalid_request_raises(self, value: str, match: str) -> None:
|
||||||
parse_request("[1, 2, 3]")
|
"""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:
|
def test_missing_file_raises(self, tmp_path: Path) -> None:
|
||||||
"""Test that a missing request file raises ValueError."""
|
"""Test that a missing request file raises ValueError."""
|
||||||
@@ -361,17 +397,10 @@ class TestParseRequest:
|
|||||||
class TestConstants:
|
class TestConstants:
|
||||||
"""Tests for module constants."""
|
"""Tests for module constants."""
|
||||||
|
|
||||||
def test_timeframe_map_has_expected_keys(self) -> None:
|
@pytest.mark.parametrize("name", ["TIMEFRAME_MAP", "TICK_FLAG_MAP"])
|
||||||
"""Test that TIMEFRAME_MAP contains standard timeframes."""
|
def test_private_maps_absent_from_utils(self, name: str) -> None:
|
||||||
for key in ("M1", "M5", "M15", "M30", "H1", "H4", "D1", "W1", "MN1"):
|
"""Private pdmt5 maps are not exposed as public mt5cli.utils attributes."""
|
||||||
assert key in TIMEFRAME_MAP
|
assert not hasattr(mt5cli.utils, name)
|
||||||
|
|
||||||
def test_tick_flag_map_has_expected_keys(self) -> None:
|
|
||||||
"""Test that TICK_FLAG_MAP contains standard flags with MT5 values."""
|
|
||||||
assert {"ALL", "INFO", "TRADE"} <= set(TICK_FLAG_MAP)
|
|
||||||
assert TICK_FLAG_MAP["ALL"] == -1
|
|
||||||
assert TICK_FLAG_MAP["INFO"] == 1
|
|
||||||
assert TICK_FLAG_MAP["TRADE"] == 2
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("dataset", "expected"),
|
("dataset", "expected"),
|
||||||
@@ -414,23 +443,24 @@ class TestDateTimeType:
|
|||||||
class TestTimeframeType:
|
class TestTimeframeType:
|
||||||
"""Tests for _TimeframeType."""
|
"""Tests for _TimeframeType."""
|
||||||
|
|
||||||
def test_convert_string(self) -> None:
|
@pytest.mark.parametrize(
|
||||||
"""Test converting a string to timeframe integer."""
|
("value", "expected"),
|
||||||
assert TIMEFRAME_TYPE.convert("H1", None, None) == 16385
|
[("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:
|
@pytest.mark.parametrize(
|
||||||
"""Test converting supported integer timeframe values."""
|
"value",
|
||||||
assert TIMEFRAME_TYPE.convert(16385, None, None) == 16385
|
[42, "bad"],
|
||||||
|
ids=["unsupported-int", "invalid-string"],
|
||||||
def test_convert_unsupported_int(self) -> None:
|
)
|
||||||
"""Test that unsupported integer values raise BadParameter."""
|
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"):
|
with pytest.raises(Exception, match="Invalid timeframe"):
|
||||||
TIMEFRAME_TYPE.convert(42, None, None)
|
TIMEFRAME_TYPE.convert(value, 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)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", [True, False, None, 1.5])
|
@pytest.mark.parametrize("value", [True, False, None, 1.5])
|
||||||
def test_convert_invalid_types(self, value: object) -> None:
|
def test_convert_invalid_types(self, value: object) -> None:
|
||||||
@@ -442,18 +472,24 @@ class TestTimeframeType:
|
|||||||
class TestTickFlagsType:
|
class TestTickFlagsType:
|
||||||
"""Tests for _TickFlagsType."""
|
"""Tests for _TickFlagsType."""
|
||||||
|
|
||||||
def test_convert_string(self) -> None:
|
@pytest.mark.parametrize(
|
||||||
"""Test converting a string to tick flags integer."""
|
("value", "expected"),
|
||||||
assert TICK_FLAGS_TYPE.convert("ALL", None, None) == -1
|
[("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:
|
@pytest.mark.parametrize(
|
||||||
"""Test converting supported integer tick flag values."""
|
"value",
|
||||||
assert TICK_FLAGS_TYPE.convert(2, None, None) == 2
|
[7, "bad"],
|
||||||
|
ids=["unsupported-int", "invalid-string"],
|
||||||
def test_convert_unsupported_int(self) -> None:
|
)
|
||||||
"""Test that unsupported integer values raise BadParameter."""
|
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"):
|
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])
|
@pytest.mark.parametrize("value", [True, False, None, 1.5])
|
||||||
def test_convert_invalid_types(self, value: object) -> None:
|
def test_convert_invalid_types(self, value: object) -> None:
|
||||||
@@ -461,11 +497,6 @@ class TestTickFlagsType:
|
|||||||
with pytest.raises(Exception, match="Invalid tick flags"):
|
with pytest.raises(Exception, match="Invalid tick flags"):
|
||||||
TICK_FLAGS_TYPE.convert(value, None, None)
|
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:
|
class TestRequestType:
|
||||||
"""Tests for _RequestType."""
|
"""Tests for _RequestType."""
|
||||||
|
|||||||
@@ -223,6 +223,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" },
|
{ url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "googleapis-common-protos"
|
||||||
|
version = "1.75.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "protobuf" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "griffelib"
|
name = "griffelib"
|
||||||
version = "2.0.2"
|
version = "2.0.2"
|
||||||
@@ -355,15 +367,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "metatrader5"
|
name = "metatrader5"
|
||||||
version = "5.0.5640"
|
version = "5.0.5735"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy", marker = "sys_platform == 'win32'" },
|
{ name = "numpy" },
|
||||||
]
|
]
|
||||||
wheels = [
|
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/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/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/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/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/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]]
|
[[package]]
|
||||||
@@ -487,21 +499,35 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mt5cli"
|
name = "mt5cli"
|
||||||
version = "0.9.2"
|
version = "1.1.3"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
{ name = "pandas" },
|
||||||
{ name = "pdmt5" },
|
{ name = "pdmt5" },
|
||||||
{ name = "pyarrow" },
|
{ name = "pydantic" },
|
||||||
{ name = "typer" },
|
{ name = "typer" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
otel = [
|
||||||
|
{ name = "opentelemetry-api" },
|
||||||
|
{ name = "opentelemetry-exporter-otlp-proto-http" },
|
||||||
|
{ name = "opentelemetry-sdk" },
|
||||||
|
]
|
||||||
|
parquet = [
|
||||||
|
{ name = "pyarrow" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "mkdocs" },
|
{ name = "mkdocs" },
|
||||||
{ name = "mkdocs-material" },
|
{ name = "mkdocs-material" },
|
||||||
{ name = "mkdocstrings", extra = ["python"] },
|
{ name = "mkdocstrings", extra = ["python"] },
|
||||||
|
{ name = "opentelemetry-api" },
|
||||||
|
{ name = "opentelemetry-sdk" },
|
||||||
{ name = "pandas-stubs" },
|
{ name = "pandas-stubs" },
|
||||||
|
{ name = "pyarrow" },
|
||||||
{ name = "pymdown-extensions" },
|
{ name = "pymdown-extensions" },
|
||||||
{ name = "pyright" },
|
{ name = "pyright" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
@@ -513,17 +539,26 @@ dev = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "click", specifier = ">=8.1.0" },
|
{ name = "click", specifier = ">=8.1.0" },
|
||||||
{ name = "pdmt5", specifier = ">=0.3.0" },
|
{ name = "opentelemetry-api", marker = "extra == 'otel'" },
|
||||||
{ name = "pyarrow", specifier = ">=19.0.0" },
|
{ name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'otel'" },
|
||||||
|
{ name = "opentelemetry-sdk", marker = "extra == 'otel'" },
|
||||||
|
{ 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" },
|
{ name = "typer", specifier = ">=0.15.0" },
|
||||||
]
|
]
|
||||||
|
provides-extras = ["parquet", "otel"]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "mkdocs", specifier = ">=1.6.1" },
|
{ name = "mkdocs", specifier = ">=1.6.1" },
|
||||||
{ name = "mkdocs-material", specifier = ">=9.7.6" },
|
{ name = "mkdocs-material", specifier = ">=9.7.6" },
|
||||||
{ name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.4" },
|
{ name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.4" },
|
||||||
|
{ name = "opentelemetry-api" },
|
||||||
|
{ name = "opentelemetry-sdk" },
|
||||||
{ name = "pandas-stubs", specifier = ">=2.2.3.250527" },
|
{ name = "pandas-stubs", specifier = ">=2.2.3.250527" },
|
||||||
|
{ name = "pyarrow", specifier = ">=19.0.0" },
|
||||||
{ name = "pymdown-extensions", specifier = ">=10.21.2" },
|
{ name = "pymdown-extensions", specifier = ">=10.21.2" },
|
||||||
{ name = "pyright", specifier = ">=1.1.407" },
|
{ name = "pyright", specifier = ">=1.1.407" },
|
||||||
{ name = "pytest", specifier = ">=9.0.3" },
|
{ name = "pytest", specifier = ">=9.0.3" },
|
||||||
@@ -599,6 +634,87 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/57/a7/b35835e278c18b85206834b3aa3abe68e77a98769c59233d1f6300284781/numpy-2.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b42639cdde6d24e732ff823a3fa5b701d8acad89c4142bc1d0bd6dc85200ba5", size = 12504685, upload-time = "2026-03-09T07:58:50.525Z" },
|
{ url = "https://files.pythonhosted.org/packages/57/a7/b35835e278c18b85206834b3aa3abe68e77a98769c59233d1f6300284781/numpy-2.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b42639cdde6d24e732ff823a3fa5b701d8acad89c4142bc1d0bd6dc85200ba5", size = 12504685, upload-time = "2026-03-09T07:58:50.525Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-api"
|
||||||
|
version = "1.43.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ae/cc/e4c9584181f86494df0f6bdec1a4f3280c50db44704dc2a407e994fc87bb/opentelemetry_api-1.43.0.tar.gz", hash = "sha256:107d0d03857ea8fc7c5fcbbbd83f800c281f0d560553d61c1d675fccfd1761c1", size = 73476, upload-time = "2026-06-24T15:19:55.323Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/83/6dba32b85f31868400440dc7ad2ca1eab94cbbf3a7b0459ed39f8311a9e2/opentelemetry_api-1.43.0-py3-none-any.whl", hash = "sha256:20acf45e9b21851926835292e4045d290acade1edd2ff3de86d2f069687ba1fd", size = 61912, upload-time = "2026-06-24T15:19:35.434Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-exporter-otlp-proto-common"
|
||||||
|
version = "1.43.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "opentelemetry-proto" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/55/c1/e8098490ab15abf116dcaf9fa89ededcb35547c7d08d4b5a62f573dc1e63/opentelemetry_exporter_otlp_proto_common-1.43.0.tar.gz", hash = "sha256:c4e32ba6d6b13bdb2b8f6764c4fd28d00192826561aa04f6d14eedfce7ac076f", size = 20197, upload-time = "2026-06-24T15:20:00.247Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/b2/41ebc74ae1d5859901f1b69305de58724bf043381103d6ef413521cbc35a/opentelemetry_exporter_otlp_proto_common-1.43.0-py3-none-any.whl", hash = "sha256:123c3f9cc87218562490c63b36f497bf3a722faf174a515d1443f31ababa6264", size = 17048, upload-time = "2026-06-24T15:19:41.264Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-exporter-otlp-proto-http"
|
||||||
|
version = "1.43.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "googleapis-common-protos" },
|
||||||
|
{ name = "opentelemetry-api" },
|
||||||
|
{ name = "opentelemetry-exporter-otlp-proto-common" },
|
||||||
|
{ name = "opentelemetry-proto" },
|
||||||
|
{ name = "opentelemetry-sdk" },
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fc/92/0b9f56412483a8891d4843890294796c9df8ab42417bd9bad8035d840cb3/opentelemetry_exporter_otlp_proto_http-1.43.0.tar.gz", hash = "sha256:fa8a42bb7d00ee5391f4c0b04d8e6a46c03caa437903296ab73a81dc11ba118f", size = 25406, upload-time = "2026-06-24T15:20:01.515Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/20/b685ed7af2e17c29ffc8af56f1fa8bc2033258fc30fb0d2b722f49d13ba0/opentelemetry_exporter_otlp_proto_http-1.43.0-py3-none-any.whl", hash = "sha256:647f603aa8efdbdb4dbff842e0729d0406a6fff26b295a72d3d60e7d963b2610", size = 21795, upload-time = "2026-06-24T15:19:43.164Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-proto"
|
||||||
|
version = "1.43.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "protobuf" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e0/b9/d357faefb40bda1d4799913e6af611171ff22a2dedcb93576bc92242d056/opentelemetry_proto-1.43.0.tar.gz", hash = "sha256:224778df17e1f3fafeaaa21d874236ca5f6ffc2f86e0899298ec7351aac27924", size = 46481, upload-time = "2026-06-24T15:20:07.625Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/a7/3e5308cf548b8f72529c7db1afdb3a404211982376a12927fd7759f77bf3/opentelemetry_proto-1.43.0-py3-none-any.whl", hash = "sha256:c58f1f7ef84bc7dc2834016c0c37fe0081dde7ca9f6339be1970fbf9cdaaa90d", size = 72489, upload-time = "2026-06-24T15:19:51.164Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-sdk"
|
||||||
|
version = "1.43.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "opentelemetry-api" },
|
||||||
|
{ name = "opentelemetry-semantic-conventions" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3e/eb/5041074274ac0956b03637cc039d434569112468e875eddfcc9a0674ce06/opentelemetry_sdk-1.43.0.tar.gz", hash = "sha256:d8187c81c162df9913e4003dd6485f7390d9a24fc17026ec7387b8b8218b08e9", size = 254744, upload-time = "2026-06-24T15:20:08.467Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/e3/b17be23af124201c9f52eececd4cc8ddfed1597d37b4ee771895d325805c/opentelemetry_sdk-1.43.0-py3-none-any.whl", hash = "sha256:d1323a547c1ce69d6a069a17a44b7da82bb8b332051ecb074041f87642c86823", size = 178852, upload-time = "2026-06-24T15:19:52.169Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-semantic-conventions"
|
||||||
|
version = "0.64b0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "opentelemetry-api" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5a/30/5f26df29509eccd86b99b481ac9ffa39da49ba9577cc69071c552ae30447/opentelemetry_semantic_conventions-0.64b0.tar.gz", hash = "sha256:72f76fb2d1582d9d033dd1fcd84532e961e6ff3d90d24ba6fabc72975a83864c", size = 148340, upload-time = "2026-06-24T15:20:09.267Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/ca/23ba87a221b574a7c5a99d48849d80bfe8b047624681357e2b002e566187/opentelemetry_semantic_conventions-0.64b0-py3-none-any.whl", hash = "sha256:ea77e85e354b8f604ddbe5f3d9135216f982fa4d77e5859ac30f6d8a50505aa6", size = 203713, upload-time = "2026-06-24T15:19:53.339Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "26.0"
|
version = "26.0"
|
||||||
@@ -619,46 +735,46 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pandas"
|
name = "pandas"
|
||||||
version = "3.0.1"
|
version = "3.0.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy" },
|
{ name = "numpy" },
|
||||||
{ name = "python-dateutil" },
|
{ name = "python-dateutil" },
|
||||||
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
|
{ 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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]]
|
[[package]]
|
||||||
@@ -684,16 +800,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdmt5"
|
name = "pdmt5"
|
||||||
version = "0.3.0"
|
version = "1.1.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "metatrader5", marker = "sys_platform == 'win32'" },
|
{ name = "metatrader5", marker = "sys_platform == 'win32'" },
|
||||||
{ name = "pandas" },
|
{ name = "pandas" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/cc/c8fa3a01e0e34178fec8527992f7bb8eda5881477ce23aaacaa9b2ef7bec/pdmt5-0.3.0.tar.gz", hash = "sha256:bb612d5c2695eafac9b2a7b74756e13bd383d7e5517bd90c9a2efa92492c484c", size = 215100, upload-time = "2026-06-11T13:26:46.976Z" }
|
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 = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/03/b12cc4c9db983d971c9172b3765161b6d91136d0624e6718a04dd815e7a1/pdmt5-0.3.0-py3-none-any.whl", hash = "sha256:5388b406cc583202600cfe22c9d781679b1d931b1ed5a2b5dcf37c566149b49f", size = 26250, upload-time = "2026-06-11T13:26:45.689Z" },
|
{ 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]]
|
[[package]]
|
||||||
@@ -714,6 +830,21 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "protobuf"
|
||||||
|
version = "7.35.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyarrow"
|
name = "pyarrow"
|
||||||
version = "23.0.1"
|
version = "23.0.1"
|
||||||
@@ -752,7 +883,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.12.5"
|
version = "2.13.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "annotated-types" },
|
{ name = "annotated-types" },
|
||||||
@@ -760,78 +891,81 @@ dependencies = [
|
|||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
{ name = "typing-inspection" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
name = "pydantic-core"
|
name = "pydantic-core"
|
||||||
version = "2.41.5"
|
version = "2.46.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "typing-extensions" },
|
{ 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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user