Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d156dd7176 | |||
| 307d6f5320 | |||
| 8031389a67 | |||
| fdf5e08d31 |
@@ -0,0 +1,149 @@
|
||||
---
|
||||
name: pr-feedback-triage
|
||||
description: Triage pull request review comments into fixes, replies, clarification requests, or open follow-ups while respecting safe execution modes.
|
||||
---
|
||||
|
||||
# PR Feedback Triage
|
||||
|
||||
Triage pull request review feedback, decide what action each thread needs, make focused fixes when allowed, and report or resolve only what is actually handled.
|
||||
|
||||
## When to Use
|
||||
|
||||
- A PR has review comments, requested changes, unresolved review threads, or bot review findings.
|
||||
- The user asks to address, respond to, or resolve PR feedback.
|
||||
- The user provides a PR URL/number, a branch with an associated PR, or copied comments.
|
||||
|
||||
Do not use this skill for a first-pass code review with no existing feedback; use a code review skill instead.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Pull request URL or number, or a current branch that has an associated pull request.
|
||||
- Repository checkout or platform access sufficient to inspect the PR diff and review feedback.
|
||||
- Optional reviewer priorities from the user, such as "only address blocking comments" or "do not reply on the PR platform".
|
||||
- Optional operating mode flags: `dry_run`, `no_push`, and `no_reply`.
|
||||
|
||||
If no PR or review comments are identifiable, ask for the target PR or the copied comments before proceeding.
|
||||
|
||||
## Modes
|
||||
|
||||
- `dry_run`: inspect review feedback and report the triage only. Do not edit files, run write-mode formatters, commit, push, post replies, or resolve review threads.
|
||||
- `no_push`: local edits and verification are allowed, but do not push commits or otherwise update the remote branch. Report the local diff or local commits that still need to be pushed.
|
||||
- `no_reply`: do not post replies, submit reviews, or resolve review threads. Provide suggested replies and resolution actions in the final report instead.
|
||||
|
||||
When a mode disables an action, skip that destructive or externally visible action even if normal workflow text would otherwise allow it.
|
||||
|
||||
## Preflight
|
||||
|
||||
1. Identify the current branch and target PR.
|
||||
2. Check tracked local changes with `git diff --name-only` and `git diff --cached --name-only`. Ignore untracked files unless the review feedback explicitly concerns them.
|
||||
3. Check unpushed commits before relying on remote review feedback.
|
||||
4. If tracked local changes or unpushed commits exist, warn that existing PR comments may not cover the latest local state. In `normal` mode, push only when the user request or repository workflow allows it; otherwise continue with a clearly reported limitation.
|
||||
|
||||
## Feedback Collection
|
||||
|
||||
Gather the complete feedback set before editing:
|
||||
|
||||
- Fetch unresolved review threads, requested-change reviews, PR-level summary comments, and copied comments.
|
||||
- Use platform-native APIs/CLI when available. Paginate results; do not inspect only the first page of threads or comments.
|
||||
- For bot reviewers that post both summary comments and inline comments, collect both. Summary comments often contain severity, rationale, and fix instructions; inline comments contain the exact file and line context.
|
||||
- Preserve each thread/comment identifier needed to reply or resolve later.
|
||||
- Compare each comment with the current diff and file contents because review lines can become outdated.
|
||||
|
||||
## Deduplication and Ordering
|
||||
|
||||
Build one triage record per distinct finding:
|
||||
|
||||
- Prefer exact review-thread identity when available.
|
||||
- For duplicate bot findings appearing in both summary and inline comments, merge by exact issue title first, then by file path plus line range as a fallback.
|
||||
- Prefer inline comments for location and current code context.
|
||||
- Prefer summary comments for severity, category, rationale, and detailed agent prompts.
|
||||
- Preserve the reviewer’s exact issue title and original wording where practical. Do not rename findings in a way that would make replies hard to map back to comments.
|
||||
- Preserve the reviewer’s original ordering unless the user asks for priority reordering. Many review bots already order findings by severity.
|
||||
|
||||
Each triage record should track: original title, reviewer, source IDs, location, current applicability, severity/priority if available, disposition, planned action, verification, reply text if any, and resolution decision.
|
||||
|
||||
## Resolution Policy
|
||||
|
||||
In normal mode, `Resolve conversation` is the default action for any review thread that has been fully handled. A thread is handled when the requested change is implemented and verified, the current code already satisfies the comment, the comment is outdated and no longer applies, or a deliberate deferral/won't-fix response has been posted with a clear reason.
|
||||
|
||||
Keep a thread open only when it still needs reviewer, maintainer, or product input, the fix is local-only and not pushed, verification is missing for a material change, or the user explicitly requested `dry_run`, `no_push`, or `no_reply` behavior that prevents resolution.
|
||||
|
||||
When resolving a thread, add a concise reply first only if it provides useful context, such as what changed, why no code change was needed, why a finding was intentionally deferred, or why the original comment is now outdated. Do not add noisy replies for self-evident fixes unless project norms require them.
|
||||
|
||||
## Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Identify PR and branch state] --> B[Collect all review feedback]
|
||||
B --> C[Deduplicate and preserve source IDs]
|
||||
C --> D[Inspect current diff and code]
|
||||
D --> E{Classify each triage record}
|
||||
E -->|Fix| F[Implement minimal change]
|
||||
E -->|Answer| G[Prepare concise reply]
|
||||
E -->|Clarify| H[Leave open with question]
|
||||
E -->|Already addressed or Outdated| I[Prepare evidence]
|
||||
E -->|Defer or Won't fix| J[Document reason]
|
||||
F --> K[Verify]
|
||||
G --> L{Mode}
|
||||
H --> L
|
||||
I --> L
|
||||
J --> L
|
||||
K --> L
|
||||
L -->|dry_run| M[Report triage only]
|
||||
L -->|no_push| N[Report local diff or commits]
|
||||
L -->|no_reply| O[Report suggested replies/actions]
|
||||
L -->|normal| P[Commit/push if changed, then batch reply and resolve handled threads]
|
||||
M --> Q[Final summary]
|
||||
N --> Q
|
||||
O --> Q
|
||||
P --> Q
|
||||
```
|
||||
|
||||
## Compact Workflow
|
||||
|
||||
1. **Collect all relevant feedback**
|
||||
- Identify the PR and gather unresolved review threads, requested-change reviews, PR-level summaries, inline comments, and copied comments.
|
||||
- Paginate all platform calls and keep comment/thread IDs for later replies and resolution.
|
||||
- For bot reviews, collect both summary and inline comments, then merge duplicates rather than fixing the same finding twice.
|
||||
|
||||
2. **Classify each triage record**
|
||||
- **Fix**: Valid requested change; make the smallest focused edit when not in `dry_run`.
|
||||
- **Answer**: No code change needed; prepare a concise explanation.
|
||||
- **Clarify**: Ambiguous, conflicting, or missing context; leave open with a question.
|
||||
- **Already addressed**: Current code already satisfies it; prepare evidence.
|
||||
- **Outdated**: Commented code or issue no longer exists; prepare evidence.
|
||||
- **Defer / Won't fix**: Valid concern intentionally not changed now; document a specific reason.
|
||||
|
||||
3. **Act according to the classification and mode**
|
||||
- Keep edits scoped to the review feedback.
|
||||
- Follow reviewer-provided fix instructions literally when they are still applicable; deviate only when the current code proves the instruction is stale or unsafe.
|
||||
- In `dry_run`, stop at triage, proposed fixes, suggested replies, and verification plan.
|
||||
- In `no_push`, local edits are allowed, but do not push or resolve threads for local-only fixes.
|
||||
- In `no_reply`, do not post replies or resolve threads; report suggested replies/actions instead.
|
||||
- In normal mode, batch replies where practical, then resolve every handled thread by default after the fix, explanation, or deferral reason is available on the PR.
|
||||
|
||||
4. **Verify before claiming completion**
|
||||
- For fixes, run appropriate checks or explain why they could not run.
|
||||
- Re-inspect the updated diff and comment context to confirm the concern is resolved.
|
||||
- Do not mark a thread resolved if it still needs reviewer, maintainer, or product input.
|
||||
|
||||
5. **Finish**
|
||||
- Normal mode: commit/push changes when appropriate, post useful replies or a summary, and resolve all handled threads by default.
|
||||
- Safe modes: report the local state and the exact replies/resolution actions a human could take.
|
||||
|
||||
## Reply Guidance
|
||||
|
||||
- Keep inline replies short and tied to the original title or concern.
|
||||
- For fixed findings, mention the concrete change or commit if useful.
|
||||
- For already-addressed or outdated findings, cite the current code path or behavior that makes the finding no longer applicable.
|
||||
- For deferred or won't-fix findings, provide the reason and any follow-up issue or owner if known.
|
||||
- If a reply or resolve operation fails, continue with the remaining threads and report the failure in the final summary.
|
||||
|
||||
## Final Summary Checklist
|
||||
|
||||
- Mode used: `normal`, `dry_run`, `no_push`, or `no_reply`
|
||||
- Counts by disposition: fixed, answered, clarified/left open, already addressed, outdated, deferred/won't-fix
|
||||
- Threads resolved, intentionally left open, or resolution actions skipped by mode
|
||||
- Verification run or planned
|
||||
- Commits pushed, local diff/commits, or "none"
|
||||
- Remaining open items and who needs to respond
|
||||
@@ -62,6 +62,19 @@ jobs:
|
||||
runs-on: ubuntu-slim
|
||||
secrets:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
github-codeql-analysis:
|
||||
if: >
|
||||
github.event_name == 'push'
|
||||
|| github.event_name == 'pull_request'
|
||||
|| (github.event_name == 'workflow_dispatch' && inputs.workflow == 'lint-and-test')
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
actions: read
|
||||
uses: dceoy/gh-actions-for-devops/.github/workflows/github-codeql-analysis.yml@main # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
language: >
|
||||
["python"]
|
||||
dependabot-auto-merge:
|
||||
if: >
|
||||
github.event_name == 'pull_request' && github.actor == 'dependabot[bot]'
|
||||
@@ -73,5 +86,7 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
actions: read
|
||||
checks: read
|
||||
statuses: read
|
||||
with:
|
||||
unconditional: true
|
||||
|
||||
@@ -1,81 +1,37 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Commands
|
||||
## Project Structure & Module Organization
|
||||
|
||||
### Development Setup
|
||||
`mt5cli/` contains the package source. Important modules include `cli.py` for the Typer command-line app, `client.py` and `sdk.py` for public MT5 client/session APIs, `history.py` for SQLite history collection, `storage.py` and `converters.py` for export behavior, and `schemas.py` for normalized dataset contracts. `tests/` holds pytest coverage for CLI behavior, SDK contracts, trading helpers, history, and utilities. `docs/` and `mkdocs.yml` define the MkDocs site and API reference. `skills/mt5cli/SKILL.md` documents the mt5cli agent skill.
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
### Code Quality and Documentation
|
||||
- `uv sync` installs runtime and development dependencies from `pyproject.toml` and `uv.lock`.
|
||||
- `uv run mt5cli --help` runs the local CLI entry point.
|
||||
- `uv run ruff format .` formats Python files.
|
||||
- `uv run ruff check --fix .` lints and applies safe fixes.
|
||||
- `uv run pyright .` runs strict type checking.
|
||||
- `uv run pytest` runs doctests, branch coverage, and the test suite.
|
||||
- `uv run mkdocs serve` previews documentation locally; `uv run mkdocs build` validates the docs build.
|
||||
|
||||
**Important**: Run these before committing or creating a PR.
|
||||
Use `.agents/skills/local-qa/SKILL.md` for pre-handoff QA. It runs `.agents/skills/local-qa/scripts/qa.sh`, which formats, lints, type-checks, tests, formats Markdown, and checks GitHub workflows.
|
||||
|
||||
1. **format, lint, and test**: Use `local-qa` skill.
|
||||
2. **Documentation build** (if any public API changes): `uv run mkdocs build`
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
## Architecture
|
||||
Target Python `>=3.11,<3.14`. Use Ruff’s configured 88-character line length and Google-style docstrings. Pyright is strict, so prefer explicit public type annotations and narrow exception handling. Keep module, function, and variable names in `snake_case`; classes and enums use `PascalCase`. Preserve the package’s small, typed helper style rather than adding broad abstractions.
|
||||
|
||||
### Key Dependencies
|
||||
## Design Principles
|
||||
|
||||
- **pdmt5**: Pandas-based data handler for MetaTrader 5 (core library)
|
||||
- **typer**: CLI framework for building command-line interfaces
|
||||
- **click**: Parameter type customization for CLI options
|
||||
- **pandas**: Core data manipulation and analysis
|
||||
Apply KISS, DRY, and YAGNI when changing code. Prefer the simplest implementation that satisfies the current CLI/API contract. Remove duplication when shared behavior is already proven by at least two concrete call sites, but avoid generic helpers for speculative reuse. Do not add configuration flags, extension hooks, or alternate backends until a real repository use case requires them.
|
||||
|
||||
### Package Structure
|
||||
## Testing Guidelines
|
||||
|
||||
- `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 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
|
||||
- Modern Python packaging with `pyproject.toml` and uv dependency management
|
||||
|
||||
### Quality Standards
|
||||
|
||||
- Type hints required (pyright strict mode)
|
||||
- Comprehensive linting with 35+ rule categories (ruff)
|
||||
- Test coverage tracking with 100% (pytest-cov)
|
||||
- Parametrized tests for input/result matrices using `pytest.mark.parametrize` (pytest)
|
||||
- Test doubles (mocks, stubs) using `pytest_mock` for external dependencies (pytest-mock)
|
||||
- Pydantic models for data validation and configuration
|
||||
|
||||
### Documentation workflow
|
||||
|
||||
1. Add Google-style docstrings to functions/classes
|
||||
2. Local preview: `uv run mkdocs serve`
|
||||
3. Build: `uv run mkdocs build`
|
||||
4. Deploy: `uv run mkdocs gh-deploy`
|
||||
Tests use pytest, pytest-mock, doctests, and pytest-cov. Test files should match `tests/test_*.py`, classes `Test*`, and functions `test_*`. Coverage is configured with `fail_under = 100`, so add focused tests for every behavior change. Mock MT5/pdmt5 boundaries; do not require a live MetaTrader terminal in unit tests.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
- Run QA checks using `local-qa` skill before committing or creating a PR.
|
||||
- Branch names use appropriate prefixes on creation (e.g., `feature/...`, `bugfix/...`, `refactor/...`, `docs/...`, `chore/...`).
|
||||
- When instructed to create a PR, create it as a draft with appropriate labels by default.
|
||||
Recent history uses concise imperative commits, sometimes with conventional prefixes such as `feat:` or `chore:` and PR numbers appended by GitHub. Keep commits scoped to one logical change. Pull requests should describe behavior changes, note tests run, link related issues, and call out MT5/live-trading risk where relevant.
|
||||
|
||||
## Code Design Principles
|
||||
## Security & Configuration Tips
|
||||
|
||||
Always prefer the simplest design that works.
|
||||
|
||||
- **KISS**: Choose straightforward solutions and avoid unnecessary abstraction.
|
||||
- **DRY**: Remove duplication when it improves clarity and maintainability.
|
||||
- **YAGNI**: Do not add features, hooks, or flexibility until they are needed.
|
||||
- **SOLID/Clean Code**: Apply these as tools, only when they keep the design simpler and easier to change.
|
||||
|
||||
## Development Methodology
|
||||
|
||||
Keep delivery incremental, test-backed, and easy to review.
|
||||
|
||||
- Make small, safe, reversible changes.
|
||||
- Prefer `Red -> Green -> Refactor`.
|
||||
- Do not mix feature work and refactoring in the same commit.
|
||||
- Refactor when it improves clarity or removes real duplication (Rule of Three).
|
||||
- Keep tests fast, focused, and self-validating.
|
||||
Never commit account credentials, broker passwords, exported private data, or local `.venv` contents. Treat `order_send` and CLI `order-send --yes` as live execution paths; gate examples and tests so they cannot place real trades accidentally.
|
||||
|
||||
@@ -85,6 +85,37 @@ Schema contracts live in `mt5cli.schemas` (`DataKind`, `validate_schema`, `norma
|
||||
|
||||
`MT5Client.order_send()` is a live execution primitive: it can place real trades on the connected account. mt5cli does not implement strategy logic, signal generation, backtesting, or optimization — downstream applications must gate live execution explicitly.
|
||||
|
||||
### Trading lifecycle and state helpers
|
||||
|
||||
Trading applications can depend on `mt5cli` imports only; terminal path,
|
||||
credentials, server, and timeout are forwarded to `pdmt5.Mt5Config`, numeric
|
||||
login strings are coerced to integers, and empty login strings are treated as
|
||||
unset.
|
||||
|
||||
```python
|
||||
from mt5cli import (
|
||||
calculate_spread_ratio,
|
||||
create_trading_client,
|
||||
get_account_snapshot,
|
||||
mt5_trading_session,
|
||||
)
|
||||
|
||||
with mt5_trading_session(
|
||||
path=r"C:\Program Files\MetaTrader 5\terminal64.exe",
|
||||
login="12345",
|
||||
password="from-env-or-secret-store",
|
||||
server="Broker-Demo",
|
||||
) as client:
|
||||
account = get_account_snapshot(client)
|
||||
spread = calculate_spread_ratio(client, "EURUSD")
|
||||
|
||||
client = create_trading_client(login=12345, server="Broker-Demo")
|
||||
try:
|
||||
positions = client.positions_get_as_df(symbol="EURUSD")
|
||||
finally:
|
||||
client.shutdown()
|
||||
```
|
||||
|
||||
## CLI usage
|
||||
|
||||
```bash
|
||||
|
||||
+29
-3
@@ -167,19 +167,45 @@ Resolution rules:
|
||||
|
||||
### 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:
|
||||
The canonical normalized rate table is `rates`; compatibility views are named
|
||||
with `rate_<symbol>__<timeframe>` for single-timeframe symbols or
|
||||
`rate_<symbol>__<granularity>_<timeframe>` when a symbol has multiple stored
|
||||
timeframes. `resolve_rate_table_name()` returns `rates`, while
|
||||
`resolve_rate_view_name()` returns the per-symbol compatibility view name.
|
||||
|
||||
Use `load_rate_data()` or `load_rate_series_from_sqlite(..., table=...)` to load
|
||||
a single table or view from a SQLite path. Use
|
||||
`load_rate_series_by_granularity()` to load multiple instrument/granularity
|
||||
targets without hard-coding view names:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
from mt5cli import load_rate_data
|
||||
from mt5cli import (
|
||||
load_rate_data,
|
||||
load_rate_series_by_granularity,
|
||||
load_rate_series_from_sqlite,
|
||||
resolve_rate_table_name,
|
||||
)
|
||||
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)
|
||||
same_rates = load_rate_series_from_sqlite(Path("history.db"), table=view, count=1000)
|
||||
|
||||
table = resolve_rate_table_name("EURUSD", "M1") # "rates"
|
||||
series = load_rate_series_by_granularity(
|
||||
Path("history.db"),
|
||||
symbols=["EURUSD", "GBPUSD"],
|
||||
granularities=["M1", "H1"],
|
||||
count=500,
|
||||
)
|
||||
```
|
||||
|
||||
`count` returns the latest rows while preserving chronological order. Missing
|
||||
tables/views and mismatched `explicit_tables` lengths raise `ValueError` with
|
||||
the requested database target in the message.
|
||||
|
||||
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`.
|
||||
|
||||
+20
-5
@@ -31,13 +31,25 @@ rates = collect_latest_rates_for_accounts_with_retries(
|
||||
### 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.
|
||||
row. `fetch_latest_closed_rates()` handles one connected client; multi-account
|
||||
helpers fetch `count + 1` bars, drop that row with `drop_forming_rate_bar()`,
|
||||
and validate each series is non-empty. Returned frames are ordered
|
||||
oldest-to-newest and may contain fewer than `count` rows only when MT5 returns
|
||||
fewer closed bars.
|
||||
|
||||
```python
|
||||
from mt5cli import AccountSpec, collect_latest_closed_rates_by_granularity
|
||||
from mt5cli import (
|
||||
AccountSpec,
|
||||
collect_latest_closed_rates_by_granularity,
|
||||
fetch_latest_closed_rates,
|
||||
)
|
||||
|
||||
closed = fetch_latest_closed_rates(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=500,
|
||||
)
|
||||
|
||||
rates = collect_latest_closed_rates_by_granularity(
|
||||
[AccountSpec(symbols=["EURUSD"], login=12345)],
|
||||
@@ -48,6 +60,9 @@ rates = collect_latest_closed_rates_by_granularity(
|
||||
closed_m1 = rates["EURUSD", "M1"]
|
||||
```
|
||||
|
||||
Use `collect_latest_closed_rates_by_granularity()` when callers prefer keys such
|
||||
as `("EURUSD", "M1")` instead of integer timeframes.
|
||||
|
||||
### Resolving credentials and `${ENV_VAR}` placeholders
|
||||
|
||||
`resolve_account_spec()` / `resolve_account_specs()` merge explicit override
|
||||
|
||||
+56
-12
@@ -4,38 +4,60 @@
|
||||
|
||||
## Trading-capable MT5 sessions
|
||||
|
||||
`mt5_trading_session()` complements the read-only `mt5_session()` helper in
|
||||
`sdk.py`. It yields a connected `pdmt5.Mt5TradingClient`, uses
|
||||
`Mt5Config.path` to launch the terminal when configured, and always calls
|
||||
`shutdown()` on exit.
|
||||
`create_trading_client()` and `mt5_trading_session()` complement the read-only
|
||||
`mt5_session()` helper in `sdk.py`. They return or yield an initialized
|
||||
`pdmt5.Mt5TradingClient`, use `Mt5Config.path` to launch the terminal when
|
||||
configured, and `mt5_trading_session()` always calls `shutdown()` on exit.
|
||||
|
||||
```python
|
||||
from pdmt5 import Mt5Config
|
||||
|
||||
from mt5cli import mt5_trading_session
|
||||
from mt5cli import create_trading_client, mt5_trading_session
|
||||
|
||||
with mt5_trading_session(
|
||||
Mt5Config(path=r"C:\Program Files\MetaTrader 5\terminal64.exe", login=12345),
|
||||
path=r"C:\Program Files\MetaTrader 5\terminal64.exe",
|
||||
login="12345",
|
||||
password="secret",
|
||||
server="Broker-Demo",
|
||||
retry_count=2,
|
||||
) as client:
|
||||
positions = client.positions_get_as_df(symbol="EURUSD")
|
||||
|
||||
client = create_trading_client(login=12345, server="Broker-Demo")
|
||||
try:
|
||||
account = client.account_info_as_dict()
|
||||
finally:
|
||||
client.shutdown()
|
||||
```
|
||||
|
||||
`login` accepts `int`, numeric `str`, or an empty string; empty strings are
|
||||
treated as unset. `path`, `password`, `server`, and `timeout` are forwarded to
|
||||
`pdmt5.Mt5Config`, and omitted `timeout` values keep the lower-level default.
|
||||
The read-only `Mt5CliClient` / `mt5_session()` API is unchanged.
|
||||
|
||||
## Operational trading helpers
|
||||
## State and order helpers
|
||||
|
||||
These helpers are strategy-agnostic and do not depend on signal detection,
|
||||
betting logic, or scheduling code in downstream applications.
|
||||
|
||||
```python
|
||||
from mt5cli import (
|
||||
calculate_spread_ratio,
|
||||
calculate_margin_and_volume,
|
||||
close_open_positions,
|
||||
detect_position_side,
|
||||
determine_order_limits,
|
||||
get_account_snapshot,
|
||||
get_positions_frame,
|
||||
get_symbol_snapshot,
|
||||
get_tick_snapshot,
|
||||
place_market_order,
|
||||
)
|
||||
|
||||
account = get_account_snapshot(client)
|
||||
symbol = get_symbol_snapshot(client, "EURUSD")
|
||||
tick = get_tick_snapshot(client, "EURUSD")
|
||||
positions = get_positions_frame(client, "EURUSD")
|
||||
side = detect_position_side(client, "EURUSD")
|
||||
spread_ratio = calculate_spread_ratio(client, "EURUSD")
|
||||
sizing = calculate_margin_and_volume(
|
||||
client,
|
||||
"EURUSD",
|
||||
@@ -49,11 +71,33 @@ limits = determine_order_limits(
|
||||
stop_loss_limit_ratio=0.01,
|
||||
take_profit_limit_ratio=0.02,
|
||||
)
|
||||
preview = place_market_order(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
volume=sizing["buy_volume"],
|
||||
order_side="BUY",
|
||||
sl=limits["stop_loss"],
|
||||
tp=limits["take_profit"],
|
||||
dry_run=True,
|
||||
)
|
||||
closed = close_open_positions(client, symbols="EURUSD", dry_run=True)
|
||||
```
|
||||
|
||||
Protective ratios must satisfy `0 <= ratio < 1`; `0` omits that level.
|
||||
`calculate_margin_and_volume()` clamps negative `margin_free` to `0.0`
|
||||
before sizing.
|
||||
`detect_position_side()` returns `long` for buy-only exposure, `short` for
|
||||
sell-only exposure, and `None` for no positions or mixed long/short exposure.
|
||||
`calculate_spread_ratio()` uses `(ask - bid) / ((ask + bid) / 2)` and raises
|
||||
`Mt5TradingError` when bid or ask is missing or non-positive.
|
||||
|
||||
SL/TP ratios for `determine_order_limits()` must satisfy `0 <= ratio < 1`; `0`
|
||||
omits that level. SL/TP prices are rounded with symbol `digits` metadata when
|
||||
available. `unit_margin_ratio` and `preserved_margin_ratio` for
|
||||
`calculate_margin_and_volume()` accept `0 <= ratio <= 1`; `unit_margin_ratio=0`
|
||||
requests one minimum valid unit when the post-reserve margin can afford it.
|
||||
Negative `margin_free` is clamped to `0.0` before sizing. Execution helpers
|
||||
return normalized dictionaries containing the request, response, status,
|
||||
retcode, and `dry_run` flag; `dry_run=True` never sends an order. Market order
|
||||
helpers mark known non-success MT5 retcodes as `status="failed"` while keeping
|
||||
the normalized response for inspection.
|
||||
|
||||
## Migration from mteor-local helpers
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from importlib.metadata import version
|
||||
|
||||
from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
|
||||
|
||||
from .client import MT5Client, build_config, mt5_session
|
||||
from .converters import (
|
||||
ensure_utc,
|
||||
@@ -32,6 +34,7 @@ from .history import (
|
||||
resolve_history_datasets,
|
||||
resolve_history_tick_flags,
|
||||
resolve_history_timeframes,
|
||||
resolve_rate_table_name,
|
||||
resolve_rate_tables,
|
||||
resolve_rate_view_name,
|
||||
resolve_rate_view_names,
|
||||
@@ -63,6 +66,7 @@ from .sdk import (
|
||||
copy_rates_range,
|
||||
copy_ticks_from,
|
||||
copy_ticks_range,
|
||||
fetch_latest_closed_rates,
|
||||
history_deals,
|
||||
history_orders,
|
||||
last_error,
|
||||
@@ -96,10 +100,26 @@ from .storage import (
|
||||
export_dataframe_to_sqlite,
|
||||
)
|
||||
from .trading import (
|
||||
POSITION_COLUMNS,
|
||||
OrderFillingMode,
|
||||
OrderSide,
|
||||
OrderTimeMode,
|
||||
PositionSide,
|
||||
calculate_margin_and_volume,
|
||||
calculate_new_position_margin_ratio,
|
||||
calculate_spread_ratio,
|
||||
calculate_volume_by_margin,
|
||||
close_open_positions,
|
||||
create_trading_client,
|
||||
detect_position_side,
|
||||
determine_order_limits,
|
||||
get_account_snapshot,
|
||||
get_positions_frame,
|
||||
get_symbol_snapshot,
|
||||
get_tick_snapshot,
|
||||
mt5_trading_session,
|
||||
place_market_order,
|
||||
update_sltp_for_open_positions,
|
||||
)
|
||||
from .utils import (
|
||||
TICK_FLAG_MAP,
|
||||
@@ -114,6 +134,7 @@ __version__ = version(__package__) if __package__ else None
|
||||
__all__ = [
|
||||
"DEDUP_KEYS",
|
||||
"KNOWN_MT5_TIME_COLUMNS",
|
||||
"POSITION_COLUMNS",
|
||||
"REQUIRED_COLUMNS",
|
||||
"TICK_FLAG_MAP",
|
||||
"TIMEFRAME_MAP",
|
||||
@@ -125,9 +146,17 @@ __all__ = [
|
||||
"MT5Client",
|
||||
"Mt5CliClient",
|
||||
"Mt5CliError",
|
||||
"Mt5Config",
|
||||
"Mt5ConnectionError",
|
||||
"Mt5OperationError",
|
||||
"Mt5RuntimeError",
|
||||
"Mt5SchemaError",
|
||||
"Mt5TradingClient",
|
||||
"Mt5TradingError",
|
||||
"OrderFillingMode",
|
||||
"OrderSide",
|
||||
"OrderTimeMode",
|
||||
"PositionSide",
|
||||
"RateTarget",
|
||||
"ThrottledHistoryUpdater",
|
||||
"account_info",
|
||||
@@ -135,7 +164,11 @@ __all__ = [
|
||||
"build_rate_targets",
|
||||
"build_rate_view_name",
|
||||
"calculate_margin_and_volume",
|
||||
"calculate_new_position_margin_ratio",
|
||||
"calculate_spread_ratio",
|
||||
"calculate_volume_by_margin",
|
||||
"call_with_normalized_errors",
|
||||
"close_open_positions",
|
||||
"collect_history",
|
||||
"collect_latest_closed_rates_by_granularity",
|
||||
"collect_latest_closed_rates_for_accounts",
|
||||
@@ -147,6 +180,7 @@ __all__ = [
|
||||
"copy_rates_range",
|
||||
"copy_ticks_from",
|
||||
"copy_ticks_range",
|
||||
"create_trading_client",
|
||||
"detect_format",
|
||||
"detect_position_side",
|
||||
"determine_order_limits",
|
||||
@@ -154,6 +188,11 @@ __all__ = [
|
||||
"ensure_utc",
|
||||
"export_dataframe",
|
||||
"export_dataframe_to_sqlite",
|
||||
"fetch_latest_closed_rates",
|
||||
"get_account_snapshot",
|
||||
"get_positions_frame",
|
||||
"get_symbol_snapshot",
|
||||
"get_tick_snapshot",
|
||||
"granularity_name",
|
||||
"history_deals",
|
||||
"history_orders",
|
||||
@@ -181,6 +220,7 @@ __all__ = [
|
||||
"parse_datetime",
|
||||
"parse_tick_flags",
|
||||
"parse_timeframe",
|
||||
"place_market_order",
|
||||
"positions",
|
||||
"recent_history_deals",
|
||||
"recent_ticks",
|
||||
@@ -190,6 +230,7 @@ __all__ = [
|
||||
"resolve_history_datasets",
|
||||
"resolve_history_tick_flags",
|
||||
"resolve_history_timeframes",
|
||||
"resolve_rate_table_name",
|
||||
"resolve_rate_tables",
|
||||
"resolve_rate_view_name",
|
||||
"resolve_rate_view_names",
|
||||
@@ -201,5 +242,6 @@ __all__ = [
|
||||
"terminal_info",
|
||||
"update_history",
|
||||
"update_history_with_config",
|
||||
"update_sltp_for_open_positions",
|
||||
"validate_schema",
|
||||
]
|
||||
|
||||
+72
-10
@@ -7,7 +7,7 @@ import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Literal, cast
|
||||
from typing import TYPE_CHECKING, Literal, cast, overload
|
||||
|
||||
import pandas as pd
|
||||
from pdmt5 import get_timeframe_name as _get_timeframe_name
|
||||
@@ -142,6 +142,26 @@ def build_rate_view_name(
|
||||
return f"rate_{symbol}__{granularity}_{timeframe}"
|
||||
|
||||
|
||||
def resolve_rate_table_name(symbol: str, granularity: str) -> str:
|
||||
"""Return the canonical normalized SQLite rate table name.
|
||||
|
||||
The normalized history table stores all symbols and timeframes in
|
||||
``rates``; use :func:`resolve_rate_view_name` for per-symbol compatibility
|
||||
view names.
|
||||
|
||||
Returns:
|
||||
Canonical normalized rates table name.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``symbol`` or ``granularity`` is invalid.
|
||||
"""
|
||||
parse_timeframe(granularity)
|
||||
if not symbol.strip():
|
||||
msg = "symbol must not be empty."
|
||||
raise ValueError(msg)
|
||||
return Dataset.rates.table_name
|
||||
|
||||
|
||||
SqliteConnOrPath = sqlite3.Connection | Path | str
|
||||
|
||||
|
||||
@@ -653,34 +673,76 @@ def resolve_rate_tables(
|
||||
conn.close()
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@overload
|
||||
def load_rate_series_from_sqlite(
|
||||
conn_or_path: SqliteConnOrPath,
|
||||
targets: None = None,
|
||||
count: int | None = None,
|
||||
explicit_tables: None = None,
|
||||
*,
|
||||
table: str,
|
||||
) -> pd.DataFrame: ...
|
||||
|
||||
@overload
|
||||
def load_rate_series_from_sqlite(
|
||||
conn_or_path: SqliteConnOrPath,
|
||||
targets: None = None,
|
||||
count: int | None = None,
|
||||
explicit_tables: Sequence[str] | None = None,
|
||||
*,
|
||||
table: None = None,
|
||||
) -> dict[tuple[str | None, int], pd.DataFrame]: ...
|
||||
|
||||
@overload
|
||||
def load_rate_series_from_sqlite(
|
||||
conn_or_path: SqliteConnOrPath,
|
||||
targets: Sequence[RateTarget],
|
||||
count: int,
|
||||
explicit_tables: Sequence[str] | None = None,
|
||||
*,
|
||||
table: None = None,
|
||||
) -> dict[tuple[str | None, int], pd.DataFrame]: ...
|
||||
|
||||
|
||||
def load_rate_series_from_sqlite(
|
||||
conn_or_path: SqliteConnOrPath,
|
||||
targets: Sequence[RateTarget],
|
||||
count: int,
|
||||
targets: Sequence[RateTarget] | None = None,
|
||||
count: int | None = None,
|
||||
explicit_tables: Sequence[str] | None = None,
|
||||
) -> dict[tuple[str | None, int], pd.DataFrame]:
|
||||
"""Load multiple rate series from a SQLite database.
|
||||
*,
|
||||
table: str | None = None,
|
||||
) -> dict[tuple[str | None, int], pd.DataFrame] | pd.DataFrame:
|
||||
"""Load one table/view or multiple rate series from a SQLite database.
|
||||
|
||||
Args:
|
||||
conn_or_path: SQLite database path or open connection.
|
||||
targets: Rate targets to load. Each ``(symbol, timeframe_int)`` pair
|
||||
must be unique.
|
||||
count: Number of most recent rows to load per series.
|
||||
targets: Rate targets to load. Each ``(symbol, timeframe_int)`` pair must
|
||||
be unique. Omit when loading a single explicit ``table``.
|
||||
count: Optional number of most recent rows to load per series.
|
||||
explicit_tables: Optional explicit table or view names matching targets.
|
||||
When omitted, managed ``rate_*`` compatibility views must already
|
||||
exist in the database.
|
||||
table: Optional single table or view name to load directly.
|
||||
|
||||
Returns:
|
||||
Mapping keyed by ``(symbol, timeframe_int)`` to each rate DataFrame.
|
||||
A DataFrame when ``table`` is provided, otherwise a mapping keyed by
|
||||
``(symbol, timeframe_int)`` to each rate DataFrame.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``count`` is not positive, targets are empty, duplicate
|
||||
``(symbol, timeframe_int)`` pairs are present, or table resolution
|
||||
fails.
|
||||
"""
|
||||
if count <= 0:
|
||||
if table is not None:
|
||||
return load_rate_data(conn_or_path, table, count=count)
|
||||
if count is None or count <= 0:
|
||||
msg = "count must be positive."
|
||||
raise ValueError(msg)
|
||||
if targets is None:
|
||||
msg = "targets are required when table is not provided."
|
||||
raise ValueError(msg)
|
||||
target_list = list(targets)
|
||||
if not target_list:
|
||||
msg = "At least one rate target is required."
|
||||
|
||||
+29
-14
@@ -37,6 +37,7 @@ from .utils import (
|
||||
parse_tick_flags,
|
||||
parse_timeframe,
|
||||
)
|
||||
from .utils import coerce_login as _coerce_login
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
@@ -122,6 +123,7 @@ __all__ = [
|
||||
"copy_rates_range",
|
||||
"copy_ticks_from",
|
||||
"copy_ticks_range",
|
||||
"fetch_latest_closed_rates",
|
||||
"history_deals",
|
||||
"history_orders",
|
||||
"last_error",
|
||||
@@ -1289,6 +1291,33 @@ def latest_rates(
|
||||
)
|
||||
|
||||
|
||||
def fetch_latest_closed_rates(
|
||||
client: Mt5CliClient,
|
||||
*,
|
||||
symbol: str,
|
||||
granularity: str,
|
||||
count: int,
|
||||
) -> pd.DataFrame:
|
||||
"""Fetch up to ``count`` most recent closed bars, oldest to newest.
|
||||
|
||||
Returns:
|
||||
Closed rate bars ordered oldest to newest.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``count`` is not positive or no closed bars are returned.
|
||||
"""
|
||||
_require_positive(count, "count")
|
||||
frame = client.latest_rates(symbol, granularity, count + 1, start_pos=0)
|
||||
closed = drop_forming_rate_bar(frame)
|
||||
if closed.empty:
|
||||
msg = (
|
||||
f"Rate data is empty for {symbol!r} at granularity {granularity!r} "
|
||||
f"with count {count}."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
return closed.tail(count).reset_index(drop=True)
|
||||
|
||||
|
||||
def collect_latest_rates(
|
||||
symbols: Sequence[str],
|
||||
timeframes: Sequence[int | str],
|
||||
@@ -1470,20 +1499,6 @@ def resolve_account_specs(
|
||||
]
|
||||
|
||||
|
||||
def _coerce_login(login: int | str | None) -> int | None:
|
||||
"""Coerce a login value to int, treating empty strings as unset.
|
||||
|
||||
Returns:
|
||||
Integer login, or None when unset or an empty string.
|
||||
"""
|
||||
if login is None or isinstance(login, int):
|
||||
return login
|
||||
text = login.strip()
|
||||
if not text:
|
||||
return None
|
||||
return int(text)
|
||||
|
||||
|
||||
def _build_account_config(
|
||||
account: AccountSpec,
|
||||
base_config: Mt5Config | None,
|
||||
|
||||
+688
-37
@@ -3,27 +3,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
from math import floor, isfinite
|
||||
from typing import TYPE_CHECKING, Literal, cast
|
||||
|
||||
from pdmt5 import Mt5Config, Mt5TradingClient
|
||||
import pandas as pd
|
||||
from pdmt5 import Mt5Config, Mt5TradingClient, Mt5TradingError
|
||||
|
||||
from .sdk import build_config
|
||||
from .utils import coerce_login as _coerce_login
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pandas as pd
|
||||
|
||||
PositionSide = Literal["long", "short"]
|
||||
OrderSide = Literal["long", "short"]
|
||||
OrderSide = Literal["BUY", "SELL"]
|
||||
OrderFillingMode = Literal["IOC", "FOK", "RETURN"]
|
||||
OrderTimeMode = Literal["GTC", "DAY", "SPECIFIED", "SPECIFIED_DAY"]
|
||||
_ORDER_FILLING_MODES: frozenset[str] = frozenset({"IOC", "FOK", "RETURN"})
|
||||
_ORDER_TIME_MODES: frozenset[str] = frozenset({
|
||||
"GTC",
|
||||
"DAY",
|
||||
"SPECIFIED",
|
||||
"SPECIFIED_DAY",
|
||||
})
|
||||
_SUCCESS_RETCODE_NAMES: tuple[str, ...] = (
|
||||
"TRADE_RETCODE_DONE",
|
||||
"TRADE_RETCODE_DONE_PARTIAL",
|
||||
"TRADE_RETCODE_PLACED",
|
||||
)
|
||||
_SUCCESS_RETCODE_FALLBACKS: frozenset[int] = frozenset({10008, 10009, 10010})
|
||||
|
||||
_ACCOUNT_SNAPSHOT_FIELDS = (
|
||||
"login",
|
||||
"balance",
|
||||
"equity",
|
||||
"margin",
|
||||
"margin_free",
|
||||
"margin_level",
|
||||
"leverage",
|
||||
"currency",
|
||||
)
|
||||
_SYMBOL_SNAPSHOT_FIELDS = (
|
||||
"symbol",
|
||||
"visible",
|
||||
"trade_mode",
|
||||
"digits",
|
||||
"point",
|
||||
"volume_min",
|
||||
"volume_max",
|
||||
"volume_step",
|
||||
"trade_contract_size",
|
||||
"trade_tick_size",
|
||||
"trade_tick_value",
|
||||
"trade_stops_level",
|
||||
"filling_mode",
|
||||
)
|
||||
_TICK_SNAPSHOT_FIELDS = ("symbol", "time", "bid", "ask", "last", "volume")
|
||||
POSITION_COLUMNS = (
|
||||
"ticket",
|
||||
"time",
|
||||
"symbol",
|
||||
"type",
|
||||
"volume",
|
||||
"price_open",
|
||||
"sl",
|
||||
"tp",
|
||||
"price_current",
|
||||
"profit",
|
||||
"swap",
|
||||
"comment",
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"POSITION_COLUMNS",
|
||||
"OrderFillingMode",
|
||||
"OrderSide",
|
||||
"OrderTimeMode",
|
||||
"PositionSide",
|
||||
"calculate_margin_and_volume",
|
||||
"calculate_new_position_margin_ratio",
|
||||
"calculate_spread_ratio",
|
||||
"calculate_volume_by_margin",
|
||||
"close_open_positions",
|
||||
"create_trading_client",
|
||||
"detect_position_side",
|
||||
"determine_order_limits",
|
||||
"get_account_snapshot",
|
||||
"get_positions_frame",
|
||||
"get_symbol_snapshot",
|
||||
"get_tick_snapshot",
|
||||
"mt5_trading_session",
|
||||
"place_market_order",
|
||||
"update_sltp_for_open_positions",
|
||||
]
|
||||
|
||||
|
||||
@@ -46,18 +117,184 @@ def _sum_position_volume(positions: pd.DataFrame, position_type: object) -> floa
|
||||
return float(matched.to_numpy(dtype=float).sum())
|
||||
|
||||
|
||||
def _resolve_config(
|
||||
*,
|
||||
config: Mt5Config | None,
|
||||
login: int | str | None,
|
||||
password: str | None,
|
||||
server: str | None,
|
||||
path: str | None,
|
||||
timeout: int | None,
|
||||
) -> Mt5Config:
|
||||
if config is not None:
|
||||
return config
|
||||
return build_config(
|
||||
path=path,
|
||||
login=_coerce_login(login),
|
||||
password=password,
|
||||
server=server,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_order_side(side: str) -> OrderSide:
|
||||
normalized = side.upper()
|
||||
if normalized in {"BUY", "LONG"}:
|
||||
return "BUY"
|
||||
if normalized in {"SELL", "SHORT"}:
|
||||
return "SELL"
|
||||
msg = f"Unsupported order side: {side!r}. Expected 'BUY' or 'SELL'."
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def _position_side_from_order_side(side: str) -> PositionSide:
|
||||
normalized = side.lower()
|
||||
if normalized in {"long", "buy"}:
|
||||
return "long"
|
||||
if normalized in {"short", "sell"}:
|
||||
return "short"
|
||||
msg = (
|
||||
f"Unsupported order side: {side!r}. Expected 'long', 'short', 'buy', or 'sell'."
|
||||
)
|
||||
msg = f"Unsupported position side: {side!r}. Expected 'long' or 'short'."
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def _snapshot_from_value(value: object, fields: tuple[str, ...]) -> dict[str, object]:
|
||||
if isinstance(value, pd.DataFrame):
|
||||
row: dict[str, object] = (
|
||||
{} if value.empty else cast("dict[str, object]", value.iloc[0].to_dict())
|
||||
)
|
||||
else:
|
||||
asdict = getattr(value, "_asdict", None)
|
||||
if callable(asdict):
|
||||
row = cast("dict[str, object]", asdict())
|
||||
elif isinstance(value, dict):
|
||||
typed_value = cast("dict[object, object]", value)
|
||||
row = {str(key): item for key, item in typed_value.items()}
|
||||
else:
|
||||
row = {
|
||||
field: getattr(value, field)
|
||||
for field in fields
|
||||
if hasattr(value, field)
|
||||
}
|
||||
if not fields:
|
||||
return row
|
||||
return {field: row.get(field) for field in fields}
|
||||
|
||||
|
||||
def _call_snapshot_method(client: Mt5TradingClient, *names: str) -> object:
|
||||
for name in names:
|
||||
method = getattr(client, name, None)
|
||||
if callable(method):
|
||||
return method()
|
||||
msg = f"MT5 client is missing required method: {' or '.join(names)}"
|
||||
raise AttributeError(msg)
|
||||
|
||||
|
||||
def _resolve_mt5_constant(
|
||||
mt5: object,
|
||||
prefix: str,
|
||||
value: str,
|
||||
allowed: frozenset[str],
|
||||
) -> int:
|
||||
normalized = value.upper()
|
||||
if normalized not in allowed:
|
||||
msg = f"Unsupported {prefix.lower()} mode: {value!r}."
|
||||
raise ValueError(msg)
|
||||
name = f"{prefix}_{normalized}"
|
||||
try:
|
||||
return cast("int", getattr(mt5, name))
|
||||
except AttributeError as exc:
|
||||
msg = f"MT5 module is missing required constant: {name}"
|
||||
raise Mt5TradingError(msg) from exc
|
||||
|
||||
|
||||
def _optional_price(value: object) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, int | float):
|
||||
return None
|
||||
price = float(value)
|
||||
if price <= 0 or not isfinite(price):
|
||||
return None
|
||||
return price
|
||||
|
||||
|
||||
def _success_retcodes(mt5: object) -> frozenset[int]:
|
||||
values = {
|
||||
value
|
||||
for name in _SUCCESS_RETCODE_NAMES
|
||||
if isinstance(value := getattr(mt5, name, None), int)
|
||||
}
|
||||
return frozenset(values) or _SUCCESS_RETCODE_FALLBACKS
|
||||
|
||||
|
||||
def _order_status_from_retcode(mt5: object, retcode: object) -> str:
|
||||
if retcode is None:
|
||||
return "executed"
|
||||
if isinstance(retcode, int) and retcode not in _success_retcodes(mt5):
|
||||
return "failed"
|
||||
return "executed"
|
||||
|
||||
|
||||
def _calculate_min_volume_if_affordable(
|
||||
client: Mt5TradingClient,
|
||||
symbol: str,
|
||||
available_margin: float,
|
||||
order_side: OrderSide,
|
||||
) -> float:
|
||||
if available_margin <= 0:
|
||||
return 0.0
|
||||
symbol_info = get_symbol_snapshot(client, symbol)
|
||||
volume_min = float(symbol_info.get("volume_min") or 0.0)
|
||||
volume_max = float(symbol_info.get("volume_max") or 0.0)
|
||||
volume_step = float(symbol_info.get("volume_step") or volume_min or 0.0)
|
||||
if (
|
||||
volume_min <= 0
|
||||
or volume_step <= 0
|
||||
or (volume_max > 0 and volume_min > volume_max)
|
||||
):
|
||||
msg = f"Invalid volume constraints for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
side = _normalize_order_side(order_side)
|
||||
tick = get_tick_snapshot(client, symbol)
|
||||
price = tick["ask"] if side == "BUY" else tick["bid"]
|
||||
if not isinstance(price, int | float) or price <= 0:
|
||||
msg = f"Tick price is unavailable for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
order_type = (
|
||||
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
|
||||
)
|
||||
min_margin = float(client.order_calc_margin(order_type, symbol, volume_min, price))
|
||||
return round(volume_min, 10) if 0 < min_margin <= available_margin else 0.0
|
||||
|
||||
|
||||
def create_trading_client(
|
||||
*,
|
||||
config: Mt5Config | None = None,
|
||||
login: int | str | None = None,
|
||||
password: str | None = None,
|
||||
server: str | None = None,
|
||||
path: str | None = None,
|
||||
timeout: int | None = None,
|
||||
retry_count: int = 0,
|
||||
) -> Mt5TradingClient:
|
||||
"""Return an initialized and logged-in trading client."""
|
||||
mt5_config = _resolve_config(
|
||||
config=config,
|
||||
login=login,
|
||||
password=password,
|
||||
server=server,
|
||||
path=path,
|
||||
timeout=timeout,
|
||||
)
|
||||
client = Mt5TradingClient(config=mt5_config, retry_count=retry_count)
|
||||
try:
|
||||
client.initialize_and_login_mt5()
|
||||
except Exception:
|
||||
client.shutdown()
|
||||
raise
|
||||
return client
|
||||
|
||||
|
||||
def detect_position_side(
|
||||
client: Mt5TradingClient,
|
||||
symbol: str,
|
||||
@@ -69,11 +306,11 @@ def detect_position_side(
|
||||
symbol: Symbol to inspect.
|
||||
|
||||
Returns:
|
||||
``"long"`` when net buy volume exceeds sell volume, ``"short"`` when
|
||||
net sell volume exceeds buy volume, or ``None`` when no positions exist
|
||||
or buy/sell volumes are exactly balanced.
|
||||
``"long"`` when there are buy positions and no sell positions,
|
||||
``"short"`` when there are sell positions and no buy positions, or
|
||||
``None`` when no positions or mixed exposure exists.
|
||||
"""
|
||||
positions = client.positions_get_as_df(symbol=symbol)
|
||||
positions = get_positions_frame(client, symbol=symbol)
|
||||
if positions.empty:
|
||||
return None
|
||||
|
||||
@@ -81,14 +318,114 @@ def detect_position_side(
|
||||
sell_type = client.mt5.POSITION_TYPE_SELL
|
||||
buy_volume = _sum_position_volume(positions, buy_type)
|
||||
sell_volume = _sum_position_volume(positions, sell_type)
|
||||
net_volume = buy_volume - sell_volume
|
||||
if net_volume > 0:
|
||||
if buy_volume > 0 and sell_volume == 0:
|
||||
return "long"
|
||||
if net_volume < 0:
|
||||
if sell_volume > 0 and buy_volume == 0:
|
||||
return "short"
|
||||
return None
|
||||
|
||||
|
||||
def get_account_snapshot(
|
||||
client: Mt5TradingClient,
|
||||
) -> dict[str, float | int | str | None]:
|
||||
"""Return normalized account state with stable keys."""
|
||||
value = _call_snapshot_method(client, "account_info_as_dict", "account_info")
|
||||
return cast(
|
||||
"dict[str, float | int | str | None]",
|
||||
_snapshot_from_value(value, _ACCOUNT_SNAPSHOT_FIELDS),
|
||||
)
|
||||
|
||||
|
||||
def get_symbol_snapshot(
|
||||
client: Mt5TradingClient,
|
||||
symbol: str,
|
||||
) -> dict[str, float | int | str | bool | None]:
|
||||
"""Return normalized symbol metadata required for trading decisions."""
|
||||
method = getattr(client, "symbol_info_as_dict", None)
|
||||
value = method(symbol=symbol) if callable(method) else client.symbol_info(symbol)
|
||||
snapshot = _snapshot_from_value(value, _SYMBOL_SNAPSHOT_FIELDS)
|
||||
snapshot["symbol"] = snapshot.get("symbol") or symbol
|
||||
return cast("dict[str, float | int | str | bool | None]", snapshot)
|
||||
|
||||
|
||||
def get_tick_snapshot(
|
||||
client: Mt5TradingClient,
|
||||
symbol: str,
|
||||
) -> dict[str, float | int | None]:
|
||||
"""Return normalized latest tick data, including bid, ask, and timestamp."""
|
||||
method = getattr(client, "symbol_info_tick_as_dict", None)
|
||||
value = (
|
||||
method(symbol=symbol) if callable(method) else client.symbol_info_tick(symbol)
|
||||
)
|
||||
snapshot = _snapshot_from_value(value, _TICK_SNAPSHOT_FIELDS)
|
||||
snapshot["symbol"] = snapshot.get("symbol") or symbol
|
||||
return cast("dict[str, float | int | None]", snapshot)
|
||||
|
||||
|
||||
def get_positions_frame(
|
||||
client: Mt5TradingClient,
|
||||
symbol: str | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Return open positions as a DataFrame with stable baseline columns."""
|
||||
frame = client.positions_get_as_df(symbol=symbol)
|
||||
for column in POSITION_COLUMNS:
|
||||
if column not in frame.columns:
|
||||
frame[column] = pd.Series(dtype="object")
|
||||
return frame
|
||||
|
||||
|
||||
def calculate_spread_ratio(client: Mt5TradingClient, symbol: str) -> float:
|
||||
"""Return ``(ask - bid) / ((ask + bid) / 2)`` for the latest tick.
|
||||
|
||||
Raises:
|
||||
Mt5TradingError: If bid or ask is unavailable or non-positive.
|
||||
"""
|
||||
tick = get_tick_snapshot(client, symbol)
|
||||
bid = tick.get("bid")
|
||||
ask = tick.get("ask")
|
||||
if not isinstance(bid, int | float) or not isinstance(ask, int | float):
|
||||
msg = f"Tick bid/ask is unavailable for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
if bid <= 0 or ask <= 0:
|
||||
msg = f"Tick bid/ask must be positive for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
return (float(ask) - float(bid)) / ((float(ask) + float(bid)) / 2.0)
|
||||
|
||||
|
||||
def calculate_new_position_margin_ratio(
|
||||
client: Mt5TradingClient,
|
||||
*,
|
||||
symbol: str,
|
||||
new_position_side: OrderSide | None = None,
|
||||
new_position_volume: float = 0.0,
|
||||
) -> float:
|
||||
"""Return total margin/equity ratio after an optional hypothetical position.
|
||||
|
||||
Raises:
|
||||
Mt5TradingError: If equity or required tick data is invalid.
|
||||
"""
|
||||
account = get_account_snapshot(client)
|
||||
equity = float(account.get("equity") or 0.0)
|
||||
if equity <= 0:
|
||||
msg = "Account equity must be positive to calculate margin ratio."
|
||||
raise Mt5TradingError(msg)
|
||||
margin = float(account.get("margin") or 0.0)
|
||||
if new_position_side is not None and new_position_volume > 0:
|
||||
side = _normalize_order_side(new_position_side)
|
||||
tick = get_tick_snapshot(client, symbol)
|
||||
price = tick["ask"] if side == "BUY" else tick["bid"]
|
||||
if not isinstance(price, int | float) or price <= 0:
|
||||
msg = f"Tick price is unavailable for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
order_type = (
|
||||
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
|
||||
)
|
||||
margin += float(
|
||||
client.order_calc_margin(order_type, symbol, new_position_volume, price),
|
||||
)
|
||||
return margin / equity
|
||||
|
||||
|
||||
def calculate_margin_and_volume(
|
||||
client: Mt5TradingClient,
|
||||
symbol: str,
|
||||
@@ -99,7 +436,9 @@ def calculate_margin_and_volume(
|
||||
|
||||
Applies ``preserved_margin_ratio`` to keep a reserve off ``margin_free``,
|
||||
then allocates ``unit_margin_ratio`` of the remainder as the margin budget
|
||||
for volume sizing on both buy and sell sides.
|
||||
for proportional volume sizing on both buy and sell sides. A
|
||||
``unit_margin_ratio`` of ``0`` requests exactly one minimum valid unit per
|
||||
side when the post-reserve margin can afford it.
|
||||
|
||||
Args:
|
||||
client: Connected ``Mt5TradingClient`` instance.
|
||||
@@ -119,23 +458,109 @@ def calculate_margin_and_volume(
|
||||
margin_free = max(0.0, float(account.get("margin_free") or 0.0))
|
||||
available_margin = margin_free * (1.0 - preserved_margin_ratio)
|
||||
trade_margin = available_margin * unit_margin_ratio
|
||||
buy_volume = client.calculate_volume_by_margin(symbol, trade_margin, "BUY")
|
||||
sell_volume = client.calculate_volume_by_margin(symbol, trade_margin, "SELL")
|
||||
if unit_margin_ratio == 0:
|
||||
buy_volume = _calculate_min_volume_if_affordable(
|
||||
client,
|
||||
symbol,
|
||||
available_margin,
|
||||
"BUY",
|
||||
)
|
||||
sell_volume = _calculate_min_volume_if_affordable(
|
||||
client,
|
||||
symbol,
|
||||
available_margin,
|
||||
"SELL",
|
||||
)
|
||||
else:
|
||||
native_calculate_volume = getattr(client, "calculate_volume_by_margin", None)
|
||||
if callable(native_calculate_volume):
|
||||
buy_volume = float(
|
||||
cast(
|
||||
"float | int | str",
|
||||
native_calculate_volume(symbol, trade_margin, "BUY"),
|
||||
),
|
||||
)
|
||||
sell_volume = float(
|
||||
cast(
|
||||
"float | int | str",
|
||||
native_calculate_volume(symbol, trade_margin, "SELL"),
|
||||
),
|
||||
)
|
||||
else:
|
||||
buy_volume = calculate_volume_by_margin(client, symbol, trade_margin, "BUY")
|
||||
sell_volume = calculate_volume_by_margin(
|
||||
client,
|
||||
symbol,
|
||||
trade_margin,
|
||||
"SELL",
|
||||
)
|
||||
try:
|
||||
symbol_info = get_symbol_snapshot(client, symbol)
|
||||
volume_min = float(symbol_info.get("volume_min") or 0.0)
|
||||
volume_max = float(symbol_info.get("volume_max") or 0.0)
|
||||
volume_step = float(symbol_info.get("volume_step") or 0.0)
|
||||
except AttributeError:
|
||||
volume_min = volume_max = volume_step = 0.0
|
||||
return {
|
||||
"margin_free": margin_free,
|
||||
"available_margin": available_margin,
|
||||
"trade_margin": trade_margin,
|
||||
"buy_volume": buy_volume,
|
||||
"sell_volume": sell_volume,
|
||||
"buy_volume": float(buy_volume),
|
||||
"sell_volume": float(sell_volume),
|
||||
"volume_min": volume_min,
|
||||
"volume_max": volume_max,
|
||||
"volume_step": volume_step,
|
||||
}
|
||||
|
||||
|
||||
def calculate_volume_by_margin(
|
||||
client: Mt5TradingClient,
|
||||
symbol: str,
|
||||
available_margin: float,
|
||||
order_side: OrderSide,
|
||||
) -> float:
|
||||
"""Calculate max normalized volume affordable for one side.
|
||||
|
||||
Returns:
|
||||
Affordable volume rounded down to symbol volume constraints.
|
||||
|
||||
Raises:
|
||||
Mt5TradingError: If symbol volume constraints or tick data are invalid.
|
||||
"""
|
||||
if available_margin <= 0:
|
||||
return 0.0
|
||||
symbol_info = get_symbol_snapshot(client, symbol)
|
||||
volume_min = float(symbol_info.get("volume_min") or 0.0)
|
||||
volume_max = float(symbol_info.get("volume_max") or 0.0)
|
||||
volume_step = float(symbol_info.get("volume_step") or volume_min or 0.0)
|
||||
if volume_min <= 0 or volume_step <= 0:
|
||||
msg = f"Invalid volume constraints for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
side = _normalize_order_side(order_side)
|
||||
tick = get_tick_snapshot(client, symbol)
|
||||
price = tick["ask"] if side == "BUY" else tick["bid"]
|
||||
if not isinstance(price, int | float) or price <= 0:
|
||||
msg = f"Tick price is unavailable for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
order_type = (
|
||||
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
|
||||
)
|
||||
min_margin = float(client.order_calc_margin(order_type, symbol, volume_min, price))
|
||||
if min_margin <= 0 or min_margin > available_margin:
|
||||
return 0.0
|
||||
raw_volume = available_margin / min_margin * volume_min
|
||||
capped = min(raw_volume, volume_max) if volume_max > 0 else raw_volume
|
||||
steps = floor(((capped - volume_min) / volume_step) + 1e-12)
|
||||
normalized = volume_min + max(0, steps) * volume_step
|
||||
return round(normalized, 10) if normalized >= volume_min else 0.0
|
||||
|
||||
|
||||
def determine_order_limits(
|
||||
client: Mt5TradingClient,
|
||||
symbol: str,
|
||||
side: OrderSide | str,
|
||||
stop_loss_limit_ratio: float,
|
||||
take_profit_limit_ratio: float,
|
||||
side: PositionSide | str,
|
||||
stop_loss_limit_ratio: float | None = None,
|
||||
take_profit_limit_ratio: float | None = None,
|
||||
) -> dict[str, float | None]:
|
||||
"""Derive entry and protective order prices from current market quotes.
|
||||
|
||||
@@ -152,26 +577,41 @@ def determine_order_limits(
|
||||
Returns:
|
||||
Dictionary with ``entry``, ``stop_loss``, and ``take_profit`` keys.
|
||||
Omitted protective levels are returned as ``None``.
|
||||
|
||||
Raises:
|
||||
Mt5TradingError: If required tick data is invalid.
|
||||
"""
|
||||
_require_protective_ratio(stop_loss_limit_ratio, "stop_loss_limit_ratio")
|
||||
_require_protective_ratio(take_profit_limit_ratio, "take_profit_limit_ratio")
|
||||
normalized_side = _normalize_order_side(side)
|
||||
tick = client.symbol_info_tick_as_dict(symbol=symbol)
|
||||
entry = float(tick["ask"] if normalized_side == "long" else tick["bid"])
|
||||
stop_loss_ratio = stop_loss_limit_ratio or 0.0
|
||||
take_profit_ratio = take_profit_limit_ratio or 0.0
|
||||
_require_protective_ratio(stop_loss_ratio, "stop_loss_limit_ratio")
|
||||
_require_protective_ratio(take_profit_ratio, "take_profit_limit_ratio")
|
||||
normalized_side = _position_side_from_order_side(side)
|
||||
tick = get_tick_snapshot(client, symbol)
|
||||
entry_value = tick["ask"] if normalized_side == "long" else tick["bid"]
|
||||
if not isinstance(entry_value, int | float):
|
||||
msg = f"Tick price is unavailable for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
entry = float(entry_value)
|
||||
try:
|
||||
digits = int(get_symbol_snapshot(client, symbol).get("digits") or 8)
|
||||
except AttributeError:
|
||||
digits = 8
|
||||
|
||||
stop_loss: float | None = None
|
||||
if stop_loss_limit_ratio > 0:
|
||||
if stop_loss_ratio > 0:
|
||||
if normalized_side == "long":
|
||||
stop_loss = entry * (1.0 - stop_loss_limit_ratio)
|
||||
stop_loss = entry * (1.0 - stop_loss_ratio)
|
||||
else:
|
||||
stop_loss = entry * (1.0 + stop_loss_limit_ratio)
|
||||
stop_loss = entry * (1.0 + stop_loss_ratio)
|
||||
stop_loss = round(stop_loss, digits)
|
||||
|
||||
take_profit: float | None = None
|
||||
if take_profit_limit_ratio > 0:
|
||||
if take_profit_ratio > 0:
|
||||
if normalized_side == "long":
|
||||
take_profit = entry * (1.0 + take_profit_limit_ratio)
|
||||
take_profit = entry * (1.0 + take_profit_ratio)
|
||||
else:
|
||||
take_profit = entry * (1.0 - take_profit_limit_ratio)
|
||||
take_profit = entry * (1.0 - take_profit_ratio)
|
||||
take_profit = round(take_profit, digits)
|
||||
|
||||
return {
|
||||
"entry": entry,
|
||||
@@ -180,9 +620,209 @@ def determine_order_limits(
|
||||
}
|
||||
|
||||
|
||||
def place_market_order(
|
||||
client: Mt5TradingClient,
|
||||
*,
|
||||
symbol: str,
|
||||
volume: float,
|
||||
order_side: OrderSide,
|
||||
order_filling_mode: OrderFillingMode = "IOC",
|
||||
order_time_mode: OrderTimeMode = "GTC",
|
||||
sl: float | None = None,
|
||||
tp: float | None = None,
|
||||
position: int | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, object]:
|
||||
"""Place one normalized market order or return a dry-run result.
|
||||
|
||||
``pdmt5.Mt5TradingClient.order_send()`` raises only when MT5 returns no
|
||||
response. When MT5 returns a response with a known non-success retcode, this
|
||||
helper returns ``status="failed"`` and keeps the normalized response
|
||||
details for callers to inspect.
|
||||
|
||||
Returns:
|
||||
Normalized execution result containing request and response details.
|
||||
|
||||
Raises:
|
||||
Mt5TradingError: If volume or required tick data is invalid.
|
||||
"""
|
||||
if volume <= 0:
|
||||
msg = "volume must be positive."
|
||||
raise Mt5TradingError(msg)
|
||||
side = _normalize_order_side(order_side)
|
||||
tick = get_tick_snapshot(client, symbol)
|
||||
price = tick["ask"] if side == "BUY" else tick["bid"]
|
||||
if not isinstance(price, int | float) or price <= 0:
|
||||
msg = f"Tick price is unavailable for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
request = {
|
||||
"action": client.mt5.TRADE_ACTION_DEAL,
|
||||
"symbol": symbol,
|
||||
"volume": volume,
|
||||
"type": (
|
||||
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
|
||||
),
|
||||
"price": float(price),
|
||||
"type_filling": _resolve_mt5_constant(
|
||||
client.mt5,
|
||||
"ORDER_FILLING",
|
||||
order_filling_mode,
|
||||
_ORDER_FILLING_MODES,
|
||||
),
|
||||
"type_time": _resolve_mt5_constant(
|
||||
client.mt5,
|
||||
"ORDER_TIME",
|
||||
order_time_mode,
|
||||
_ORDER_TIME_MODES,
|
||||
),
|
||||
}
|
||||
if sl is not None:
|
||||
request["sl"] = sl
|
||||
if tp is not None:
|
||||
request["tp"] = tp
|
||||
if position is not None:
|
||||
request["position"] = position
|
||||
if dry_run:
|
||||
return {
|
||||
"status": "dry_run",
|
||||
"symbol": symbol,
|
||||
"order_side": side,
|
||||
"volume": volume,
|
||||
"retcode": None,
|
||||
"comment": None,
|
||||
"request": request,
|
||||
"response": None,
|
||||
"dry_run": True,
|
||||
}
|
||||
response = client.order_send(request)
|
||||
response_dict = _snapshot_from_value(response, ())
|
||||
retcode = response_dict.get("retcode")
|
||||
return {
|
||||
"status": _order_status_from_retcode(client.mt5, retcode),
|
||||
"symbol": symbol,
|
||||
"order_side": side,
|
||||
"volume": volume,
|
||||
"retcode": retcode,
|
||||
"comment": response_dict.get("comment"),
|
||||
"request": request,
|
||||
"response": response_dict,
|
||||
"dry_run": False,
|
||||
}
|
||||
|
||||
|
||||
def _filter_positions(
|
||||
positions: pd.DataFrame,
|
||||
*,
|
||||
symbols: str | list[str] | None = None,
|
||||
tickets: list[int] | None = None,
|
||||
) -> pd.DataFrame:
|
||||
frame = positions
|
||||
if symbols is not None:
|
||||
symbol_set = {symbols} if isinstance(symbols, str) else set(symbols)
|
||||
frame = frame.loc[frame["symbol"].isin(symbol_set)]
|
||||
if tickets is not None:
|
||||
frame = frame.loc[frame["ticket"].isin(tickets)]
|
||||
return frame
|
||||
|
||||
|
||||
def close_open_positions(
|
||||
client: Mt5TradingClient,
|
||||
*,
|
||||
symbols: str | list[str] | None = None,
|
||||
tickets: list[int] | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> list[dict[str, object]]:
|
||||
"""Close matching open positions.
|
||||
|
||||
Returns:
|
||||
Normalized execution results for matching positions.
|
||||
"""
|
||||
positions = _filter_positions(
|
||||
get_positions_frame(client),
|
||||
symbols=symbols,
|
||||
tickets=tickets,
|
||||
)
|
||||
results: list[dict[str, object]] = []
|
||||
for row in positions.to_dict("records"):
|
||||
pos_type = row["type"]
|
||||
side: OrderSide = "SELL" if pos_type == client.mt5.POSITION_TYPE_BUY else "BUY"
|
||||
result = place_market_order(
|
||||
client,
|
||||
symbol=str(row["symbol"]),
|
||||
volume=float(row["volume"]),
|
||||
order_side=side,
|
||||
position=int(row["ticket"]),
|
||||
dry_run=dry_run,
|
||||
)
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
|
||||
def update_sltp_for_open_positions(
|
||||
client: Mt5TradingClient,
|
||||
*,
|
||||
symbol: str | None = None,
|
||||
tickets: list[int] | None = None,
|
||||
stop_loss: float | None = None,
|
||||
take_profit: float | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> list[dict[str, object]]:
|
||||
"""Update SL/TP for matching open positions.
|
||||
|
||||
Returns:
|
||||
Normalized execution results for matching positions.
|
||||
"""
|
||||
positions = _filter_positions(
|
||||
get_positions_frame(client),
|
||||
symbols=symbol,
|
||||
tickets=tickets,
|
||||
)
|
||||
results: list[dict[str, object]] = []
|
||||
for row in positions.to_dict("records"):
|
||||
request = {
|
||||
"action": client.mt5.TRADE_ACTION_SLTP,
|
||||
"symbol": row["symbol"],
|
||||
"position": row["ticket"],
|
||||
}
|
||||
sl = _optional_price(row.get("sl") if stop_loss is None else stop_loss)
|
||||
tp = _optional_price(row.get("tp") if take_profit is None else take_profit)
|
||||
if sl is not None:
|
||||
request["sl"] = sl
|
||||
if tp is not None:
|
||||
request["tp"] = tp
|
||||
if dry_run:
|
||||
response = None
|
||||
status = "dry_run"
|
||||
else:
|
||||
response = _snapshot_from_value(client.order_send(request), ())
|
||||
status = "executed"
|
||||
results.append(
|
||||
{
|
||||
"status": status,
|
||||
"symbol": row["symbol"],
|
||||
"order_side": "BUY"
|
||||
if row["type"] == client.mt5.POSITION_TYPE_BUY
|
||||
else "SELL",
|
||||
"volume": row["volume"],
|
||||
"retcode": None if response is None else response.get("retcode"),
|
||||
"comment": None if response is None else response.get("comment"),
|
||||
"request": request,
|
||||
"response": response,
|
||||
"dry_run": dry_run,
|
||||
},
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@contextmanager
|
||||
def mt5_trading_session(
|
||||
config: Mt5Config | None = None,
|
||||
*,
|
||||
login: int | str | None = None,
|
||||
password: str | None = None,
|
||||
server: str | None = None,
|
||||
path: str | None = None,
|
||||
timeout: int | None = None,
|
||||
retry_count: int = 0,
|
||||
) -> Iterator[Mt5TradingClient]:
|
||||
"""Open a trading-capable MT5 session and always shut down safely.
|
||||
@@ -195,16 +835,27 @@ def mt5_trading_session(
|
||||
Args:
|
||||
config: MT5 connection configuration. Defaults to an empty config that
|
||||
attaches to a running terminal.
|
||||
login: Optional trading account login.
|
||||
password: Optional trading account password.
|
||||
server: Optional trading server name.
|
||||
path: Optional terminal executable path.
|
||||
timeout: Optional connection timeout in milliseconds.
|
||||
retry_count: Number of initialization retries passed to
|
||||
``Mt5TradingClient``.
|
||||
|
||||
Yields:
|
||||
Connected ``Mt5TradingClient`` bound to the session.
|
||||
"""
|
||||
mt5_config = config or build_config()
|
||||
client = Mt5TradingClient(config=mt5_config, retry_count=retry_count)
|
||||
client = create_trading_client(
|
||||
config=config,
|
||||
login=login,
|
||||
password=password,
|
||||
server=server,
|
||||
path=path,
|
||||
timeout=timeout,
|
||||
retry_count=retry_count,
|
||||
)
|
||||
try:
|
||||
client.initialize_and_login_mt5()
|
||||
yield client
|
||||
finally:
|
||||
client.shutdown()
|
||||
|
||||
@@ -241,6 +241,20 @@ def detect_format(
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def coerce_login(login: int | str | None) -> int | None:
|
||||
"""Coerce a login value to int, treating empty strings as unset.
|
||||
|
||||
Returns:
|
||||
Integer login, or None when unset or an empty string.
|
||||
"""
|
||||
if login is None or isinstance(login, int):
|
||||
return login
|
||||
text = login.strip()
|
||||
if not text:
|
||||
return None
|
||||
return int(text)
|
||||
|
||||
|
||||
def export_dataframe_to_sqlite(
|
||||
df: pd.DataFrame,
|
||||
output_path: Path,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mt5cli"
|
||||
version = "0.7.2"
|
||||
version = "0.8.0"
|
||||
description = "Generic MT5 data and execution infrastructure for Python applications"
|
||||
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||
|
||||
@@ -48,6 +48,7 @@ from mt5cli.history import (
|
||||
resolve_history_datasets,
|
||||
resolve_history_tick_flags,
|
||||
resolve_history_timeframes,
|
||||
resolve_rate_table_name,
|
||||
resolve_rate_tables,
|
||||
resolve_rate_view_name,
|
||||
resolve_rate_view_names,
|
||||
@@ -63,6 +64,15 @@ from mt5cli.utils import TIMEFRAME_MAP, Dataset, IfExists
|
||||
class TestResolveRateViewName:
|
||||
"""Tests for resolve_rate_view_name and resolve_rate_view_names."""
|
||||
|
||||
def test_resolve_rate_table_name_returns_normalized_table(self) -> None:
|
||||
"""Test canonical normalized rates table name is stable."""
|
||||
assert resolve_rate_table_name("EURUSD", "M1") == "rates"
|
||||
|
||||
def test_resolve_rate_table_name_rejects_empty_symbol(self) -> None:
|
||||
"""Test canonical rate table resolution validates symbols."""
|
||||
with pytest.raises(ValueError, match="symbol must not be empty"):
|
||||
resolve_rate_table_name(" ", "M1")
|
||||
|
||||
def test_missing_database_path_does_not_create_file(self, tmp_path: Path) -> None:
|
||||
"""Test resolving against a missing path does not create a database."""
|
||||
db_path = tmp_path / "missing.db"
|
||||
@@ -416,6 +426,32 @@ class TestLoadRateData:
|
||||
frame = load_rate_data_from_connection(conn, "rate_view")
|
||||
assert list(frame["close"]) == [1.0]
|
||||
|
||||
def test_load_rate_series_from_sqlite_table_style(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test public table-style loader returns one rate DataFrame."""
|
||||
db_path = tmp_path / "table-style.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("CREATE TABLE rates(time TEXT, close REAL)")
|
||||
conn.executemany(
|
||||
"INSERT INTO rates(time, close) VALUES (?, ?)",
|
||||
[
|
||||
("2024-01-01T00:00:00+00:00", 1.0),
|
||||
("2024-01-01T00:01:00+00:00", 1.1),
|
||||
],
|
||||
)
|
||||
|
||||
frame = load_rate_series_from_sqlite(db_path, table="rates", count=1)
|
||||
|
||||
assert isinstance(frame, pd.DataFrame)
|
||||
assert list(frame["close"]) == [1.1]
|
||||
|
||||
def test_load_rate_series_from_sqlite_requires_targets_without_table(self) -> None:
|
||||
"""Test multi-series loading requires targets when table is omitted."""
|
||||
with pytest.raises(ValueError, match="targets are required"):
|
||||
load_rate_series_from_sqlite("unused.db", count=1)
|
||||
|
||||
def test_loads_quoted_identifier(self, tmp_path: Path) -> None:
|
||||
"""Test table names are quoted safely."""
|
||||
db_path = tmp_path / "quoted.db"
|
||||
|
||||
+60
-3
@@ -37,6 +37,7 @@ from mt5cli.sdk import (
|
||||
copy_rates_range,
|
||||
copy_ticks_from,
|
||||
copy_ticks_range,
|
||||
fetch_latest_closed_rates,
|
||||
history_deals,
|
||||
history_orders,
|
||||
last_error,
|
||||
@@ -61,7 +62,7 @@ from mt5cli.sdk import (
|
||||
update_history_with_config,
|
||||
version,
|
||||
)
|
||||
from mt5cli.utils import Dataset, IfExists
|
||||
from mt5cli.utils import Dataset, IfExists, coerce_login
|
||||
|
||||
|
||||
class _TerminalInfo(NamedTuple):
|
||||
@@ -1301,12 +1302,12 @@ class TestAccountSpec:
|
||||
expected: int | None,
|
||||
) -> None:
|
||||
"""Test login values are normalized for account configs."""
|
||||
assert sdk._coerce_login(login) == expected # type: ignore[reportPrivateUsage]
|
||||
assert coerce_login(login) == expected
|
||||
|
||||
def test_coerce_login_rejects_non_numeric_string(self) -> None:
|
||||
"""Test non-numeric login strings raise ValueError."""
|
||||
with pytest.raises(ValueError, match="invalid literal"):
|
||||
sdk._coerce_login("abc") # type: ignore[reportPrivateUsage]
|
||||
coerce_login("abc")
|
||||
|
||||
|
||||
class TestCollectLatestRatesForAccounts:
|
||||
@@ -1662,6 +1663,62 @@ class TestCollectLatestClosedRatesForAccounts:
|
||||
)
|
||||
|
||||
|
||||
class TestFetchLatestClosedRates:
|
||||
"""Tests for fetch_latest_closed_rates."""
|
||||
|
||||
def test_fetches_extra_bar_and_drops_forming_row(self) -> None:
|
||||
"""Test single-symbol closed-bar helper hides the forming bar."""
|
||||
client = MagicMock()
|
||||
client.latest_rates.return_value = pd.DataFrame(
|
||||
{
|
||||
"time": [1, 2, 3],
|
||||
"close": [1.0, 1.1, 1.2],
|
||||
},
|
||||
)
|
||||
|
||||
result = fetch_latest_closed_rates(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=2,
|
||||
)
|
||||
|
||||
client.latest_rates.assert_called_once_with(
|
||||
"EURUSD",
|
||||
"M1",
|
||||
3,
|
||||
start_pos=0,
|
||||
)
|
||||
assert list(result["close"]) == [1.0, 1.1]
|
||||
|
||||
def test_raises_when_no_closed_bars_are_available(self) -> None:
|
||||
"""Test empty closed-bar results raise an actionable ValueError."""
|
||||
client = MagicMock()
|
||||
client.latest_rates.return_value = pd.DataFrame({"close": [1.0]})
|
||||
|
||||
with pytest.raises(ValueError, match="Rate data is empty"):
|
||||
fetch_latest_closed_rates(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=1,
|
||||
)
|
||||
|
||||
def test_rejects_non_positive_count_before_fetching(self) -> None:
|
||||
"""Test invalid count values fail before calling MT5."""
|
||||
client = MagicMock()
|
||||
|
||||
with pytest.raises(ValueError, match="count must be positive"):
|
||||
fetch_latest_closed_rates(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=0,
|
||||
)
|
||||
|
||||
client.latest_rates.assert_not_called()
|
||||
|
||||
|
||||
class TestCollectLatestClosedRatesByGranularity:
|
||||
"""Tests for collect_latest_closed_rates_by_granularity."""
|
||||
|
||||
|
||||
+923
-9
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user