Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18df96872b | |||
| 5b1d54bfe9 | |||
| ad9e513253 | |||
| 334f01b647 | |||
| 1b69e8f08e | |||
| 9957b0a1de | |||
| b2bb2ad0a0 | |||
| 756faf747b | |||
| c4232bf44d | |||
| 5b44318d55 | |||
| 7f70073301 | |||
| da74c11087 | |||
| c45efb953c | |||
| c1eea3fa3d | |||
| 62e5f438f0 | |||
| 50f62bca73 | |||
| 0b7dfdc621 | |||
| bab776e700 | |||
| 9bf9a6a72c | |||
| d7594ddc43 | |||
| b8ce76c9d6 | |||
| 5750625e14 | |||
| 2cf4d1cf6b |
@@ -1,25 +1,22 @@
|
||||
# local-qa
|
||||
---
|
||||
name: local-qa
|
||||
description: Run local QA including formatting, linting, and testing for the repository. Use whenever any file has been updated.
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
Run local QA checks (format, lint, test) on the repository.
|
||||
# Local QA (format, lint, and test)
|
||||
|
||||
## When to use
|
||||
Run the local QA script `scripts/qa.sh` in this skill.
|
||||
|
||||
After making changes to repository files, run `scripts/qa.sh` to validate formatting, linting, and tests.
|
||||
## Procedure
|
||||
|
||||
## Steps
|
||||
|
||||
1. Execute `scripts/qa.sh` and capture the results.
|
||||
2. Report successes, failures, warnings, and any modified files.
|
||||
|
||||
## If tools are missing
|
||||
|
||||
Install them following this priority order:
|
||||
|
||||
1. Project package managers (`uv`, `poetry`, npm scripts)
|
||||
2. System package managers (`brew`, `apt`)
|
||||
3. Language-specific installers (`pipx`, `pip`, `npm`, `go install`)
|
||||
|
||||
## Constraints
|
||||
|
||||
- Only execute QA and tool-installation commands.
|
||||
- If installation fails or requires unavailable privileges, report the attempt and exact failure, then stop.
|
||||
- Execute the script exactly as shown above when this skill is triggered.
|
||||
- Capture and summarize key output (success/failure, major warnings, and any files modified).
|
||||
- If the script fails due to missing tooling (`command not found`, missing executable, or equivalent), install the missing tool(s) and rerun `./scripts/qa.sh`.
|
||||
- Install tools using this order of preference:
|
||||
1. Use the project's package manager when applicable (`uv`/`poetry` for Python, package manager scripts/dependencies for Node.js).
|
||||
2. Use a system package manager (`brew` on macOS, `apt` on Debian/Ubuntu) when project-local install is not applicable.
|
||||
3. Use language-specific installers as fallback (`pipx`/`pip`, `npm`, `go install`, etc.).
|
||||
- If multiple tools are missing, repeat install -> rerun until QA completes or you hit a blocker.
|
||||
- If installation fails or requires unavailable privileges, report what was attempted, the exact failure, and stop.
|
||||
- Do not run unrelated commands; only run commands needed for QA and missing-tool installation.
|
||||
|
||||
@@ -13,7 +13,14 @@ uv run pytest
|
||||
npx -y prettier --write './**/*.md'
|
||||
|
||||
# GitHub Actions
|
||||
zizmor --fix=safe .github/workflows
|
||||
git ls-files -z -- '.github/workflows/*.yml' | xargs -0 -t actionlint
|
||||
git ls-files -z -- '.github/workflows/*.yml' | xargs -0 -t yamllint -d '{"extends": "relaxed", "rules": {"line-length": "disable"}}'
|
||||
checkov --framework=all --output=github_failed_only --directory=.
|
||||
case "${OSTYPE}" in
|
||||
darwin* | linux* )
|
||||
zizmor --fix=safe .github/workflows
|
||||
git ls-files -z -- '.github/workflows/*.yml' | xargs -0 -t actionlint
|
||||
git ls-files -z -- '.github/workflows/*.yml' | xargs -0 -t yamllint -d '{"extends": "relaxed", "rules": {"line-length": "disable"}}'
|
||||
checkov --framework=all --output=github_failed_only --directory=.
|
||||
;;
|
||||
* )
|
||||
echo "GitHub Actions linting is only supported on Linux and macOS."
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|| (github.event_name == 'workflow_dispatch' && inputs.workflow == 'lint-and-test')
|
||||
permissions:
|
||||
contents: read
|
||||
uses: dceoy/gh-actions-for-devops/.github/workflows/python-package-lint-and-scan.yml@main # zizmor: ignore[unpinned-uses]
|
||||
uses: dceoy/gh-actions-for-devops/.github/workflows/python-package-lint-and-scan.yml@main # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
package-path: .
|
||||
runs-on: windows-latest
|
||||
@@ -59,23 +59,9 @@ jobs:
|
||||
uses: dceoy/gh-actions-for-devops/.github/workflows/python-package-mkdocs-gh-deploy.yml@main # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
package-path: .
|
||||
mkdocs-theme: material
|
||||
runs-on: ubuntu-slim
|
||||
secrets:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
python-package-release:
|
||||
if: >
|
||||
github.event_name == 'push'
|
||||
|| (github.event_name == 'workflow_dispatch' && inputs.workflow == 'lint-and-test')
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
uses: dceoy/gh-actions-for-devops/.github/workflows/python-package-release-on-pypi-and-github.yml@main # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
package-path: .
|
||||
create-releases: false
|
||||
secrets:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
dependabot-auto-merge:
|
||||
if: >
|
||||
github.event_name == 'pull_request' && github.actor == 'dependabot[bot]'
|
||||
|
||||
@@ -9,84 +9,34 @@ defaults:
|
||||
shell: bash -euo pipefail {0}
|
||||
working-directory: .
|
||||
jobs:
|
||||
build:
|
||||
build-and-release:
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
uses: dceoy/gh-actions-for-devops/.github/workflows/python-package-release-on-pypi-and-github.yml@main # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
package-path: .
|
||||
create-releases: false
|
||||
create-github-release: true
|
||||
publish-to-pypi: false
|
||||
secrets:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
github-release:
|
||||
name: Sign the Python 🐍 distribution 📦 with Sigstore and upload them to GitHub Release
|
||||
if: >
|
||||
startsWith(github.ref, 'refs/tags/')
|
||||
needs:
|
||||
- build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # IMPORTANT: mandatory for making GitHub Releases
|
||||
id-token: write # IMPORTANT: mandatory for sigstore
|
||||
steps:
|
||||
- name: Download all the dists
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: ${{ needs.build.outputs.distribution-artifact-name }}
|
||||
path: dist/
|
||||
- name: Validate the version-tag consistency
|
||||
env:
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
NEEDS_BUILD_OUTPUTS_PROJECT_NAME: ${{ needs.build.outputs.project-name }}
|
||||
run: |
|
||||
v="$( \
|
||||
find dist -type f -name "${NEEDS_BUILD_OUTPUTS_PROJECT_NAME}-*" -exec basename {} \; \
|
||||
| head -n 1 \
|
||||
| cut -d '-' -f 2 \
|
||||
)"
|
||||
v="${v%.tar.gz}"
|
||||
if [[ "${TAG_NAME}" != "${v}" ]] && [[ "${TAG_NAME}" != "v${v}" ]]; then
|
||||
echo "The tag (${TAG_NAME}) is inconsistent with the version (${v})." && exit 1
|
||||
fi
|
||||
- name: Sign the dists with Sigstore
|
||||
uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
|
||||
with:
|
||||
inputs: >-
|
||||
./dist/*.whl
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }} # zizmor: ignore[secrets-outside-env] caller-provided secret
|
||||
run: |
|
||||
gh release create "${GITHUB_REF_NAME}" --repo "${REPOSITORY}" --generate-notes --verify-tag
|
||||
- name: Upload artifact signatures to GitHub Release
|
||||
env:
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }} # zizmor: ignore[secrets-outside-env] caller-provided secret
|
||||
# Upload to GitHub Release using the `gh` CLI.
|
||||
# `dist/` contains the built packages, and the
|
||||
# sigstore-produced signatures and certificates.
|
||||
run: |
|
||||
gh release upload "${GITHUB_REF_NAME}" dist/** --repo "${REPOSITORY}"
|
||||
publish-to-pypi:
|
||||
name: Publish the Python 🐍 distribution 📦 to PyPI
|
||||
if: >
|
||||
startsWith(github.ref, 'refs/tags/')
|
||||
needs:
|
||||
- build
|
||||
- github-release
|
||||
- build-and-release
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/p/${{ needs.build.outputs.project-name }}
|
||||
url: https://pypi.org/p/${{ needs.build-and-release.outputs.project-name }}
|
||||
permissions:
|
||||
id-token: write # IMPORTANT: mandatory for trusted publishing
|
||||
steps:
|
||||
- name: Download all the dists
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: ${{ needs.build.outputs.distribution-artifact-name }}
|
||||
name: ${{ needs.build-and-release.outputs.distribution-artifact-name }}
|
||||
path: dist/
|
||||
- name: Publish distribution 📦 to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
|
||||
@@ -29,9 +29,11 @@ uv sync
|
||||
- `mt5cli/`: Main package directory
|
||||
- `__init__.py`: Package initialization and exports (`detect_format`, `export_dataframe`)
|
||||
- `cli.py`: CLI application with typer-based commands for data export
|
||||
- `utils.py`: Constants, enums, parameter types, parsers, and export utilities
|
||||
- `__main__.py`: Entry point for `python -m mt5cli`
|
||||
- `tests/`: Comprehensive test suite (pytest-based)
|
||||
- `test_cli.py`: Tests for CLI commands, parameter types, and export functions
|
||||
- `test_cli.py`: Tests for CLI commands and collect-history behavior
|
||||
- `test_utils.py`: Tests for utility constants, parameter types, parsers, and export functions
|
||||
- `docs/`: MkDocs documentation with API reference
|
||||
- `docs/index.md`: Main documentation
|
||||
- `docs/api/`: Auto-generated API documentation for all modules
|
||||
|
||||
@@ -13,6 +13,7 @@ Built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data han
|
||||
- **Comprehensive data access**: Rates, ticks, account info, symbols, orders, positions, and trading history
|
||||
- **Flexible timeframes**: Named timeframes (M1, H1, D1, etc.) and numeric values
|
||||
- **Connection management**: Optional credentials, server, and timeout configuration
|
||||
- **SQLite rate loading**: Load mt5cli-managed rate tables/views for offline workflows
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -50,21 +51,112 @@ python -m mt5cli -o account.csv account-info
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
| ---------------- | ---------------------------------- |
|
||||
| `rates-from` | Export rates from a start date |
|
||||
| `rates-from-pos` | Export rates from a start position |
|
||||
| `rates-range` | Export rates for a date range |
|
||||
| `ticks-from` | Export ticks from a start date |
|
||||
| `ticks-range` | Export ticks for a date range |
|
||||
| `account-info` | Export account information |
|
||||
| `terminal-info` | Export terminal information |
|
||||
| `symbols` | Export symbol list |
|
||||
| `symbol-info` | Export symbol details |
|
||||
| `orders` | Export active orders |
|
||||
| `positions` | Export open positions |
|
||||
| `history-orders` | Export historical orders |
|
||||
| `history-deals` | Export historical deals |
|
||||
| Command | Description |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `rates-from` | Export rates from a start date |
|
||||
| `rates-from-pos` | Export rates from a start position |
|
||||
| `latest-rates` | Export latest rates from a start position |
|
||||
| `rates-range` | Export rates for a date range |
|
||||
| `ticks-from` | Export ticks from a start date |
|
||||
| `ticks-range` | Export ticks for a date range |
|
||||
| `ticks-recent` | Export ticks from a recent trailing window |
|
||||
| `account-info` | Export account information |
|
||||
| `terminal-info` | Export terminal information |
|
||||
| `version` | Export MetaTrader 5 version information |
|
||||
| `last-error` | Export the last error information |
|
||||
| `symbols` | Export symbol list |
|
||||
| `symbol-info` | Export symbol details |
|
||||
| `symbol-info-tick` | Export the last tick for a symbol |
|
||||
| `minimum-margins` | Export minimum-volume buy and sell margin requirements |
|
||||
| `market-book` | Export market depth (order book) |
|
||||
| `orders` | Export active orders |
|
||||
| `positions` | Export open positions |
|
||||
| `history-orders` | Export historical orders |
|
||||
| `history-deals` | Export historical deals |
|
||||
| `recent-history-deals` | Export historical deals from a recent trailing window |
|
||||
| `mt5-summary` | Export terminal/account status summary |
|
||||
| `order-check` | Check funds sufficiency for a trade request |
|
||||
| `order-send` | Send a trade request to the trade server (`--yes` required) |
|
||||
| `collect-history` | Bundle rates, ticks, history-orders, and history-deals for one or more symbols into a single SQLite database |
|
||||
|
||||
Use `order-check` to validate a request payload before running `order-send --yes`.
|
||||
|
||||
### `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`.
|
||||
|
||||
```bash
|
||||
mt5cli -o history.db collect-history \
|
||||
--symbol EURUSD --symbol GBPUSD \
|
||||
--date-from 2024-01-01 --date-to 2024-02-01 \
|
||||
--dataset rates --dataset history-deals \
|
||||
--timeframe M1 --flags ALL --if-exists append --with-views
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### Incremental history SDK
|
||||
|
||||
For automated pipelines, use the importable incremental API instead of re-fetching fixed date ranges:
|
||||
|
||||
```python
|
||||
from pdmt5 import Mt5Config, Mt5DataClient
|
||||
from mt5cli import Dataset, update_history, update_history_with_config
|
||||
|
||||
# Reuse an already-connected pdmt5 client (does not open/close MT5)
|
||||
client = Mt5DataClient(config=Mt5Config(login=12345))
|
||||
client.initialize_and_login_mt5()
|
||||
try:
|
||||
update_history(
|
||||
client=client,
|
||||
output="history.db",
|
||||
symbols=["EURUSD", "GBPUSD"],
|
||||
datasets={Dataset.rates, Dataset.history_deals},
|
||||
timeframes=["M1", "H1"], # default: all fixed MT5 timeframes
|
||||
lookback_hours=24,
|
||||
create_rate_views=True,
|
||||
with_views=True,
|
||||
include_account_events=True,
|
||||
)
|
||||
finally:
|
||||
client.shutdown()
|
||||
|
||||
# Standalone wrapper that opens and closes MT5 for you
|
||||
update_history_with_config(
|
||||
output="history.db",
|
||||
symbols=["EURUSD"],
|
||||
config=Mt5Config(login=12345),
|
||||
)
|
||||
```
|
||||
|
||||
- **`collect-history`**: explicit date-range export into SQLite.
|
||||
- **`update_history`**: incremental append based on existing SQLite `MAX(time)` per symbol (and timeframe for rates); account-level deals use a separate cursor when `include_account_events=True`.
|
||||
- **`rates` table**: normalized storage with `symbol` and `timeframe` columns.
|
||||
- **Rate compatibility views**: mt5cli manages all `rate_*` views. Naming is `rate_<symbol>__<timeframe>` when a symbol has one timeframe, otherwise `rate_<symbol>__<granularity>_<timeframe>` (for example `rate_EURUSD__M1_1`). Stale `rate_*` views are dropped and recreated when rates change for offline tools such as mteor optimize.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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
|
||||
from mt5cli import AccountSpec, collect_latest_closed_rates_by_granularity
|
||||
|
||||
rates = collect_latest_closed_rates_by_granularity(
|
||||
[AccountSpec(symbols=["EURUSD", "GBPUSD"], login=12345)],
|
||||
["M1", "H1"],
|
||||
count=500,
|
||||
retry_count=3,
|
||||
)
|
||||
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.
|
||||
- **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` and let the caller decide logging.
|
||||
- **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 `Mt5CliClient` 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.
|
||||
- **Recent ticks and margins**: `recent_ticks()` and `minimum_margins()` SDK helpers (and matching CLI commands) cover common downstream read-only queries.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
# History Collection (SQLite)
|
||||
|
||||
::: mt5cli.history
|
||||
|
||||
## `collect-history` schema
|
||||
|
||||
The `collect-history` command (and the matching `collect_history` SDK function) writes
|
||||
selected MT5 datasets into one SQLite database. Each dataset becomes a table; column
|
||||
names and types mirror the pdmt5 DataFrame schema for that export, with two additions:
|
||||
|
||||
- `symbol` is prepended on every table.
|
||||
- `timeframe` is prepended on `rates` so appended runs at different bar sizes stay
|
||||
distinguishable.
|
||||
|
||||
SQLite does not declare foreign keys. Rows are linked logically by `symbol`, time
|
||||
windows, and (for deals) `position_id` / `order`. Duplicate rows are removed on
|
||||
append using dataset-specific keys (for example `ticket` on history tables, or
|
||||
`(symbol, timeframe, time)` on rates).
|
||||
|
||||
Optional views are created when `--with-views` is set and the `history-deals` dataset
|
||||
was written.
|
||||
|
||||
### Entity-relationship diagram
|
||||
|
||||
Sample layout for a full collection with `--with-views`:
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
rates {
|
||||
TEXT symbol "dedup key"
|
||||
INTEGER timeframe "dedup key"
|
||||
TEXT time "dedup key"
|
||||
REAL open
|
||||
REAL high
|
||||
REAL low
|
||||
REAL close
|
||||
INTEGER tick_volume
|
||||
INTEGER spread
|
||||
INTEGER real_volume
|
||||
}
|
||||
|
||||
ticks {
|
||||
TEXT symbol "dedup key"
|
||||
TEXT time "dedup key"
|
||||
INTEGER time_msc "dedup key (preferred)"
|
||||
REAL bid
|
||||
REAL ask
|
||||
REAL last
|
||||
INTEGER volume
|
||||
INTEGER flags
|
||||
REAL volume_real
|
||||
}
|
||||
|
||||
history_orders {
|
||||
INTEGER ticket "dedup key"
|
||||
TEXT symbol
|
||||
TEXT time
|
||||
INTEGER type
|
||||
INTEGER state
|
||||
REAL volume_initial
|
||||
REAL price_open
|
||||
REAL price_current
|
||||
INTEGER magic
|
||||
}
|
||||
|
||||
history_deals {
|
||||
INTEGER ticket "dedup key"
|
||||
INTEGER order
|
||||
INTEGER position_id "groups position view"
|
||||
TEXT symbol
|
||||
TEXT time
|
||||
INTEGER type "0/1 trade, else cash event"
|
||||
INTEGER entry "0 IN, 1 OUT, 2 INOUT, 3 OUT_BY"
|
||||
REAL volume
|
||||
REAL price
|
||||
REAL profit
|
||||
REAL commission
|
||||
REAL swap
|
||||
REAL fee
|
||||
}
|
||||
|
||||
cash_events {
|
||||
INTEGER ticket
|
||||
TEXT symbol
|
||||
TEXT time
|
||||
INTEGER type
|
||||
REAL profit
|
||||
}
|
||||
|
||||
positions_reconstructed {
|
||||
INTEGER position_id
|
||||
TEXT symbol
|
||||
TEXT open_time
|
||||
TEXT close_time
|
||||
INTEGER direction
|
||||
REAL volume_open
|
||||
REAL volume_close
|
||||
REAL volume_reversal
|
||||
REAL open_price
|
||||
REAL close_price
|
||||
REAL total_profit
|
||||
INTEGER reversal_count
|
||||
INTEGER deals_count
|
||||
}
|
||||
|
||||
rates ||--o{ history_deals : "symbol (logical)"
|
||||
ticks ||--o{ history_deals : "symbol (logical)"
|
||||
history_orders ||--o{ history_deals : "order ~ ticket (logical)"
|
||||
history_deals ||--|| cash_events : "VIEW: type NOT IN (0,1)"
|
||||
history_deals ||--o{ positions_reconstructed : "VIEW: GROUP BY position_id"
|
||||
```
|
||||
|
||||
### Tables and views
|
||||
|
||||
| Object | Kind | Source | Notes |
|
||||
| ------------------------- | ----- | -------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `rates` | table | `copy_rates_range` | Indexed on `(symbol, timeframe, time)` when columns exist. |
|
||||
| `ticks` | table | `copy_ticks_range` | Indexed on `(symbol, time)` when columns exist. |
|
||||
| `history_orders` | table | `history_orders_get` | Fetched per `--symbol`, then concatenated. |
|
||||
| `history_deals` | table | `history_deals_get` | Fetched per `--symbol`, then concatenated. Indexed on `(position_id, symbol)` when present. |
|
||||
| `cash_events` | view | `history_deals` | Non-trade deal types (deposits, balance ops, etc.). Requires `type` column. |
|
||||
| `positions_reconstructed` | view | `history_deals` | One row per closed `position_id`; volume-weighted prices and reversal stats. |
|
||||
|
||||
Column sets can vary with terminal and pdmt5 version. Views are skipped with a warning
|
||||
when required columns are missing.
|
||||
|
||||
### Incremental collection
|
||||
|
||||
The `update_history` SDK path uses the same base tables and optional
|
||||
`cash_events` / `positions_reconstructed` views. It additionally maintains
|
||||
`rate_<symbol>__<timeframe>` compatibility views when `create_rate_views=True`.
|
||||
|
||||
### Rate view resolution
|
||||
|
||||
Downstream tools can resolve mt5cli-managed compatibility view names from an
|
||||
existing SQLite history database without creating files or guessing naming
|
||||
schemes:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
from mt5cli.history import resolve_rate_view_name, resolve_rate_view_names
|
||||
|
||||
# Single symbol and granularity
|
||||
view = resolve_rate_view_name(Path("history.db"), "EURUSD", "M1")
|
||||
|
||||
# Batch resolution in row-major order
|
||||
views = resolve_rate_view_names(
|
||||
Path("history.db"),
|
||||
["EURUSD", "GBPUSD"],
|
||||
["M1", "H1"],
|
||||
)
|
||||
```
|
||||
|
||||
Resolution rules:
|
||||
|
||||
- Returns `rate_<symbol>__<timeframe>` when a symbol stores one timeframe.
|
||||
- Returns `rate_<symbol>__<granularity>_<timeframe>` when multiple timeframes
|
||||
are stored for the same symbol.
|
||||
- When multiple naming candidates apply, prefers an existing managed
|
||||
`rate_*__*` view from the candidate list.
|
||||
- Falls back to single-timeframe naming when the database path is missing or
|
||||
`rates` metadata is unavailable.
|
||||
- Pass `require_existing=True` to raise `ValueError` instead of returning a
|
||||
best-guess name when the database or view is missing.
|
||||
- Accepts either a SQLite path or an open `sqlite3.Connection`.
|
||||
|
||||
### Rate data loading
|
||||
|
||||
Use `load_rate_data()` to load a table or view from a SQLite path, or
|
||||
`load_rate_data_from_connection()` when you already have a connection:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
from mt5cli import load_rate_data
|
||||
from mt5cli.history import resolve_rate_view_name
|
||||
|
||||
view = resolve_rate_view_name(Path("history.db"), "EURUSD", "M1", require_existing=True)
|
||||
rates = load_rate_data(Path("history.db"), view, count=1000)
|
||||
```
|
||||
|
||||
The loader accepts close-based OHLC rate data or tick-like bid/ask data. It
|
||||
validates that `time` exists, parses timestamps with pandas, and returns a
|
||||
DataFrame indexed by ascending `DatetimeIndex` named `time`.
|
||||
|
||||
### Multi-series rate loading
|
||||
|
||||
For loading many rate series at once, build neutral `RateTarget` pairs and load
|
||||
them from SQLite in one call. View names are resolved via the same
|
||||
compatibility-view rules, or you can pass `explicit_tables` to bypass resolution:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
from mt5cli import build_rate_targets, load_rate_series_from_sqlite
|
||||
|
||||
targets = build_rate_targets(["EURUSD", "GBPUSD"], ["M1", "H1"])
|
||||
series = load_rate_series_from_sqlite(Path("history.db"), targets, count=1000)
|
||||
frame = series["EURUSD", 1] # keyed by (symbol, integer timeframe)
|
||||
```
|
||||
|
||||
- `build_rate_targets()` returns `RateTarget(symbol, timeframe)` pairs in
|
||||
row-major order, normalizing timeframe names such as `"M1"` to their integer
|
||||
values; set `allow_missing_symbol=True` to address series solely by
|
||||
`explicit_tables` (targets carry `symbol=None`).
|
||||
- `resolve_rate_tables()` maps targets to table or view names and validates that
|
||||
any `explicit_tables` count matches the target count. Pass
|
||||
`require_existing=True` to raise `ValueError` instead of returning a
|
||||
best-guess name when the database or managed view is missing. When
|
||||
`explicit_tables` is provided, names are returned as-is and
|
||||
`require_existing` is ignored.
|
||||
- `load_rate_series_from_sqlite()` returns a mapping keyed by
|
||||
`(symbol, integer timeframe)`. Unless `explicit_tables` is supplied, it
|
||||
requires existing managed `rate_*` compatibility views and raises
|
||||
`ValueError` when they are missing. Duplicate `(symbol, timeframe)` targets
|
||||
are rejected.
|
||||
- `load_rate_series_by_granularity()` is a thin wrapper that builds the targets,
|
||||
loads the series, and rekeys the result by granularity name to avoid
|
||||
converting integer timeframes downstream:
|
||||
|
||||
```python
|
||||
from mt5cli import load_rate_series_by_granularity
|
||||
|
||||
series = load_rate_series_by_granularity(
|
||||
"history.db", ["EURUSD"], ["M1", "H1"], count=1000
|
||||
)
|
||||
frame = series["EURUSD", "M1"] # keyed by (symbol | None, granularity_name)
|
||||
```
|
||||
+63
-6
@@ -10,12 +10,26 @@ The mt5cli package consists of the following modules:
|
||||
|
||||
Command-line interface module providing typer-based commands for exporting MetaTrader 5 data to CSV, JSON, Parquet, and SQLite3 formats.
|
||||
|
||||
### [Utils](utils.md)
|
||||
|
||||
Utility module providing constants, enums, Click parameter types, and helper functions for parsing and exporting data.
|
||||
|
||||
### [SDK](sdk.md)
|
||||
|
||||
Programmatic SDK for read-only MetaTrader 5 data collection. Returns pandas DataFrames and provides `collect_history` for SQLite bulk collection.
|
||||
|
||||
### [History Collection (SQLite)](history.md)
|
||||
|
||||
SQLite storage helpers for the `collect-history` command schema, incremental updates, deduplication, indexes, and optional views.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The package follows a simple architecture built on top of pdmt5:
|
||||
|
||||
1. **CLI Layer** (`cli.py`): Typer application with subcommands for each data type, custom Click parameter types for datetime/timeframe/tick flags parsing, and format detection/export utilities.
|
||||
2. **Data Layer** (via `pdmt5`): Uses `Mt5DataClient` and `Mt5Config` from the pdmt5 package for all MetaTrader 5 data access.
|
||||
1. **CLI Layer** (`cli.py`): Typer application with subcommands that delegate to the SDK and export results.
|
||||
2. **SDK Layer** (`sdk.py`): Read-only data access functions, `Mt5CliClient`, and `collect_history` orchestration.
|
||||
3. **Utils Layer** (`utils.py`): Constants, enums, custom Click parameter types, parsing helpers, and format detection/export utilities.
|
||||
4. **Data Layer** (via `pdmt5`): Uses `Mt5DataClient` and `Mt5Config` from the pdmt5 package for all MetaTrader 5 data access.
|
||||
|
||||
## Usage Guidelines
|
||||
|
||||
@@ -47,15 +61,58 @@ mt5cli -o data.db --table symbols symbols --group "*USD*"
|
||||
## Python API
|
||||
|
||||
```python
|
||||
from mt5cli import detect_format, export_dataframe
|
||||
import pandas as pd
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from mt5cli import (
|
||||
Dataset,
|
||||
IfExists,
|
||||
Mt5CliClient,
|
||||
collect_history,
|
||||
copy_rates_range,
|
||||
detect_format,
|
||||
export_dataframe,
|
||||
export_dataframe_to_sqlite,
|
||||
minimum_margins,
|
||||
recent_ticks,
|
||||
)
|
||||
from mt5cli.history import resolve_rate_view_name
|
||||
|
||||
# Fetch rates programmatically
|
||||
rates = copy_rates_range(
|
||||
"EURUSD",
|
||||
timeframe="H1",
|
||||
date_from="2024-01-01",
|
||||
date_to="2024-02-01",
|
||||
)
|
||||
|
||||
# Detect output format from file extension
|
||||
fmt = detect_format(Path("output.parquet")) # Returns "parquet"
|
||||
|
||||
# Export a DataFrame
|
||||
df = pd.DataFrame({"symbol": ["EURUSD"], "bid": [1.1234]})
|
||||
export_dataframe(df, Path("output.csv"), "csv")
|
||||
export_dataframe(rates, Path("output.csv"), "csv")
|
||||
|
||||
# Append to SQLite with deduplication
|
||||
export_dataframe_to_sqlite(
|
||||
rates,
|
||||
Path("history.db"),
|
||||
"rates",
|
||||
if_exists=IfExists.APPEND,
|
||||
deduplicate_on=("symbol", "timeframe", "time"),
|
||||
)
|
||||
|
||||
# Resolve rate compatibility views and fetch recent ticks
|
||||
view = resolve_rate_view_name(Path("history.db"), "EURUSD", "M1")
|
||||
ticks = recent_ticks("EURUSD", seconds=300)
|
||||
margins = minimum_margins("EURUSD")
|
||||
|
||||
# Collect history into SQLite
|
||||
collect_history(
|
||||
Path("history.db"),
|
||||
symbols=["EURUSD"],
|
||||
date_from=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
date_to=datetime(2024, 2, 1, tzinfo=UTC),
|
||||
)
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
# SDK Module
|
||||
|
||||
::: mt5cli.sdk
|
||||
|
||||
## Resilient multi-account orchestration
|
||||
|
||||
The SDK ships strategy-agnostic helpers for building long-running collectors on
|
||||
top of the read-only client. None of them depend on a particular trading
|
||||
application.
|
||||
|
||||
### Retrying transient rate collection
|
||||
|
||||
`collect_latest_rates_for_accounts_with_retries()` wraps
|
||||
`collect_latest_rates_for_accounts()` with bounded exponential backoff. Only
|
||||
`pdmt5.Mt5TradingError` and `pdmt5.Mt5RuntimeError` are retried; the final
|
||||
failure is re-raised once `retry_count` is exhausted.
|
||||
|
||||
```python
|
||||
from mt5cli import AccountSpec, collect_latest_rates_for_accounts_with_retries
|
||||
|
||||
accounts = [AccountSpec(symbols=["EURUSD"], login=12345)]
|
||||
rates = collect_latest_rates_for_accounts_with_retries(
|
||||
accounts,
|
||||
["M1", "H1"],
|
||||
count=500,
|
||||
retry_count=3,
|
||||
backoff_base=2, # sleeps 2s, 4s, 8s between attempts
|
||||
)
|
||||
```
|
||||
|
||||
### Latest closed rate bars
|
||||
|
||||
MetaTrader 5 `start_pos=0` includes the still-forming current bar as the last
|
||||
row. `collect_latest_closed_rates_for_accounts()` fetches `count + 1` bars,
|
||||
drops that row with `drop_forming_rate_bar()`, and validates each series is
|
||||
non-empty. Use `collect_latest_closed_rates_by_granularity()` when callers
|
||||
prefer keys such as `("EURUSD", "M1")` instead of integer timeframes.
|
||||
|
||||
```python
|
||||
from mt5cli import AccountSpec, collect_latest_closed_rates_by_granularity
|
||||
|
||||
rates = collect_latest_closed_rates_by_granularity(
|
||||
[AccountSpec(symbols=["EURUSD"], login=12345)],
|
||||
["M1", "H1"],
|
||||
count=500,
|
||||
retry_count=3,
|
||||
)
|
||||
closed_m1 = rates["EURUSD", "M1"]
|
||||
```
|
||||
|
||||
### Resolving credentials and `${ENV_VAR}` placeholders
|
||||
|
||||
`resolve_account_spec()` / `resolve_account_specs()` merge explicit override
|
||||
values over `AccountSpec` fields and expand `${ENV_VAR}` placeholders, keeping
|
||||
secrets out of plan/config files. A missing environment variable raises
|
||||
`ValueError`.
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
from mt5cli import AccountSpec, resolve_account_specs
|
||||
|
||||
os.environ["MT5_LOGIN"] = "12345"
|
||||
os.environ["MT5_PASSWORD"] = "secret"
|
||||
accounts = [
|
||||
AccountSpec(symbols=["EURUSD"], login="${MT5_LOGIN}", password="${MT5_PASSWORD}")
|
||||
]
|
||||
|
||||
resolved = resolve_account_specs(accounts, server="Broker-Demo")
|
||||
# resolved[0].login == "12345", resolved[0].server == "Broker-Demo"
|
||||
```
|
||||
|
||||
### Throttled incremental history updates
|
||||
|
||||
`ThrottledHistoryUpdater` wraps `update_history()` with a minimum interval
|
||||
between successful runs (using a monotonic clock), so an application loop can
|
||||
call it every iteration without over-fetching.
|
||||
|
||||
```python
|
||||
from pdmt5 import Mt5Config, Mt5DataClient
|
||||
|
||||
from mt5cli import Dataset, ThrottledHistoryUpdater
|
||||
|
||||
updater = ThrottledHistoryUpdater(
|
||||
output="history.db",
|
||||
datasets={Dataset.rates},
|
||||
timeframes=["M1"],
|
||||
interval_seconds=60, # <= 0 updates on every call
|
||||
)
|
||||
|
||||
client = Mt5DataClient(config=Mt5Config(login=12345))
|
||||
client.initialize_and_login_mt5()
|
||||
try:
|
||||
while True:
|
||||
updater.update(client, ["EURUSD", "GBPUSD"]) # no-op until 60s elapse
|
||||
# ... do other work; break when shutting down ...
|
||||
finally:
|
||||
client.shutdown()
|
||||
```
|
||||
|
||||
By default `Mt5TradingError`, `Mt5RuntimeError`, and `sqlite3.Error` propagate so
|
||||
the caller controls logging; pass `suppress_errors=True` to swallow them and
|
||||
return `False` without advancing the throttle.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Utils Module
|
||||
|
||||
::: mt5cli.utils
|
||||
+125
-17
@@ -13,6 +13,7 @@ mt5cli is a CLI application that exports MetaTrader 5 trading data to multiple f
|
||||
- **Comprehensive data access**: Rates, ticks, account info, symbols, orders, positions, and trading history
|
||||
- **Flexible timeframes**: Named timeframes (M1, H1, D1, etc.) and numeric values
|
||||
- **Connection management**: Optional credentials, server, and timeout configuration
|
||||
- **SQLite rate loading**: Load mt5cli-managed rate tables/views for offline workflows
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -20,6 +21,67 @@ mt5cli is a CLI application that exports MetaTrader 5 trading data to multiple f
|
||||
pip install mt5cli
|
||||
```
|
||||
|
||||
## Programmatic usage / SDK usage
|
||||
|
||||
mt5cli can be used as a small Python SDK for read-only MetaTrader 5 data collection. SDK functions return pandas DataFrames without writing files. Use `export_dataframe` or `export_dataframe_to_sqlite` when you need to persist results.
|
||||
|
||||
```python
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from mt5cli import (
|
||||
Mt5CliClient,
|
||||
collect_history,
|
||||
copy_rates_range,
|
||||
export_dataframe,
|
||||
export_dataframe_to_sqlite,
|
||||
load_rate_data,
|
||||
minimum_margins,
|
||||
recent_ticks,
|
||||
)
|
||||
from mt5cli.history import resolve_rate_view_name
|
||||
|
||||
# One-off fetch with module-level helpers
|
||||
rates = copy_rates_range(
|
||||
"EURUSD",
|
||||
timeframe="H1",
|
||||
date_from="2024-01-01",
|
||||
date_to="2024-02-01",
|
||||
)
|
||||
export_dataframe(rates, Path("rates.csv"), "csv")
|
||||
|
||||
# Resolve SQLite rate compatibility views for downstream tools
|
||||
view = resolve_rate_view_name(Path("history.db"), "EURUSD", "M1", require_existing=True)
|
||||
offline_rates = load_rate_data(Path("history.db"), view, count=1000)
|
||||
|
||||
# Recent tick window and minimum margin summary
|
||||
ticks = recent_ticks("EURUSD", seconds=300)
|
||||
margins = minimum_margins("EURUSD")
|
||||
|
||||
# Reuse one MT5 connection for multiple calls
|
||||
with Mt5CliClient(login=12345, password="secret", server="Broker-Demo") as client:
|
||||
account = client.account_info()
|
||||
positions = client.positions()
|
||||
latest = client.latest_rates("EURUSD", "M1", count=100)
|
||||
summary = client.mt5_summary()
|
||||
summary_table = client.mt5_summary_as_df()
|
||||
|
||||
# Bulk SQLite collection (same behavior as the collect-history CLI command)
|
||||
collect_history(
|
||||
Path("history.db"),
|
||||
symbols=["EURUSD", "GBPUSD"],
|
||||
date_from=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
date_to=datetime(2024, 2, 1, tzinfo=UTC),
|
||||
timeframe="M1",
|
||||
flags="ALL",
|
||||
with_views=True,
|
||||
)
|
||||
```
|
||||
|
||||
Timeframes, tick flags, and ISO 8601 date strings are accepted wherever noted in the SDK API.
|
||||
|
||||
`Mt5CliClient.mt5_summary()` returns the SDK structured form as plain nested Python values. Use `Mt5CliClient.mt5_summary_as_df()` when you need a one-row DataFrame for export. The `mt5-summary` CLI command uses this tabular form, so nested terminal/account fields are JSON-encoded strings that are safe for CSV, JSON, Parquet, and SQLite output.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
@@ -50,32 +112,76 @@ mt5cli --login 12345 --password mypass --server MyBroker-Demo \
|
||||
| ---------------- | ---------------------------------- |
|
||||
| `rates-from` | Export rates from a start date |
|
||||
| `rates-from-pos` | Export rates from a start position |
|
||||
| `latest-rates` | Export latest rates |
|
||||
| `rates-range` | Export rates for a date range |
|
||||
|
||||
### Ticks
|
||||
|
||||
| Command | Description |
|
||||
| ------------- | ------------------------------ |
|
||||
| `ticks-from` | Export ticks from a start date |
|
||||
| `ticks-range` | Export ticks for a date range |
|
||||
| Command | Description |
|
||||
| -------------- | ----------------------------------- |
|
||||
| `ticks-from` | Export ticks from a start date |
|
||||
| `ticks-range` | Export ticks for a date range |
|
||||
| `ticks-recent` | Export ticks from a trailing window |
|
||||
|
||||
### Information
|
||||
|
||||
| Command | Description |
|
||||
| --------------- | --------------------------- |
|
||||
| `account-info` | Export account information |
|
||||
| `terminal-info` | Export terminal information |
|
||||
| `symbols` | Export symbol list |
|
||||
| `symbol-info` | Export symbol details |
|
||||
| Command | Description |
|
||||
| ------------------ | --------------------------------------- |
|
||||
| `account-info` | Export account information |
|
||||
| `terminal-info` | Export terminal information |
|
||||
| `version` | Export MetaTrader 5 version information |
|
||||
| `last-error` | Export the last error information |
|
||||
| `symbols` | Export symbol list |
|
||||
| `symbol-info` | Export symbol details |
|
||||
| `symbol-info-tick` | Export the last tick for a symbol |
|
||||
| `minimum-margins` | Export minimum-volume margin summary |
|
||||
| `market-book` | Export market depth (order book) |
|
||||
|
||||
### Trading
|
||||
|
||||
| Command | Description |
|
||||
| ---------------- | ------------------------ |
|
||||
| `orders` | Export active orders |
|
||||
| `positions` | Export open positions |
|
||||
| `history-orders` | Export historical orders |
|
||||
| `history-deals` | Export historical deals |
|
||||
| Command | Description |
|
||||
| ---------------------- | ----------------------------------------------------------- |
|
||||
| `orders` | Export active orders |
|
||||
| `positions` | Export open positions |
|
||||
| `history-orders` | Export historical orders |
|
||||
| `history-deals` | Export historical deals |
|
||||
| `recent-history-deals` | Export historical deals from a trailing window |
|
||||
| `mt5-summary` | Export terminal/account status summary |
|
||||
| `order-check` | Check funds sufficiency for a trade request |
|
||||
| `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`.
|
||||
|
||||
### Bulk Collection
|
||||
|
||||
| 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) |
|
||||
|
||||
```bash
|
||||
mt5cli -o history.db collect-history \
|
||||
--symbol EURUSD --symbol GBPUSD \
|
||||
--date-from 2024-01-01 --date-to 2024-02-01 \
|
||||
--dataset rates --dataset history-deals \
|
||||
--timeframe M1 --flags ALL --if-exists append --with-views
|
||||
```
|
||||
|
||||
`collect-history` options:
|
||||
|
||||
| Option | Default | Description |
|
||||
| -------------- | ---------- | --------------------------------------------------------------------------------------------- |
|
||||
| `--symbol/-s` | _required_ | Symbol to collect (repeat for multiple). |
|
||||
| `--date-from` | _required_ | Start date in ISO 8601. |
|
||||
| `--date-to` | _required_ | End date in ISO 8601. |
|
||||
| `--dataset` | all four | Repeatable: `rates`, `ticks`, `history-orders`, `history-deals`. |
|
||||
| `--timeframe` | `M1` | Rates timeframe; recorded in a `timeframe` column on the `rates` table. |
|
||||
| `--flags` | `ALL` | Tick copy flags forwarded to `copy_ticks_range`. |
|
||||
| `--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). |
|
||||
|
||||
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`.
|
||||
|
||||
See the [History schema diagram](api/history.md#entity-relationship-diagram) for a sample ER layout of the resulting database.
|
||||
|
||||
## Global Options
|
||||
|
||||
@@ -101,7 +207,9 @@ mt5cli --login 12345 --password mypass --server MyBroker-Demo \
|
||||
|
||||
Browse the API documentation for detailed module information:
|
||||
|
||||
- [CLI Module](api/cli.md) - CLI application with export commands and utility functions
|
||||
- [CLI Module](api/cli.md) - CLI application with export commands
|
||||
- [SDK Module](api/sdk.md) - Programmatic read-only data collection API
|
||||
- [Utils Module](api/utils.md) - Constants, parameter types, parsers, and export utilities
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ theme:
|
||||
features:
|
||||
- content.code.annotate
|
||||
- content.code.copy
|
||||
- content.code.mermaid
|
||||
- navigation.indexes
|
||||
- navigation.sections
|
||||
- navigation.tabs
|
||||
@@ -56,6 +57,9 @@ nav:
|
||||
- API Reference:
|
||||
- Overview: api/index.md
|
||||
- CLI: api/cli.md
|
||||
- SDK: api/sdk.md
|
||||
- History Collection (SQLite): api/history.md
|
||||
- Utils: api/utils.md
|
||||
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
|
||||
+133
-2
@@ -1,12 +1,143 @@
|
||||
"""mt5cli: Command-line tool for MetaTrader 5."""
|
||||
"""mt5cli: Command-line tool and SDK for MetaTrader 5."""
|
||||
|
||||
from importlib.metadata import version
|
||||
|
||||
from .cli import detect_format, export_dataframe
|
||||
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_history_datasets,
|
||||
resolve_history_tick_flags,
|
||||
resolve_history_timeframes,
|
||||
resolve_rate_tables,
|
||||
resolve_rate_view_name,
|
||||
resolve_rate_view_names,
|
||||
)
|
||||
from .sdk import (
|
||||
AccountSpec,
|
||||
Mt5CliClient,
|
||||
ThrottledHistoryUpdater,
|
||||
account_info,
|
||||
build_config,
|
||||
collect_history,
|
||||
collect_latest_closed_rates_by_granularity,
|
||||
collect_latest_closed_rates_for_accounts,
|
||||
collect_latest_rates,
|
||||
collect_latest_rates_for_accounts,
|
||||
collect_latest_rates_for_accounts_with_retries,
|
||||
copy_rates_from,
|
||||
copy_rates_from_pos,
|
||||
copy_rates_range,
|
||||
copy_ticks_from,
|
||||
copy_ticks_range,
|
||||
history_deals,
|
||||
history_orders,
|
||||
last_error,
|
||||
latest_rates,
|
||||
market_book,
|
||||
minimum_margins,
|
||||
mt5_session,
|
||||
mt5_summary,
|
||||
mt5_summary_as_df,
|
||||
orders,
|
||||
positions,
|
||||
recent_history_deals,
|
||||
recent_ticks,
|
||||
resolve_account_spec,
|
||||
resolve_account_specs,
|
||||
substitute_env_placeholders,
|
||||
symbol_info,
|
||||
symbol_info_tick,
|
||||
symbols,
|
||||
terminal_info,
|
||||
update_history,
|
||||
update_history_with_config,
|
||||
)
|
||||
from .sdk import (
|
||||
version as mt5_version,
|
||||
)
|
||||
from .utils import (
|
||||
TICK_FLAG_MAP,
|
||||
TIMEFRAME_MAP,
|
||||
Dataset,
|
||||
IfExists,
|
||||
detect_format,
|
||||
export_dataframe,
|
||||
export_dataframe_to_sqlite,
|
||||
parse_datetime,
|
||||
parse_tick_flags,
|
||||
parse_timeframe,
|
||||
)
|
||||
|
||||
__version__ = version(__package__) if __package__ else None
|
||||
|
||||
__all__ = [
|
||||
"TICK_FLAG_MAP",
|
||||
"TIMEFRAME_MAP",
|
||||
"AccountSpec",
|
||||
"Dataset",
|
||||
"IfExists",
|
||||
"Mt5CliClient",
|
||||
"RateTarget",
|
||||
"ThrottledHistoryUpdater",
|
||||
"account_info",
|
||||
"build_config",
|
||||
"build_rate_targets",
|
||||
"build_rate_view_name",
|
||||
"collect_history",
|
||||
"collect_latest_closed_rates_by_granularity",
|
||||
"collect_latest_closed_rates_for_accounts",
|
||||
"collect_latest_rates",
|
||||
"collect_latest_rates_for_accounts",
|
||||
"collect_latest_rates_for_accounts_with_retries",
|
||||
"copy_rates_from",
|
||||
"copy_rates_from_pos",
|
||||
"copy_rates_range",
|
||||
"copy_ticks_from",
|
||||
"copy_ticks_range",
|
||||
"detect_format",
|
||||
"drop_forming_rate_bar",
|
||||
"export_dataframe",
|
||||
"export_dataframe_to_sqlite",
|
||||
"history_deals",
|
||||
"history_orders",
|
||||
"last_error",
|
||||
"latest_rates",
|
||||
"load_rate_data",
|
||||
"load_rate_data_from_connection",
|
||||
"load_rate_series_by_granularity",
|
||||
"load_rate_series_from_sqlite",
|
||||
"market_book",
|
||||
"minimum_margins",
|
||||
"mt5_session",
|
||||
"mt5_summary",
|
||||
"mt5_summary_as_df",
|
||||
"mt5_version",
|
||||
"orders",
|
||||
"parse_datetime",
|
||||
"parse_tick_flags",
|
||||
"parse_timeframe",
|
||||
"positions",
|
||||
"recent_history_deals",
|
||||
"recent_ticks",
|
||||
"resolve_account_spec",
|
||||
"resolve_account_specs",
|
||||
"resolve_history_datasets",
|
||||
"resolve_history_tick_flags",
|
||||
"resolve_history_timeframes",
|
||||
"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_with_config",
|
||||
]
|
||||
|
||||
+346
-382
@@ -3,16 +3,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from datetime import datetime # noqa: TC003
|
||||
from pathlib import Path # noqa: TC003
|
||||
from typing import TYPE_CHECKING, Annotated, cast
|
||||
from typing import TYPE_CHECKING, Annotated, Any, cast
|
||||
|
||||
import click
|
||||
import typer
|
||||
from pdmt5 import Mt5Config, Mt5DataClient
|
||||
from pdmt5 import Mt5Config
|
||||
|
||||
from . import sdk
|
||||
from .utils import (
|
||||
DATETIME_TYPE,
|
||||
REQUEST_TYPE,
|
||||
TICK_FLAGS_TYPE,
|
||||
TIMEFRAME_TYPE,
|
||||
Dataset,
|
||||
IfExists,
|
||||
LogLevel,
|
||||
OutputFormat,
|
||||
detect_format,
|
||||
export_dataframe,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@@ -21,169 +32,6 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TIMEFRAME_MAP: dict[str, int] = {
|
||||
"M1": 1,
|
||||
"M2": 2,
|
||||
"M3": 3,
|
||||
"M4": 4,
|
||||
"M5": 5,
|
||||
"M6": 6,
|
||||
"M10": 10,
|
||||
"M12": 12,
|
||||
"M15": 15,
|
||||
"M20": 20,
|
||||
"M30": 30,
|
||||
"H1": 16385,
|
||||
"H2": 16386,
|
||||
"H3": 16387,
|
||||
"H4": 16388,
|
||||
"H6": 16390,
|
||||
"H8": 16392,
|
||||
"H12": 16396,
|
||||
"D1": 16408,
|
||||
"W1": 32769,
|
||||
"MN1": 49153,
|
||||
}
|
||||
|
||||
TICK_FLAG_MAP: dict[str, int] = {
|
||||
"ALL": 1,
|
||||
"INFO": 2,
|
||||
"TRADE": 4,
|
||||
}
|
||||
|
||||
_FORMAT_EXTENSIONS: dict[str, str] = {
|
||||
".csv": "csv",
|
||||
".json": "json",
|
||||
".parquet": "parquet",
|
||||
".pq": "parquet",
|
||||
".db": "sqlite3",
|
||||
".sqlite": "sqlite3",
|
||||
".sqlite3": "sqlite3",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OutputFormat(StrEnum):
|
||||
"""Supported output file formats."""
|
||||
|
||||
csv = "csv"
|
||||
json = "json"
|
||||
parquet = "parquet"
|
||||
sqlite3 = "sqlite3"
|
||||
|
||||
|
||||
class LogLevel(StrEnum):
|
||||
"""Logging verbosity levels."""
|
||||
|
||||
DEBUG = "DEBUG"
|
||||
INFO = "INFO"
|
||||
WARNING = "WARNING"
|
||||
ERROR = "ERROR"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Click parameter types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _DateTimeType(click.ParamType):
|
||||
"""Click parameter type for ISO 8601 datetime strings."""
|
||||
|
||||
name = "DATETIME"
|
||||
|
||||
def convert(
|
||||
self,
|
||||
value: object,
|
||||
param: click.Parameter | None,
|
||||
ctx: click.Context | None,
|
||||
) -> datetime:
|
||||
"""Convert a string value to a timezone-aware datetime.
|
||||
|
||||
Args:
|
||||
value: Raw value from the command line.
|
||||
param: Click parameter instance.
|
||||
ctx: Click context.
|
||||
|
||||
Returns:
|
||||
Parsed datetime.
|
||||
"""
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
try:
|
||||
return parse_datetime(str(value))
|
||||
except ValueError as exc:
|
||||
self.fail(str(exc), param, ctx)
|
||||
|
||||
|
||||
class _TimeframeType(click.ParamType):
|
||||
"""Click parameter type for MT5 timeframe values."""
|
||||
|
||||
name = "TIMEFRAME"
|
||||
|
||||
def convert(
|
||||
self,
|
||||
value: object,
|
||||
param: click.Parameter | None,
|
||||
ctx: click.Context | None,
|
||||
) -> int:
|
||||
"""Convert a string or integer value to a timeframe integer.
|
||||
|
||||
Args:
|
||||
value: Raw value from the command line.
|
||||
param: Click parameter instance.
|
||||
ctx: Click context.
|
||||
|
||||
Returns:
|
||||
Integer timeframe value.
|
||||
"""
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
try:
|
||||
return parse_timeframe(str(value))
|
||||
except ValueError as exc:
|
||||
self.fail(str(exc), param, ctx)
|
||||
|
||||
|
||||
class _TickFlagsType(click.ParamType):
|
||||
"""Click parameter type for MT5 tick copy flags."""
|
||||
|
||||
name = "FLAGS"
|
||||
|
||||
def convert(
|
||||
self,
|
||||
value: object,
|
||||
param: click.Parameter | None,
|
||||
ctx: click.Context | None,
|
||||
) -> int:
|
||||
"""Convert a string or integer value to a tick flags integer.
|
||||
|
||||
Args:
|
||||
value: Raw value from the command line.
|
||||
param: Click parameter instance.
|
||||
ctx: Click context.
|
||||
|
||||
Returns:
|
||||
Integer tick flag value.
|
||||
"""
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
try:
|
||||
return parse_tick_flags(str(value))
|
||||
except ValueError as exc:
|
||||
self.fail(str(exc), param, ctx)
|
||||
|
||||
|
||||
DATETIME_TYPE = _DateTimeType()
|
||||
TIMEFRAME_TYPE = _TimeframeType()
|
||||
TICK_FLAGS_TYPE = _TickFlagsType()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export context
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -199,149 +47,6 @@ class _ExportContext:
|
||||
config: Mt5Config
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public utility functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def detect_format(
|
||||
output_path: Path,
|
||||
explicit_format: str | None = None,
|
||||
) -> str:
|
||||
"""Detect the output format from a file extension or explicit format string.
|
||||
|
||||
Args:
|
||||
output_path: Path to the output file.
|
||||
explicit_format: Explicitly specified format, if any.
|
||||
|
||||
Returns:
|
||||
The detected format string.
|
||||
|
||||
Raises:
|
||||
ValueError: If the format cannot be determined.
|
||||
"""
|
||||
if explicit_format is not None:
|
||||
return explicit_format
|
||||
suffix = output_path.suffix.lower()
|
||||
if suffix in _FORMAT_EXTENSIONS:
|
||||
return _FORMAT_EXTENSIONS[suffix]
|
||||
msg = (
|
||||
f"Cannot detect format from extension '{suffix}'."
|
||||
" Use --format to specify the output format."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def export_dataframe(
|
||||
df: pd.DataFrame,
|
||||
output_path: Path,
|
||||
output_format: str,
|
||||
table_name: str = "data",
|
||||
) -> None:
|
||||
"""Export a pandas DataFrame to the specified file format.
|
||||
|
||||
Args:
|
||||
df: DataFrame to export.
|
||||
output_path: Path to the output file.
|
||||
output_format: Output format (csv, json, parquet, or sqlite3).
|
||||
table_name: Table name for SQLite3 output.
|
||||
|
||||
Raises:
|
||||
ValueError: If the output format is not supported.
|
||||
"""
|
||||
if output_format == "csv":
|
||||
df.to_csv(output_path, index=False)
|
||||
elif output_format == "json":
|
||||
df.to_json(
|
||||
output_path,
|
||||
orient="records",
|
||||
date_format="iso",
|
||||
indent=2,
|
||||
)
|
||||
elif output_format == "parquet":
|
||||
df.to_parquet(output_path, index=False)
|
||||
elif output_format == "sqlite3":
|
||||
with sqlite3.connect(output_path) as conn:
|
||||
df.to_sql( # type: ignore[reportUnknownMemberType]
|
||||
table_name,
|
||||
conn,
|
||||
if_exists="replace",
|
||||
index=False,
|
||||
)
|
||||
else:
|
||||
msg = f"Unsupported output format: {output_format}"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def parse_datetime(value: str) -> datetime:
|
||||
"""Parse an ISO 8601 datetime string to a timezone-aware datetime.
|
||||
|
||||
Args:
|
||||
value: ISO 8601 datetime string (e.g., '2024-01-01' or
|
||||
'2024-01-01T12:00:00+00:00').
|
||||
|
||||
Returns:
|
||||
Parsed datetime with UTC timezone if no timezone is specified.
|
||||
|
||||
Raises:
|
||||
ValueError: If the string cannot be parsed.
|
||||
"""
|
||||
try:
|
||||
dt = datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
msg = f"Invalid datetime format: '{value}'. Use ISO 8601 format."
|
||||
raise ValueError(msg) from None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt
|
||||
|
||||
|
||||
def parse_timeframe(value: str) -> int:
|
||||
"""Parse a timeframe string or integer value.
|
||||
|
||||
Args:
|
||||
value: Timeframe name (e.g., 'M1', 'H1', 'D1') or integer value.
|
||||
|
||||
Returns:
|
||||
Integer timeframe value.
|
||||
|
||||
Raises:
|
||||
ValueError: If the timeframe is invalid.
|
||||
"""
|
||||
upper = value.upper()
|
||||
if upper in TIMEFRAME_MAP:
|
||||
return TIMEFRAME_MAP[upper]
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
valid = ", ".join(TIMEFRAME_MAP)
|
||||
msg = f"Invalid timeframe: '{value}'. Use one of: {valid}, or an integer."
|
||||
raise ValueError(msg) from None
|
||||
|
||||
|
||||
def parse_tick_flags(value: str) -> int:
|
||||
"""Parse tick flags string or integer value.
|
||||
|
||||
Args:
|
||||
value: Tick flag name (ALL, INFO, TRADE) or integer value.
|
||||
|
||||
Returns:
|
||||
Integer tick flag value.
|
||||
|
||||
Raises:
|
||||
ValueError: If the flag is invalid.
|
||||
"""
|
||||
upper = value.upper()
|
||||
if upper in TICK_FLAG_MAP:
|
||||
return TICK_FLAG_MAP[upper]
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
valid = ", ".join(TICK_FLAG_MAP)
|
||||
msg = f"Invalid tick flags: '{value}'. Use one of: {valid}, or an integer."
|
||||
raise ValueError(msg) from None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Typer application
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -351,6 +56,10 @@ app = typer.Typer(
|
||||
help="Export MetaTrader5 data to CSV, JSON, Parquet, or SQLite3.",
|
||||
)
|
||||
|
||||
_REQUEST_OPTION_HELP = (
|
||||
"Order request as a JSON object string, or '@path' to load JSON from a file."
|
||||
)
|
||||
|
||||
|
||||
def _get_export_context(ctx: typer.Context) -> _ExportContext:
|
||||
return cast("_ExportContext", ctx.obj)
|
||||
@@ -358,34 +67,33 @@ def _get_export_context(ctx: typer.Context) -> _ExportContext:
|
||||
|
||||
def _execute_export(
|
||||
ctx: typer.Context,
|
||||
fetch_fn: Callable[[Mt5DataClient], pd.DataFrame],
|
||||
fetch_fn: Callable[[], pd.DataFrame],
|
||||
) -> None:
|
||||
"""Execute the common connect-fetch-export-shutdown workflow.
|
||||
"""Execute the common fetch-export workflow.
|
||||
|
||||
Args:
|
||||
ctx: Typer context carrying shared options.
|
||||
fetch_fn: Callable that receives a connected client and returns a
|
||||
DataFrame.
|
||||
fetch_fn: Callable that returns a DataFrame via the SDK layer.
|
||||
"""
|
||||
export_ctx = _get_export_context(ctx)
|
||||
client = Mt5DataClient(config=export_ctx.config)
|
||||
client.initialize_and_login_mt5()
|
||||
try:
|
||||
df = fetch_fn(client)
|
||||
export_dataframe(
|
||||
df=df,
|
||||
output_path=export_ctx.output,
|
||||
output_format=export_ctx.output_format,
|
||||
table_name=export_ctx.table,
|
||||
)
|
||||
logger.info(
|
||||
"Exported %d rows to %s (%s)",
|
||||
len(df),
|
||||
export_ctx.output,
|
||||
export_ctx.output_format,
|
||||
)
|
||||
finally:
|
||||
client.shutdown()
|
||||
df = fetch_fn()
|
||||
export_dataframe(
|
||||
df=df,
|
||||
output_path=export_ctx.output,
|
||||
output_format=export_ctx.output_format,
|
||||
table_name=export_ctx.table,
|
||||
)
|
||||
logger.info(
|
||||
"Exported %d rows to %s (%s)",
|
||||
len(df),
|
||||
export_ctx.output,
|
||||
export_ctx.output_format,
|
||||
)
|
||||
|
||||
|
||||
def _sdk_client(ctx: typer.Context) -> sdk.Mt5CliClient:
|
||||
export_ctx = _get_export_context(ctx)
|
||||
return sdk.Mt5CliClient(config=export_ctx.config)
|
||||
|
||||
|
||||
@app.callback()
|
||||
@@ -485,14 +193,10 @@ def rates_from(
|
||||
count: Annotated[int, typer.Option(help="Number of records.")],
|
||||
) -> None:
|
||||
"""Export rates from a start date."""
|
||||
client = _sdk_client(ctx)
|
||||
_execute_export(
|
||||
ctx,
|
||||
lambda c: c.copy_rates_from_as_df(
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
date_from=date_from,
|
||||
count=count,
|
||||
),
|
||||
lambda: client.copy_rates_from(symbol, timeframe, date_from, count),
|
||||
)
|
||||
|
||||
|
||||
@@ -511,14 +215,35 @@ def rates_from_pos(
|
||||
count: Annotated[int, typer.Option(help="Number of records.")],
|
||||
) -> None:
|
||||
"""Export rates from a start position."""
|
||||
client = _sdk_client(ctx)
|
||||
_execute_export(
|
||||
ctx,
|
||||
lambda c: c.copy_rates_from_pos_as_df(
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
start_pos=start_pos,
|
||||
count=count,
|
||||
lambda: client.copy_rates_from_pos(symbol, timeframe, start_pos, count),
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def latest_rates(
|
||||
ctx: typer.Context,
|
||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||
timeframe: Annotated[
|
||||
int,
|
||||
typer.Option(
|
||||
click_type=TIMEFRAME_TYPE,
|
||||
help="Timeframe.",
|
||||
),
|
||||
],
|
||||
count: Annotated[int, typer.Option(help="Number of records.")],
|
||||
start_pos: Annotated[
|
||||
int,
|
||||
typer.Option(help="Start position (0 = current bar)."),
|
||||
] = 0,
|
||||
) -> None:
|
||||
"""Export latest rates from a start position."""
|
||||
client = _sdk_client(ctx)
|
||||
_execute_export(
|
||||
ctx,
|
||||
lambda: client.latest_rates(symbol, timeframe, count, start_pos=start_pos),
|
||||
)
|
||||
|
||||
|
||||
@@ -543,14 +268,10 @@ def rates_range(
|
||||
],
|
||||
) -> None:
|
||||
"""Export rates for a date range."""
|
||||
client = _sdk_client(ctx)
|
||||
_execute_export(
|
||||
ctx,
|
||||
lambda c: c.copy_rates_range_as_df(
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
),
|
||||
lambda: client.copy_rates_range(symbol, timeframe, date_from, date_to),
|
||||
)
|
||||
|
||||
|
||||
@@ -572,14 +293,10 @@ def ticks_from(
|
||||
],
|
||||
) -> None:
|
||||
"""Export ticks from a start date."""
|
||||
client = _sdk_client(ctx)
|
||||
_execute_export(
|
||||
ctx,
|
||||
lambda c: c.copy_ticks_from_as_df(
|
||||
symbol=symbol,
|
||||
date_from=date_from,
|
||||
count=count,
|
||||
flags=flags,
|
||||
),
|
||||
lambda: client.copy_ticks_from(symbol, date_from, count, flags),
|
||||
)
|
||||
|
||||
|
||||
@@ -601,12 +318,46 @@ def ticks_range(
|
||||
],
|
||||
) -> None:
|
||||
"""Export ticks for a date range."""
|
||||
client = _sdk_client(ctx)
|
||||
_execute_export(
|
||||
ctx,
|
||||
lambda c: c.copy_ticks_range_as_df(
|
||||
symbol=symbol,
|
||||
date_from=date_from,
|
||||
lambda: client.copy_ticks_range(symbol, date_from, date_to, flags),
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def ticks_recent(
|
||||
ctx: typer.Context,
|
||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||
seconds: Annotated[
|
||||
float,
|
||||
typer.Option(help="Lookback window in seconds."),
|
||||
],
|
||||
date_to: Annotated[
|
||||
datetime | None,
|
||||
typer.Option(click_type=DATETIME_TYPE, help="Window end date."),
|
||||
] = None,
|
||||
count: Annotated[
|
||||
int,
|
||||
typer.Option(help="Maximum number of ticks to return."),
|
||||
] = 10000,
|
||||
flags: Annotated[
|
||||
int,
|
||||
typer.Option(
|
||||
click_type=TICK_FLAGS_TYPE,
|
||||
help="Tick flags (ALL, INFO, TRADE, or integer).",
|
||||
),
|
||||
] = 1,
|
||||
) -> None:
|
||||
"""Export ticks from a recent time window."""
|
||||
client = _sdk_client(ctx)
|
||||
_execute_export(
|
||||
ctx,
|
||||
lambda: client.recent_ticks(
|
||||
symbol,
|
||||
seconds,
|
||||
date_to=date_to,
|
||||
count=count,
|
||||
flags=flags,
|
||||
),
|
||||
)
|
||||
@@ -615,13 +366,13 @@ def ticks_range(
|
||||
@app.command()
|
||||
def account_info(ctx: typer.Context) -> None:
|
||||
"""Export account information."""
|
||||
_execute_export(ctx, lambda c: c.account_info_as_df())
|
||||
_execute_export(ctx, _sdk_client(ctx).account_info)
|
||||
|
||||
|
||||
@app.command()
|
||||
def terminal_info(ctx: typer.Context) -> None:
|
||||
"""Export terminal information."""
|
||||
_execute_export(ctx, lambda c: c.terminal_info_as_df())
|
||||
_execute_export(ctx, _sdk_client(ctx).terminal_info)
|
||||
|
||||
|
||||
@app.command()
|
||||
@@ -633,10 +384,8 @@ def symbols(
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Export symbol list."""
|
||||
_execute_export(
|
||||
ctx,
|
||||
lambda c: c.symbols_get_as_df(group=group),
|
||||
)
|
||||
client = _sdk_client(ctx)
|
||||
_execute_export(ctx, lambda: client.symbols(group=group))
|
||||
|
||||
|
||||
@app.command()
|
||||
@@ -645,10 +394,18 @@ def symbol_info(
|
||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||
) -> None:
|
||||
"""Export symbol details."""
|
||||
_execute_export(
|
||||
ctx,
|
||||
lambda c: c.symbol_info_as_df(symbol=symbol),
|
||||
)
|
||||
client = _sdk_client(ctx)
|
||||
_execute_export(ctx, lambda: client.symbol_info(symbol))
|
||||
|
||||
|
||||
@app.command()
|
||||
def minimum_margins(
|
||||
ctx: typer.Context,
|
||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||
) -> None:
|
||||
"""Export minimum-volume buy and sell margin requirements."""
|
||||
client = _sdk_client(ctx)
|
||||
_execute_export(ctx, lambda: client.minimum_margins(symbol))
|
||||
|
||||
|
||||
@app.command()
|
||||
@@ -659,13 +416,10 @@ def orders(
|
||||
ticket: Annotated[int | None, typer.Option(help="Ticket filter.")] = None,
|
||||
) -> None:
|
||||
"""Export active orders."""
|
||||
client = _sdk_client(ctx)
|
||||
_execute_export(
|
||||
ctx,
|
||||
lambda c: c.orders_get_as_df(
|
||||
symbol=symbol,
|
||||
group=group,
|
||||
ticket=ticket,
|
||||
),
|
||||
lambda: client.orders(symbol=symbol, group=group, ticket=ticket),
|
||||
)
|
||||
|
||||
|
||||
@@ -677,13 +431,10 @@ def positions(
|
||||
ticket: Annotated[int | None, typer.Option(help="Ticket filter.")] = None,
|
||||
) -> None:
|
||||
"""Export open positions."""
|
||||
client = _sdk_client(ctx)
|
||||
_execute_export(
|
||||
ctx,
|
||||
lambda c: c.positions_get_as_df(
|
||||
symbol=symbol,
|
||||
group=group,
|
||||
ticket=ticket,
|
||||
),
|
||||
lambda: client.positions(symbol=symbol, group=group, ticket=ticket),
|
||||
)
|
||||
|
||||
|
||||
@@ -704,9 +455,10 @@ def history_orders(
|
||||
position: Annotated[int | None, typer.Option(help="Position ticket.")] = None,
|
||||
) -> None:
|
||||
"""Export historical orders."""
|
||||
client = _sdk_client(ctx)
|
||||
_execute_export(
|
||||
ctx,
|
||||
lambda c: c.history_orders_get_as_df(
|
||||
lambda: client.history_orders(
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
group=group,
|
||||
@@ -734,9 +486,10 @@ def history_deals(
|
||||
position: Annotated[int | None, typer.Option(help="Position ticket.")] = None,
|
||||
) -> None:
|
||||
"""Export historical deals."""
|
||||
client = _sdk_client(ctx)
|
||||
_execute_export(
|
||||
ctx,
|
||||
lambda c: c.history_deals_get_as_df(
|
||||
lambda: client.history_deals(
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
group=group,
|
||||
@@ -747,6 +500,217 @@ def history_deals(
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def recent_history_deals(
|
||||
ctx: typer.Context,
|
||||
hours: Annotated[float, typer.Option(help="Lookback window in hours.")],
|
||||
date_to: Annotated[
|
||||
datetime | None,
|
||||
typer.Option(click_type=DATETIME_TYPE, help="Window end date."),
|
||||
] = None,
|
||||
group: Annotated[str | None, typer.Option(help="Group filter.")] = None,
|
||||
symbol: Annotated[str | None, typer.Option(help="Symbol filter.")] = None,
|
||||
) -> None:
|
||||
"""Export historical deals from a recent trailing window."""
|
||||
client = _sdk_client(ctx)
|
||||
_execute_export(
|
||||
ctx,
|
||||
lambda: client.recent_history_deals(
|
||||
hours,
|
||||
date_to=date_to,
|
||||
group=group,
|
||||
symbol=symbol,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def mt5_summary(ctx: typer.Context) -> None:
|
||||
"""Export a compact terminal/account status summary."""
|
||||
client = _sdk_client(ctx)
|
||||
_execute_export(ctx, client.mt5_summary_as_df)
|
||||
|
||||
|
||||
@app.command()
|
||||
def version(ctx: typer.Context) -> None:
|
||||
"""Export MetaTrader5 version information."""
|
||||
_execute_export(ctx, _sdk_client(ctx).version)
|
||||
|
||||
|
||||
@app.command()
|
||||
def last_error(ctx: typer.Context) -> None:
|
||||
"""Export the last error information."""
|
||||
_execute_export(ctx, _sdk_client(ctx).last_error)
|
||||
|
||||
|
||||
@app.command()
|
||||
def symbol_info_tick(
|
||||
ctx: typer.Context,
|
||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||
) -> None:
|
||||
"""Export the last tick for a symbol."""
|
||||
client = _sdk_client(ctx)
|
||||
_execute_export(ctx, lambda: client.symbol_info_tick(symbol))
|
||||
|
||||
|
||||
@app.command()
|
||||
def market_book(
|
||||
ctx: typer.Context,
|
||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||
) -> None:
|
||||
"""Export market depth (order book) for a symbol."""
|
||||
client = _sdk_client(ctx)
|
||||
_execute_export(ctx, lambda: client.market_book(symbol))
|
||||
|
||||
|
||||
@app.command()
|
||||
def order_check(
|
||||
ctx: typer.Context,
|
||||
request: Annotated[
|
||||
dict[str, Any],
|
||||
typer.Option(click_type=REQUEST_TYPE, help=_REQUEST_OPTION_HELP),
|
||||
],
|
||||
) -> None:
|
||||
"""Check funds sufficiency for a trading operation."""
|
||||
export_ctx = _get_export_context(ctx)
|
||||
|
||||
def _fetch() -> pd.DataFrame:
|
||||
return sdk._run_with_client( # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
|
||||
export_ctx.config,
|
||||
lambda c: c.order_check_as_df(request=request),
|
||||
)
|
||||
|
||||
_execute_export(ctx, _fetch)
|
||||
|
||||
|
||||
@app.command()
|
||||
def order_send(
|
||||
ctx: typer.Context,
|
||||
request: Annotated[
|
||||
dict[str, Any],
|
||||
typer.Option(click_type=REQUEST_TYPE, help=_REQUEST_OPTION_HELP),
|
||||
],
|
||||
yes: Annotated[
|
||||
bool,
|
||||
typer.Option("--yes", help="Confirm the live trade request."),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Send a trading operation request to the trade server.
|
||||
|
||||
Raises:
|
||||
typer.BadParameter: If --yes is not provided.
|
||||
"""
|
||||
if not yes:
|
||||
msg = "Pass --yes to send a live trade request."
|
||||
raise typer.BadParameter(msg, param_hint="--yes")
|
||||
export_ctx = _get_export_context(ctx)
|
||||
|
||||
def _fetch() -> pd.DataFrame:
|
||||
return sdk._run_with_client( # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
|
||||
export_ctx.config,
|
||||
lambda c: c.order_send_as_df(request=request),
|
||||
)
|
||||
|
||||
_execute_export(ctx, _fetch)
|
||||
|
||||
|
||||
@app.command()
|
||||
def collect_history(
|
||||
ctx: typer.Context,
|
||||
symbol: Annotated[
|
||||
list[str],
|
||||
typer.Option(
|
||||
"--symbol",
|
||||
"-s",
|
||||
help="Symbol to collect (repeat for multiple symbols).",
|
||||
),
|
||||
],
|
||||
date_from: Annotated[
|
||||
datetime,
|
||||
typer.Option(click_type=DATETIME_TYPE, help="Start date."),
|
||||
],
|
||||
date_to: Annotated[
|
||||
datetime,
|
||||
typer.Option(click_type=DATETIME_TYPE, help="End date."),
|
||||
],
|
||||
dataset: Annotated[
|
||||
list[Dataset] | None,
|
||||
typer.Option(
|
||||
"--dataset",
|
||||
help=(
|
||||
"Dataset to include (repeat for multiple)."
|
||||
" Defaults to all: rates, ticks, history-orders, history-deals."
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
timeframe: Annotated[
|
||||
int,
|
||||
typer.Option(
|
||||
click_type=TIMEFRAME_TYPE,
|
||||
help="Rates timeframe (e.g., M1, H1, D1).",
|
||||
),
|
||||
] = 1,
|
||||
flags: Annotated[
|
||||
int,
|
||||
typer.Option(
|
||||
click_type=TICK_FLAGS_TYPE,
|
||||
help="Tick copy flags (ALL, INFO, TRADE, or integer).",
|
||||
),
|
||||
] = 1,
|
||||
if_exists: Annotated[
|
||||
IfExists,
|
||||
typer.Option(
|
||||
"--if-exists",
|
||||
help="Behavior when a target table already exists.",
|
||||
),
|
||||
] = IfExists.FAIL,
|
||||
with_views: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--with-views",
|
||||
help=(
|
||||
"Add cash_events and positions_reconstructed SQLite views"
|
||||
" derived from history_deals."
|
||||
),
|
||||
),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Collect historical datasets into a single SQLite database.
|
||||
|
||||
Tables written depend on ``--dataset``: ``rates``, ``ticks``,
|
||||
``history_orders``, ``history_deals``. 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
|
||||
views ``cash_events`` and ``positions_reconstructed`` are derived from
|
||||
``history_deals`` when the required columns are present.
|
||||
|
||||
Raises:
|
||||
typer.BadParameter: If the output format is not SQLite3.
|
||||
"""
|
||||
export_ctx = _get_export_context(ctx)
|
||||
if export_ctx.output_format != "sqlite3":
|
||||
msg = (
|
||||
"collect-history requires SQLite3 output."
|
||||
" Use a .db/.sqlite/.sqlite3 extension or --format sqlite3."
|
||||
)
|
||||
raise typer.BadParameter(msg)
|
||||
datasets = set(dataset) if dataset else set(Dataset)
|
||||
sdk.collect_history(
|
||||
output=export_ctx.output,
|
||||
symbols=symbol,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
datasets=datasets,
|
||||
timeframe=timeframe,
|
||||
flags=flags,
|
||||
if_exists=if_exists,
|
||||
with_views=with_views,
|
||||
config=export_ctx.config,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the mt5cli CLI."""
|
||||
app()
|
||||
|
||||
+1858
File diff suppressed because it is too large
Load Diff
+1892
File diff suppressed because it is too large
Load Diff
+453
@@ -0,0 +1,453 @@
|
||||
"""Utility constants, types, and functions for the mt5cli package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, TypeGuard
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
import pandas as pd
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TIMEFRAME_MAP: dict[str, int] = {
|
||||
"M1": 1,
|
||||
"M2": 2,
|
||||
"M3": 3,
|
||||
"M4": 4,
|
||||
"M5": 5,
|
||||
"M6": 6,
|
||||
"M10": 10,
|
||||
"M12": 12,
|
||||
"M15": 15,
|
||||
"M20": 20,
|
||||
"M30": 30,
|
||||
"H1": 16385,
|
||||
"H2": 16386,
|
||||
"H3": 16387,
|
||||
"H4": 16388,
|
||||
"H6": 16390,
|
||||
"H8": 16392,
|
||||
"H12": 16396,
|
||||
"D1": 16408,
|
||||
"W1": 32769,
|
||||
"MN1": 49153,
|
||||
}
|
||||
|
||||
TICK_FLAG_MAP: dict[str, int] = {
|
||||
"ALL": 1,
|
||||
"INFO": 2,
|
||||
"TRADE": 4,
|
||||
}
|
||||
|
||||
_FORMAT_EXTENSIONS: dict[str, str] = {
|
||||
".csv": "csv",
|
||||
".json": "json",
|
||||
".parquet": "parquet",
|
||||
".pq": "parquet",
|
||||
".db": "sqlite3",
|
||||
".sqlite": "sqlite3",
|
||||
".sqlite3": "sqlite3",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OutputFormat(StrEnum):
|
||||
"""Supported output file formats."""
|
||||
|
||||
csv = "csv"
|
||||
json = "json"
|
||||
parquet = "parquet"
|
||||
sqlite3 = "sqlite3"
|
||||
|
||||
|
||||
class LogLevel(StrEnum):
|
||||
"""Logging verbosity levels."""
|
||||
|
||||
DEBUG = "DEBUG"
|
||||
INFO = "INFO"
|
||||
WARNING = "WARNING"
|
||||
ERROR = "ERROR"
|
||||
|
||||
|
||||
class Dataset(StrEnum):
|
||||
"""Datasets supported by the ``collect-history`` command."""
|
||||
|
||||
rates = "rates"
|
||||
ticks = "ticks"
|
||||
history_orders = "history-orders"
|
||||
history_deals = "history-deals"
|
||||
|
||||
@property
|
||||
def table_name(self) -> str:
|
||||
"""Return the SQLite table name for this dataset."""
|
||||
return self.value.replace("-", "_")
|
||||
|
||||
|
||||
class IfExists(StrEnum):
|
||||
"""SQLite table conflict behavior for the ``collect-history`` command."""
|
||||
|
||||
APPEND = "append"
|
||||
REPLACE = "replace"
|
||||
FAIL = "fail"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Click parameter types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _DateTimeType(click.ParamType):
|
||||
"""Click parameter type for ISO 8601 datetime strings."""
|
||||
|
||||
name = "DATETIME"
|
||||
|
||||
def convert(
|
||||
self,
|
||||
value: object,
|
||||
param: click.Parameter | None,
|
||||
ctx: click.Context | None,
|
||||
) -> datetime:
|
||||
"""Convert a string value to a timezone-aware datetime.
|
||||
|
||||
Args:
|
||||
value: Raw value from the command line.
|
||||
param: Click parameter instance.
|
||||
ctx: Click context.
|
||||
|
||||
Returns:
|
||||
Parsed datetime.
|
||||
"""
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
try:
|
||||
return parse_datetime(str(value))
|
||||
except ValueError as exc:
|
||||
self.fail(str(exc), param, ctx)
|
||||
|
||||
|
||||
class _TimeframeType(click.ParamType):
|
||||
"""Click parameter type for MT5 timeframe values."""
|
||||
|
||||
name = "TIMEFRAME"
|
||||
|
||||
def convert(
|
||||
self,
|
||||
value: object,
|
||||
param: click.Parameter | None,
|
||||
ctx: click.Context | None,
|
||||
) -> int:
|
||||
"""Convert a string or integer value to a timeframe integer.
|
||||
|
||||
Args:
|
||||
value: Raw value from the command line.
|
||||
param: Click parameter instance.
|
||||
ctx: Click context.
|
||||
|
||||
Returns:
|
||||
Integer timeframe value.
|
||||
"""
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
try:
|
||||
return parse_timeframe(str(value))
|
||||
except ValueError as exc:
|
||||
self.fail(str(exc), param, ctx)
|
||||
|
||||
|
||||
class _TickFlagsType(click.ParamType):
|
||||
"""Click parameter type for MT5 tick copy flags."""
|
||||
|
||||
name = "FLAGS"
|
||||
|
||||
def convert(
|
||||
self,
|
||||
value: object,
|
||||
param: click.Parameter | None,
|
||||
ctx: click.Context | None,
|
||||
) -> int:
|
||||
"""Convert a string or integer value to a tick flags integer.
|
||||
|
||||
Args:
|
||||
value: Raw value from the command line.
|
||||
param: Click parameter instance.
|
||||
ctx: Click context.
|
||||
|
||||
Returns:
|
||||
Integer tick flag value.
|
||||
"""
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
try:
|
||||
return parse_tick_flags(str(value))
|
||||
except ValueError as exc:
|
||||
self.fail(str(exc), param, ctx)
|
||||
|
||||
|
||||
class _RequestType(click.ParamType):
|
||||
"""Click parameter type for JSON order requests."""
|
||||
|
||||
name = "REQUEST"
|
||||
|
||||
def convert(
|
||||
self,
|
||||
value: object,
|
||||
param: click.Parameter | None,
|
||||
ctx: click.Context | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Convert a raw CLI value to an order request dictionary.
|
||||
|
||||
Args:
|
||||
value: Raw value from the command line.
|
||||
param: Click parameter instance.
|
||||
ctx: Click context.
|
||||
|
||||
Returns:
|
||||
Parsed request dictionary.
|
||||
"""
|
||||
try:
|
||||
return parse_request(str(value))
|
||||
except ValueError as exc:
|
||||
self.fail(str(exc), param, ctx)
|
||||
|
||||
|
||||
DATETIME_TYPE = _DateTimeType()
|
||||
TIMEFRAME_TYPE = _TimeframeType()
|
||||
TICK_FLAGS_TYPE = _TickFlagsType()
|
||||
REQUEST_TYPE = _RequestType()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public utility functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def detect_format(
|
||||
output_path: Path,
|
||||
explicit_format: str | None = None,
|
||||
) -> str:
|
||||
"""Detect the output format from a file extension or explicit format string.
|
||||
|
||||
Args:
|
||||
output_path: Path to the output file.
|
||||
explicit_format: Explicitly specified format, if any.
|
||||
|
||||
Returns:
|
||||
The detected format string.
|
||||
|
||||
Raises:
|
||||
ValueError: If the format cannot be determined.
|
||||
"""
|
||||
if explicit_format is not None:
|
||||
return explicit_format
|
||||
suffix = output_path.suffix.lower()
|
||||
if suffix in _FORMAT_EXTENSIONS:
|
||||
return _FORMAT_EXTENSIONS[suffix]
|
||||
msg = (
|
||||
f"Cannot detect format from extension '{suffix}'."
|
||||
" Use --format to specify the output format."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def export_dataframe_to_sqlite(
|
||||
df: pd.DataFrame,
|
||||
output_path: Path,
|
||||
table_name: str = "data",
|
||||
*,
|
||||
if_exists: IfExists = IfExists.APPEND,
|
||||
index: bool = False,
|
||||
index_label: str | None = None,
|
||||
deduplicate_on: Sequence[str] | None = None,
|
||||
) -> None:
|
||||
"""Write a DataFrame to SQLite with configurable append and deduplication.
|
||||
|
||||
Args:
|
||||
df: DataFrame to export.
|
||||
output_path: SQLite database path.
|
||||
table_name: Target table name.
|
||||
if_exists: Conflict behavior when the table already exists.
|
||||
index: Whether to write the DataFrame index as a column.
|
||||
index_label: Column name for the index when ``index=True``.
|
||||
deduplicate_on: Optional key columns to deduplicate after writing,
|
||||
keeping the latest ``ROWID`` per key group. Deduplication scans the
|
||||
full table, so repeated appends cost O(table size); index the key
|
||||
columns when appending frequently.
|
||||
"""
|
||||
with sqlite3.connect(output_path) as conn:
|
||||
df.to_sql( # type: ignore[reportUnknownMemberType]
|
||||
table_name,
|
||||
conn,
|
||||
if_exists=if_exists.value,
|
||||
index=index,
|
||||
index_label=index_label,
|
||||
)
|
||||
if deduplicate_on:
|
||||
from .history import drop_duplicates_in_table # noqa: PLC0415
|
||||
|
||||
drop_duplicates_in_table(
|
||||
conn.cursor(),
|
||||
table_name,
|
||||
list(deduplicate_on),
|
||||
keep="last",
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def export_dataframe(
|
||||
df: pd.DataFrame,
|
||||
output_path: Path,
|
||||
output_format: str,
|
||||
table_name: str = "data",
|
||||
) -> None:
|
||||
"""Export a pandas DataFrame to the specified file format.
|
||||
|
||||
Args:
|
||||
df: DataFrame to export.
|
||||
output_path: Path to the output file.
|
||||
output_format: Output format (csv, json, parquet, or sqlite3).
|
||||
table_name: Table name for SQLite3 output.
|
||||
|
||||
Raises:
|
||||
ValueError: If the output format is not supported.
|
||||
"""
|
||||
if output_format == "csv":
|
||||
df.to_csv(output_path, index=False)
|
||||
elif output_format == "json":
|
||||
df.to_json(
|
||||
output_path,
|
||||
orient="records",
|
||||
date_format="iso",
|
||||
indent=2,
|
||||
)
|
||||
elif output_format == "parquet":
|
||||
df.to_parquet(output_path, index=False)
|
||||
elif output_format == "sqlite3":
|
||||
export_dataframe_to_sqlite(
|
||||
df,
|
||||
output_path,
|
||||
table_name,
|
||||
if_exists=IfExists.REPLACE,
|
||||
index=False,
|
||||
)
|
||||
else:
|
||||
msg = f"Unsupported output format: {output_format}"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def parse_datetime(value: str) -> datetime:
|
||||
"""Parse an ISO 8601 datetime string to a timezone-aware datetime.
|
||||
|
||||
Args:
|
||||
value: ISO 8601 datetime string (e.g., '2024-01-01' or
|
||||
'2024-01-01T12:00:00+00:00').
|
||||
|
||||
Returns:
|
||||
Parsed datetime with UTC timezone if no timezone is specified.
|
||||
|
||||
Raises:
|
||||
ValueError: If the string cannot be parsed.
|
||||
"""
|
||||
try:
|
||||
dt = datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
msg = f"Invalid datetime format: '{value}'. Use ISO 8601 format."
|
||||
raise ValueError(msg) from None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt
|
||||
|
||||
|
||||
def parse_timeframe(value: str) -> int:
|
||||
"""Parse a timeframe string or integer value.
|
||||
|
||||
Args:
|
||||
value: Timeframe name (e.g., 'M1', 'H1', 'D1') or integer value.
|
||||
|
||||
Returns:
|
||||
Integer timeframe value.
|
||||
|
||||
Raises:
|
||||
ValueError: If the timeframe is invalid.
|
||||
"""
|
||||
upper = value.upper()
|
||||
if upper in TIMEFRAME_MAP:
|
||||
return TIMEFRAME_MAP[upper]
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
valid = ", ".join(TIMEFRAME_MAP)
|
||||
msg = f"Invalid timeframe: '{value}'. Use one of: {valid}, or an integer."
|
||||
raise ValueError(msg) from None
|
||||
|
||||
|
||||
def parse_tick_flags(value: str) -> int:
|
||||
"""Parse tick flags string or integer value.
|
||||
|
||||
Args:
|
||||
value: Tick flag name (ALL, INFO, TRADE) or integer value.
|
||||
|
||||
Returns:
|
||||
Integer tick flag value.
|
||||
|
||||
Raises:
|
||||
ValueError: If the flag is invalid.
|
||||
"""
|
||||
upper = value.upper()
|
||||
if upper in TICK_FLAG_MAP:
|
||||
return TICK_FLAG_MAP[upper]
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
valid = ", ".join(TICK_FLAG_MAP)
|
||||
msg = f"Invalid tick flags: '{value}'. Use one of: {valid}, or an integer."
|
||||
raise ValueError(msg) from None
|
||||
|
||||
|
||||
def _is_request_dict(value: object) -> TypeGuard[dict[str, Any]]:
|
||||
return isinstance(value, dict)
|
||||
|
||||
|
||||
def parse_request(value: str) -> dict[str, Any]:
|
||||
"""Parse a JSON-formatted order request string or file reference.
|
||||
|
||||
Args:
|
||||
value: JSON object string, or '@path' to read JSON from a file.
|
||||
|
||||
Returns:
|
||||
Parsed request dictionary.
|
||||
|
||||
Raises:
|
||||
ValueError: If the request file cannot be read or the value is not a
|
||||
JSON object.
|
||||
"""
|
||||
if value.startswith("@"):
|
||||
path = Path(value[1:])
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
msg = f"Failed to read JSON request file '{path}': {exc}"
|
||||
raise ValueError(msg) from exc
|
||||
else:
|
||||
text = value
|
||||
try:
|
||||
parsed: object = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
msg = f"Invalid JSON request: {exc}"
|
||||
raise ValueError(msg) from exc
|
||||
if not _is_request_dict(parsed):
|
||||
msg = "Order request must be a JSON object."
|
||||
raise ValueError(msg)
|
||||
return parsed
|
||||
+2
-5
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mt5cli"
|
||||
version = "0.1.0"
|
||||
version = "0.6.0"
|
||||
description = "Command-line tool for MetaTrader 5"
|
||||
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||
@@ -48,10 +48,6 @@ dev = [
|
||||
"pymdown-extensions >= 10.21.2",
|
||||
]
|
||||
|
||||
[tool.uv.build-backend]
|
||||
source-include = ["mt5cli/**", "LICENSE"]
|
||||
source-exclude = ["tests/**"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
exclude = ["build", ".venv"]
|
||||
@@ -128,6 +124,7 @@ ignore = [
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"mt5cli/history.py" = ["TC003"]
|
||||
"tests/**/*.py" = [
|
||||
"DOC201", # Missing return documentation
|
||||
"DOC501", # Raised exception missing from docstring
|
||||
|
||||
+24
-15
@@ -50,21 +50,22 @@ Global options MUST precede the subcommand.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Required options | Optional options |
|
||||
| ---------------- | ----------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `rates-from` | `--symbol`, `--timeframe`, `--date-from`, `--count` | — |
|
||||
| `rates-from-pos` | `--symbol`, `--timeframe`, `--start-pos`, `--count` | — |
|
||||
| `rates-range` | `--symbol`, `--timeframe`, `--date-from`, `--date-to` | — |
|
||||
| `ticks-from` | `--symbol`, `--date-from`, `--count`, `--flags` | — |
|
||||
| `ticks-range` | `--symbol`, `--date-from`, `--date-to`, `--flags` | — |
|
||||
| `account-info` | — | — |
|
||||
| `terminal-info` | — | — |
|
||||
| `symbols` | — | `--group` (e.g., `*USD*`) |
|
||||
| `symbol-info` | `--symbol` | — |
|
||||
| `orders` | — | `--symbol`, `--group`, `--ticket` |
|
||||
| `positions` | — | `--symbol`, `--group`, `--ticket` |
|
||||
| `history-orders` | — | `--date-from`, `--date-to`, `--group`, `--symbol`, `--ticket`, `--position` |
|
||||
| `history-deals` | — | `--date-from`, `--date-to`, `--group`, `--symbol`, `--ticket`, `--position` |
|
||||
| Command | Required options | Optional options |
|
||||
| ----------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `rates-from` | `--symbol`, `--timeframe`, `--date-from`, `--count` | — |
|
||||
| `rates-from-pos` | `--symbol`, `--timeframe`, `--start-pos`, `--count` | — |
|
||||
| `rates-range` | `--symbol`, `--timeframe`, `--date-from`, `--date-to` | — |
|
||||
| `ticks-from` | `--symbol`, `--date-from`, `--count`, `--flags` | — |
|
||||
| `ticks-range` | `--symbol`, `--date-from`, `--date-to`, `--flags` | — |
|
||||
| `account-info` | — | — |
|
||||
| `terminal-info` | — | — |
|
||||
| `symbols` | — | `--group` (e.g., `*USD*`) |
|
||||
| `symbol-info` | `--symbol` | — |
|
||||
| `orders` | — | `--symbol`, `--group`, `--ticket` |
|
||||
| `positions` | — | `--symbol`, `--group`, `--ticket` |
|
||||
| `history-orders` | — | `--date-from`, `--date-to`, `--group`, `--symbol`, `--ticket`, `--position` |
|
||||
| `history-deals` | — | `--date-from`, `--date-to`, `--group`, `--symbol`, `--ticket`, `--position` |
|
||||
| `collect-history` | `--symbol` (repeatable), `--date-from`, `--date-to` | `--dataset` (repeatable; rates/ticks/history-orders/history-deals; default all), `--timeframe` (M1; recorded on rates), `--flags` (ALL), `--if-exists` (append/replace/fail; default fail), `--with-views` (SQLite3 output only) |
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -85,6 +86,14 @@ mt5cli -o data.db --table symbols symbols --group "*USD*"
|
||||
|
||||
# Historical deals filtered by symbol (using an already-logged-in MT5 terminal).
|
||||
mt5cli -o deals.csv history-deals --symbol EURUSD --date-from 2024-01-01
|
||||
|
||||
# Bundle selected historical datasets into one SQLite db, appending to any
|
||||
# existing tables, plus cash_events and positions_reconstructed views.
|
||||
mt5cli -o history.db collect-history \
|
||||
--symbol EURUSD --symbol GBPUSD \
|
||||
--date-from 2024-01-01 --date-to 2024-02-01 \
|
||||
--dataset rates --dataset history-deals \
|
||||
--timeframe M1 --flags ALL --if-exists append --with-views
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
+1007
-258
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1934
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,443 @@
|
||||
"""Tests for mt5cli.utils module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from mt5cli.utils import (
|
||||
DATETIME_TYPE,
|
||||
REQUEST_TYPE,
|
||||
TICK_FLAG_MAP,
|
||||
TICK_FLAGS_TYPE,
|
||||
TIMEFRAME_MAP,
|
||||
TIMEFRAME_TYPE,
|
||||
Dataset,
|
||||
IfExists,
|
||||
detect_format,
|
||||
export_dataframe,
|
||||
export_dataframe_to_sqlite,
|
||||
parse_datetime,
|
||||
parse_request,
|
||||
parse_tick_flags,
|
||||
parse_timeframe,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detect_format
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDetectFormat:
|
||||
"""Tests for detect_format."""
|
||||
|
||||
def test_explicit_format_returned(self, tmp_path: Path) -> None:
|
||||
"""Test that explicit format overrides extension."""
|
||||
result = detect_format(tmp_path / "data.txt", explicit_format="csv")
|
||||
assert result == "csv"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("filename", "expected"),
|
||||
[
|
||||
("data.csv", "csv"),
|
||||
("data.json", "json"),
|
||||
("data.parquet", "parquet"),
|
||||
("data.pq", "parquet"),
|
||||
("data.db", "sqlite3"),
|
||||
("data.sqlite", "sqlite3"),
|
||||
("data.sqlite3", "sqlite3"),
|
||||
("DATA.CSV", "csv"),
|
||||
("DATA.JSON", "json"),
|
||||
("DATA.PARQUET", "parquet"),
|
||||
],
|
||||
)
|
||||
def test_auto_detect_from_extension(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
filename: str,
|
||||
expected: str,
|
||||
) -> None:
|
||||
"""Test format auto-detection from file extension."""
|
||||
result = detect_format(tmp_path / filename)
|
||||
assert result == expected
|
||||
|
||||
def test_unknown_extension_raises(self, tmp_path: Path) -> None:
|
||||
"""Test that unknown extension raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Cannot detect format"):
|
||||
detect_format(tmp_path / "data.xyz")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# export_dataframe
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExportDataframe:
|
||||
"""Tests for export_dataframe."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_df(self) -> pd.DataFrame:
|
||||
"""Create a sample DataFrame for testing."""
|
||||
return pd.DataFrame({"a": [1, 2, 3], "b": ["x", "y", "z"]})
|
||||
|
||||
def test_export_csv(self, tmp_path: Path, sample_df: pd.DataFrame) -> None:
|
||||
"""Test CSV export."""
|
||||
output = tmp_path / "out.csv"
|
||||
export_dataframe(sample_df, output, "csv")
|
||||
result = pd.read_csv(output)
|
||||
pd.testing.assert_frame_equal(result, sample_df)
|
||||
|
||||
def test_export_json(self, tmp_path: Path, sample_df: pd.DataFrame) -> None:
|
||||
"""Test JSON export."""
|
||||
output = tmp_path / "out.json"
|
||||
export_dataframe(sample_df, output, "json")
|
||||
with output.open() as f:
|
||||
records = json.load(f)
|
||||
assert len(records) == 3
|
||||
assert records[0]["a"] == 1
|
||||
|
||||
def test_export_parquet(self, tmp_path: Path, sample_df: pd.DataFrame) -> None:
|
||||
"""Test Parquet export."""
|
||||
output = tmp_path / "out.parquet"
|
||||
export_dataframe(sample_df, output, "parquet")
|
||||
result = pd.read_parquet(output)
|
||||
pd.testing.assert_frame_equal(result, sample_df)
|
||||
|
||||
def test_export_sqlite3(self, tmp_path: Path, sample_df: pd.DataFrame) -> None:
|
||||
"""Test SQLite3 export."""
|
||||
output = tmp_path / "out.db"
|
||||
export_dataframe(sample_df, output, "sqlite3", table_name="test_table")
|
||||
with sqlite3.connect(output) as conn:
|
||||
result = pd.read_sql( # type: ignore[reportUnknownMemberType]
|
||||
"SELECT * FROM test_table",
|
||||
conn,
|
||||
)
|
||||
pd.testing.assert_frame_equal(result, sample_df)
|
||||
|
||||
def test_unsupported_format_raises(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
sample_df: pd.DataFrame,
|
||||
) -> None:
|
||||
"""Test that unsupported format raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Unsupported output format"):
|
||||
export_dataframe(sample_df, tmp_path / "out.txt", "xml")
|
||||
|
||||
|
||||
class TestExportDataframeToSqlite:
|
||||
"""Tests for export_dataframe_to_sqlite."""
|
||||
|
||||
def test_append_preserves_existing_rows(self, tmp_path: Path) -> None:
|
||||
"""Test append mode keeps prior rows in the SQLite table."""
|
||||
output = tmp_path / "append.db"
|
||||
first = pd.DataFrame({"id": [1], "value": ["a"]})
|
||||
second = pd.DataFrame({"id": [2], "value": ["b"]})
|
||||
export_dataframe_to_sqlite(first, output, "items", if_exists=IfExists.REPLACE)
|
||||
export_dataframe_to_sqlite(second, output, "items", if_exists=IfExists.APPEND)
|
||||
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_deduplicate_keeps_latest_row(self, tmp_path: Path) -> None:
|
||||
"""Test deduplication keeps the latest ROWID for key columns."""
|
||||
output = tmp_path / "dedup.db"
|
||||
first = pd.DataFrame({
|
||||
"symbol": ["EURUSD", "EURUSD"],
|
||||
"time": ["2024-01-01", "2024-01-01"],
|
||||
"bid": [1.0, 1.1],
|
||||
})
|
||||
second = pd.DataFrame({
|
||||
"symbol": ["EURUSD"],
|
||||
"time": ["2024-01-01"],
|
||||
"bid": [1.2],
|
||||
})
|
||||
export_dataframe_to_sqlite(
|
||||
first,
|
||||
output,
|
||||
"ticks",
|
||||
if_exists=IfExists.REPLACE,
|
||||
deduplicate_on=("symbol", "time"),
|
||||
)
|
||||
export_dataframe_to_sqlite(
|
||||
second,
|
||||
output,
|
||||
"ticks",
|
||||
if_exists=IfExists.APPEND,
|
||||
deduplicate_on=("symbol", "time"),
|
||||
)
|
||||
with sqlite3.connect(output) as conn:
|
||||
result = pd.read_sql( # type: ignore[reportUnknownMemberType]
|
||||
"SELECT symbol, time, bid FROM ticks",
|
||||
conn,
|
||||
)
|
||||
pd.testing.assert_frame_equal(
|
||||
result.reset_index(drop=True),
|
||||
pd.DataFrame({
|
||||
"symbol": ["EURUSD"],
|
||||
"time": ["2024-01-01"],
|
||||
"bid": [1.2],
|
||||
}),
|
||||
)
|
||||
|
||||
def test_default_if_exists_appends_without_dropping_rows(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test the default append mode keeps prior rows."""
|
||||
output = tmp_path / "default-append.db"
|
||||
first = pd.DataFrame({"id": [1], "value": ["a"]})
|
||||
second = pd.DataFrame({"id": [2], "value": ["b"]})
|
||||
export_dataframe_to_sqlite(first, output, "items")
|
||||
export_dataframe_to_sqlite(second, output, "items")
|
||||
with sqlite3.connect(output) as conn:
|
||||
result = pd.read_sql( # type: ignore[reportUnknownMemberType]
|
||||
"SELECT id, value FROM items ORDER BY id",
|
||||
conn,
|
||||
)
|
||||
pd.testing.assert_frame_equal(
|
||||
result,
|
||||
pd.DataFrame({"id": [1, 2], "value": ["a", "b"]}),
|
||||
)
|
||||
|
||||
def test_writes_index_with_label(self, tmp_path: Path) -> None:
|
||||
"""Test optional index export with a custom label."""
|
||||
output = tmp_path / "index.db"
|
||||
frame = pd.DataFrame(
|
||||
{"value": [1.0]}, index=pd.Index(["EURUSD"], name="symbol")
|
||||
)
|
||||
export_dataframe_to_sqlite(
|
||||
frame,
|
||||
output,
|
||||
"margins",
|
||||
if_exists=IfExists.REPLACE,
|
||||
index=True,
|
||||
index_label="symbol",
|
||||
)
|
||||
with sqlite3.connect(output) as conn:
|
||||
result = pd.read_sql( # type: ignore[reportUnknownMemberType]
|
||||
"SELECT symbol, value FROM margins",
|
||||
conn,
|
||||
)
|
||||
pd.testing.assert_frame_equal(
|
||||
result,
|
||||
pd.DataFrame({"symbol": ["EURUSD"], "value": [1.0]}),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parse helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseDatetime:
|
||||
"""Tests for parse_datetime."""
|
||||
|
||||
def test_valid_date(self) -> None:
|
||||
"""Test parsing a date string."""
|
||||
result = parse_datetime("2024-01-15")
|
||||
assert result == datetime(2024, 1, 15, tzinfo=UTC)
|
||||
|
||||
def test_valid_datetime_with_tz(self) -> None:
|
||||
"""Test parsing a datetime with timezone."""
|
||||
result = parse_datetime("2024-01-15T12:00:00+00:00")
|
||||
assert result == datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC)
|
||||
|
||||
def test_invalid_format_raises(self) -> None:
|
||||
"""Test that invalid format raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid datetime"):
|
||||
parse_datetime("not-a-date")
|
||||
|
||||
|
||||
class TestParseTimeframe:
|
||||
"""Tests for parse_timeframe."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[("M1", 1), ("h1", 16385), ("D1", 16408), ("MN1", 49153)],
|
||||
)
|
||||
def test_named_timeframe(self, value: str, expected: int) -> None:
|
||||
"""Test parsing named timeframes."""
|
||||
assert parse_timeframe(value) == expected
|
||||
|
||||
def test_integer_timeframe(self) -> None:
|
||||
"""Test parsing integer timeframe."""
|
||||
assert parse_timeframe("42") == 42
|
||||
|
||||
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:
|
||||
"""Tests for parse_tick_flags."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[("ALL", 1), ("info", 2), ("TRADE", 4)],
|
||||
)
|
||||
def test_named_flag(self, value: str, expected: int) -> None:
|
||||
"""Test parsing named tick flags."""
|
||||
assert parse_tick_flags(value) == expected
|
||||
|
||||
def test_integer_flag(self) -> None:
|
||||
"""Test parsing integer tick flag."""
|
||||
assert parse_tick_flags("7") == 7
|
||||
|
||||
def test_invalid_flag_raises(self) -> None:
|
||||
"""Test that invalid flag raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid tick flags"):
|
||||
parse_tick_flags("INVALID")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_request
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseRequest:
|
||||
"""Tests for parse_request."""
|
||||
|
||||
def test_inline_json(self) -> None:
|
||||
"""Test parsing an inline JSON object string."""
|
||||
result = parse_request('{"action": 1, "symbol": "EURUSD"}')
|
||||
assert result == {"action": 1, "symbol": "EURUSD"}
|
||||
|
||||
def test_file_reference(self, tmp_path: Path) -> None:
|
||||
"""Test parsing JSON from a file via the @path syntax."""
|
||||
path = tmp_path / "req.json"
|
||||
path.write_text('{"action": 2}', encoding="utf-8")
|
||||
result = parse_request(f"@{path}")
|
||||
assert result == {"action": 2}
|
||||
|
||||
def test_invalid_json_raises(self) -> None:
|
||||
"""Test that invalid JSON raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid JSON request"):
|
||||
parse_request("not json")
|
||||
|
||||
def test_non_object_raises(self) -> None:
|
||||
"""Test that a non-object JSON raises ValueError."""
|
||||
with pytest.raises(ValueError, match="must be a JSON object"):
|
||||
parse_request("[1, 2, 3]")
|
||||
|
||||
def test_missing_file_raises(self, tmp_path: Path) -> None:
|
||||
"""Test that a missing request file raises ValueError."""
|
||||
path = tmp_path / "missing.json"
|
||||
with pytest.raises(ValueError, match="Failed to read JSON request file"):
|
||||
parse_request(f"@{path}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConstants:
|
||||
"""Tests for module constants."""
|
||||
|
||||
def test_timeframe_map_has_expected_keys(self) -> None:
|
||||
"""Test that TIMEFRAME_MAP contains standard timeframes."""
|
||||
for key in ("M1", "M5", "M15", "M30", "H1", "H4", "D1", "W1", "MN1"):
|
||||
assert key in TIMEFRAME_MAP
|
||||
|
||||
def test_tick_flag_map_has_expected_keys(self) -> None:
|
||||
"""Test that TICK_FLAG_MAP contains standard flags."""
|
||||
assert set(TICK_FLAG_MAP) == {"ALL", "INFO", "TRADE"}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("dataset", "expected"),
|
||||
[
|
||||
(Dataset.rates, "rates"),
|
||||
(Dataset.ticks, "ticks"),
|
||||
(Dataset.history_orders, "history_orders"),
|
||||
(Dataset.history_deals, "history_deals"),
|
||||
],
|
||||
)
|
||||
def test_dataset_table_name(self, dataset: Dataset, expected: str) -> None:
|
||||
"""Test dataset SQLite table names."""
|
||||
assert dataset.table_name == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Click ParamTypes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDateTimeType:
|
||||
"""Tests for _DateTimeType."""
|
||||
|
||||
def test_convert_string(self) -> None:
|
||||
"""Test converting a string to datetime."""
|
||||
result = DATETIME_TYPE.convert("2024-06-15", None, None)
|
||||
assert result == datetime(2024, 6, 15, tzinfo=UTC)
|
||||
|
||||
def test_convert_datetime_passthrough(self) -> None:
|
||||
"""Test that datetime values pass through unchanged."""
|
||||
dt = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
assert DATETIME_TYPE.convert(dt, None, None) is dt
|
||||
|
||||
def test_convert_invalid(self) -> None:
|
||||
"""Test that invalid values raise BadParameter."""
|
||||
with pytest.raises(Exception, match="Invalid datetime"):
|
||||
DATETIME_TYPE.convert("bad", None, None)
|
||||
|
||||
|
||||
class TestTimeframeType:
|
||||
"""Tests for _TimeframeType."""
|
||||
|
||||
def test_convert_string(self) -> None:
|
||||
"""Test converting a string to timeframe integer."""
|
||||
assert TIMEFRAME_TYPE.convert("H1", None, None) == 16385
|
||||
|
||||
def test_convert_int_passthrough(self) -> None:
|
||||
"""Test that integer values pass through unchanged."""
|
||||
assert TIMEFRAME_TYPE.convert(42, None, None) == 42
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class TestTickFlagsType:
|
||||
"""Tests for _TickFlagsType."""
|
||||
|
||||
def test_convert_string(self) -> None:
|
||||
"""Test converting a string to tick flags integer."""
|
||||
assert TICK_FLAGS_TYPE.convert("ALL", None, None) == 1
|
||||
|
||||
def test_convert_int_passthrough(self) -> None:
|
||||
"""Test that integer values pass through unchanged."""
|
||||
assert TICK_FLAGS_TYPE.convert(7, None, None) == 7
|
||||
|
||||
def test_convert_invalid(self) -> None:
|
||||
"""Test that invalid values raise BadParameter."""
|
||||
with pytest.raises(Exception, match="Invalid tick flags"):
|
||||
TICK_FLAGS_TYPE.convert("bad", None, None)
|
||||
|
||||
|
||||
class TestRequestType:
|
||||
"""Tests for _RequestType."""
|
||||
|
||||
def test_convert_string(self) -> None:
|
||||
"""Test converting a JSON string to a request dictionary."""
|
||||
assert REQUEST_TYPE.convert('{"action": 1}', None, None) == {"action": 1}
|
||||
|
||||
def test_convert_invalid(self) -> None:
|
||||
"""Test that invalid values raise BadParameter."""
|
||||
with pytest.raises(Exception, match="Invalid JSON request"):
|
||||
REQUEST_TYPE.convert("bad", None, None)
|
||||
@@ -234,11 +234,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.13"
|
||||
version = "3.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -487,7 +487,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mt5cli"
|
||||
version = "0.1.0"
|
||||
version = "0.6.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -836,24 +836,24 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pymdown-extensions"
|
||||
version = "10.21.2"
|
||||
version = "10.21.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown" },
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1126,11 +1126,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
version = "2.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user