Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37eef16e99 | |||
| 96c75f7852 | |||
| 292fac899a | |||
| 9ac3b885c3 | |||
| 823cb5b0a4 | |||
| 1c57be5c44 | |||
| f1ada55bce | |||
| d292fbb9d9 | |||
| 8e53212a24 | |||
| b878a61c07 | |||
| 0610ea732c | |||
| 82a39731ed | |||
| c4a4253fbc | |||
| 9f2968cc98 | |||
| 7de3ce0b7a |
@@ -27,7 +27,7 @@ If no PR or review comments are identifiable, ask for the target PR or the copie
|
||||
## 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_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. Do not resolve threads whose resolution depends on unpushed local edits.
|
||||
- `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.
|
||||
@@ -46,7 +46,7 @@ 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.
|
||||
- Preserve every thread/comment identifier needed to reply or resolve later.
|
||||
- Compare each comment with the current diff and file contents because review lines can become outdated.
|
||||
|
||||
## Deduplication and Ordering
|
||||
@@ -60,7 +60,7 @@ Build one triage record per distinct finding:
|
||||
- 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.
|
||||
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, resolution decision, platform action attempted, and final platform state.
|
||||
|
||||
## Resolution Policy
|
||||
|
||||
@@ -70,6 +70,52 @@ Keep a thread open only when it still needs reviewer, maintainer, or product inp
|
||||
|
||||
When resolving a thread, add a concise reply first only if it provides useful context, such as what changed, why no code change was needed, why a finding was intentionally deferred, or why the original comment is now outdated. Do not add noisy replies for self-evident fixes unless project norms require them.
|
||||
|
||||
## Platform Action Contract
|
||||
|
||||
Do not treat triage as complete until every collected source ID reaches an explicit terminal state:
|
||||
|
||||
- `resolved`: a platform resolve action succeeded, or a re-check shows the thread is already resolved.
|
||||
- `replied_left_open`: a reply or question was posted and the thread is intentionally left unresolved.
|
||||
- `not_resolvable`: the source is a PR-level summary comment or copied comment that has no platform-level resolve action; reply or post a PR summary when useful.
|
||||
- `skipped_by_mode`: `dry_run`, `no_push`, or `no_reply` prevented the external action.
|
||||
- `failed_action`: a reply or resolve action was attempted and failed; include the attempted action and failure in the final summary.
|
||||
|
||||
In normal mode, build and execute a platform action queue after fixes are verified and pushed when needed:
|
||||
|
||||
- `reply_then_resolve`: use for handled threads where the reviewer needs context before resolution.
|
||||
- `resolve_only`: use for self-evident fixes and already-addressed or outdated threads where an extra reply would add noise.
|
||||
- `reply_leave_open`: use only for clarification requests, blocked work, or intentionally open follow-ups.
|
||||
- `reply_only`: use for PR-level comments or summaries that cannot be resolved as review threads.
|
||||
|
||||
For duplicate findings, execute the terminal action for every source thread ID, not only the primary triage record. If one finding is represented by three unresolved inline threads, all three must be resolved or explicitly left open.
|
||||
|
||||
## GitHub Action Guidance
|
||||
|
||||
Prefer platform-native APIs or `gh` commands that expose review-thread resolution state. For GitHub inline review threads, use the thread node ID and the GraphQL `resolveReviewThread` mutation rather than assuming that a reply resolves the conversation.
|
||||
|
||||
A reliable pattern is:
|
||||
|
||||
1. Re-fetch review threads and comments immediately before acting.
|
||||
2. Reply to the thread when the action queue says a reply is needed.
|
||||
3. Resolve the review thread by node ID when the terminal state should be `resolved`.
|
||||
4. Re-fetch unresolved review threads after the action queue completes.
|
||||
5. Retry any expected-to-be-resolved thread that is still unresolved once; if it still remains unresolved, mark it `failed_action` instead of claiming completion.
|
||||
|
||||
Example GraphQL mutation shape:
|
||||
|
||||
```graphql
|
||||
mutation ($threadId: ID!) {
|
||||
resolveReviewThread(input: { threadId: $threadId }) {
|
||||
thread {
|
||||
id
|
||||
isResolved
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A posted reply alone is sufficient only for `reply_leave_open`, `reply_only`, or `not_resolvable` sources. For handled inline review threads, reply and resolve are separate actions.
|
||||
|
||||
## Flow
|
||||
|
||||
```mermaid
|
||||
@@ -80,7 +126,7 @@ flowchart TD
|
||||
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 -->|Clarify| H[Prepare question and leave open]
|
||||
E -->|Already addressed or Outdated| I[Prepare evidence]
|
||||
E -->|Defer or Won't fix| J[Document reason]
|
||||
F --> K[Verify]
|
||||
@@ -92,11 +138,13 @@ flowchart TD
|
||||
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]
|
||||
L -->|normal| P[Commit/push if changed]
|
||||
P --> R[Execute reply/resolve action queue]
|
||||
R --> S[Re-fetch threads and retry unresolved handled threads once]
|
||||
M --> Q[Final summary]
|
||||
N --> Q
|
||||
O --> Q
|
||||
P --> Q
|
||||
S --> Q
|
||||
```
|
||||
|
||||
## Compact Workflow
|
||||
@@ -109,7 +157,7 @@ flowchart TD
|
||||
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.
|
||||
- **Clarify**: Ambiguous, conflicting, or missing context; reply with the question and leave unresolved.
|
||||
- **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.
|
||||
@@ -118,17 +166,19 @@ flowchart TD
|
||||
- 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_push`, local edits are allowed, but do not push or resolve threads whose fix is only local. Reply or resolve non-code, already-addressed, or outdated threads only when the action does not depend on unpushed work and `no_reply` is not set.
|
||||
- In `no_reply`, do not post replies or resolve threads; report suggested replies/actions instead.
|
||||
- In normal mode, batch replies where practical, then resolve every handled thread by default after the fix, explanation, or deferral reason is available on the PR.
|
||||
- In normal mode, commit and push changed code when appropriate, then execute the platform action queue for every collected source ID.
|
||||
|
||||
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.
|
||||
- Re-fetch review threads after reply/resolve actions and confirm all expected-to-be-resolved thread IDs are resolved.
|
||||
- Do not mark a thread resolved if it still needs reviewer, maintainer, or product input.
|
||||
- If a resolve or reply operation fails, retry once when safe; then report `failed_action` with the affected source ID and reason.
|
||||
|
||||
5. **Finish**
|
||||
- Normal mode: commit/push changes when appropriate, post useful replies or a summary, and resolve all handled threads by default.
|
||||
- Normal mode: commit/push changes when appropriate, post useful replies or a summary, resolve all handled threads by default, and reconcile the final unresolved set.
|
||||
- Safe modes: report the local state and the exact replies/resolution actions a human could take.
|
||||
|
||||
## Reply Guidance
|
||||
@@ -143,7 +193,9 @@ flowchart TD
|
||||
|
||||
- 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
|
||||
- Counts by platform terminal state: resolved, replied-left-open, not-resolvable, skipped-by-mode, failed-action
|
||||
- Threads resolved, intentionally left open, already resolved, or resolution actions skipped by mode
|
||||
- Any expected-to-be-resolved thread that remained unresolved after retry
|
||||
- Verification run or planned
|
||||
- Commits pushed, local diff/commits, or "none"
|
||||
- Remaining open items and who needs to respond
|
||||
|
||||
@@ -31,7 +31,7 @@ pip install -U mt5cli MetaTrader5
|
||||
|
||||
## Python API (downstream packages)
|
||||
|
||||
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives. `Mt5CliClient` remains available as a backward-compatible alias.
|
||||
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives.
|
||||
|
||||
```python
|
||||
from datetime import UTC, datetime
|
||||
@@ -92,16 +92,21 @@ Schema contracts live in `mt5cli.schemas` (`DataKind`, `validate_schema`, `norma
|
||||
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.
|
||||
unset. Pass `allow_whole_dollar_env=True` to expand `${ENV_VAR}` and bare
|
||||
`$ENV_NAME` placeholders in connection string parameters before coercion.
|
||||
|
||||
```python
|
||||
from mt5cli import (
|
||||
build_config,
|
||||
calculate_spread_ratio,
|
||||
create_trading_client,
|
||||
get_account_snapshot,
|
||||
mt5_trading_session,
|
||||
)
|
||||
|
||||
# Login from environment — numeric string is coerced to int automatically
|
||||
config = build_config(login="$MT5_LOGIN", allow_whole_dollar_env=True)
|
||||
|
||||
with mt5_trading_session(
|
||||
path=r"C:\Program Files\MetaTrader 5\terminal64.exe",
|
||||
login="12345",
|
||||
@@ -248,9 +253,9 @@ rates = collect_latest_closed_rates_by_granularity(
|
||||
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`, `ValueError`, `OSError`, and MT5 client capability errors for history API methods without advancing the throttle (other `AttributeError` / `TypeError` values always propagate).
|
||||
- **Trading session helpers**: use `mt5_trading_session()` for a trading-capable `pdmt5.Mt5TradingClient` that initializes/logs in via `Mt5Config.path` and always shuts down safely. Pair with `detect_position_side()`, `calculate_margin_and_volume()`, and `determine_order_limits()` for generic position and sizing utilities. The read-only `mt5_session()` / `Mt5CliClient` SDK is unchanged.
|
||||
- **Credential resolution**: use `resolve_account_spec()` / `resolve_account_specs()` to merge explicit override values over `AccountSpec` fields and expand `${ENV_VAR}` placeholders (via `substitute_env_placeholders()`), raising `ValueError` for missing variables. This keeps secrets out of plan/config files without coupling to any strategy code. For config dicts or nested structures loaded from YAML/TOML, use `substitute_mapping_values(data, keys={"login", "password"})` to expand placeholders only for caller-specified keys — key names are never hard-coded in mt5cli.
|
||||
- **Throttled history updates**: use `ThrottledHistoryUpdater` to wrap `update_history()` with a minimum `interval_seconds` between successful runs (monotonic clock). Call `should_update()` / `update(client, symbols)` from an application loop; errors propagate by default, or pass `suppress_errors=True` to swallow recoverable `Mt5*Error`, `sqlite3.Error`, `ValueError`, `OSError`, and MT5 client capability errors for history API methods without advancing the throttle (other `AttributeError` / `TypeError` values always propagate). Pass `update_backend` to inject a custom history update callable (same keyword arguments as `update_history`) instead of monkey-patching `mt5cli.sdk.update_history`.
|
||||
- **Trading session helpers**: use `mt5_trading_session()` for a trading-capable `pdmt5.Mt5TradingClient` that initializes/logs in via `Mt5Config.path` and always shuts down safely. Pair with `detect_position_side()`, `calculate_margin_and_volume()`, and `determine_order_limits()` for generic position and sizing utilities. Keep read-only collection on `mt5_session()` / `MT5Client`.
|
||||
- **Granularity-keyed rate loading**: `load_rate_series_by_granularity()` builds targets with `build_rate_targets()`, loads them with `load_rate_series_from_sqlite()`, and returns a mapping keyed by `(symbol | None, granularity_name)` such as `("EURUSD", "M1")` to reduce downstream boilerplate.
|
||||
- **MT5 session helper**: use the `mt5_session()` context manager to attach to (or, when `Mt5Config.path` is set, launch) an MT5 terminal, log in, and yield a connected `MT5Client` that shuts down on exit.
|
||||
- **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns.
|
||||
@@ -317,7 +322,7 @@ finally:
|
||||
client.shutdown()
|
||||
```
|
||||
|
||||
Read-only collectors can keep using `mt5_session()` and `MT5Client` (or the `Mt5CliClient` alias) without changes.
|
||||
Read-only collectors can keep using `mt5_session()` and `MT5Client`.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
+108
-68
@@ -8,47 +8,48 @@ downstream app -> mt5cli -> pdmt5 -> MetaTrader 5
|
||||
```
|
||||
|
||||
Downstream packages should import from the package root (`from mt5cli import
|
||||
...`) and treat the symbols listed below as the stable SDK contract. CLI
|
||||
commands mirror the same behavior but are not importable Python APIs.
|
||||
...`) and use the public tier sets in `mt5cli.contract` to distinguish API
|
||||
stability. CLI commands mirror the same behavior but are not importable Python
|
||||
APIs.
|
||||
|
||||
## Public API tiers
|
||||
|
||||
mt5cli classifies package-root imports by intended downstream use:
|
||||
|
||||
| Tier | Contract set | Meaning |
|
||||
| ---------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Stable core | `STABLE_SDK_EXPORTS` | Preferred SDK surface for downstream MT5 infrastructure adapters. Changes require a deliberate compatibility path. |
|
||||
| Secondary public | `SECONDARY_PUBLIC_EXPORTS` | Public helpers for CLI/export/schema integrations and lower-level MT5 wrappers. Importable, but less central to the downstream trading SDK. |
|
||||
|
||||
## Stable downstream SDK API
|
||||
|
||||
These names are exported from `mt5cli` and covered by the contract in
|
||||
`mt5cli.STABLE_SDK_EXPORTS` (defined in `mt5cli.contract`). Prefer `MT5Client` over the legacy `Mt5CliClient`
|
||||
alias for new code.
|
||||
`mt5cli.STABLE_SDK_EXPORTS` (defined in `mt5cli.contract`).
|
||||
|
||||
### Session lifecycle and configuration
|
||||
|
||||
| Symbol | Role |
|
||||
| ----------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| `MT5Client`, `Mt5CliClient` | Read-only data client with optional `order_check` / `order_send` |
|
||||
| `build_config` | Build `pdmt5.Mt5Config` from connection fields |
|
||||
| `mt5_session` | Context manager: initialize, login, yield client, shutdown |
|
||||
| `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle |
|
||||
| `AccountSpec` | Generic account group: symbols plus optional credentials |
|
||||
| `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders |
|
||||
| `substitute_env_placeholders` | Replace `${NAME}` substrings from the environment |
|
||||
| Symbol | Role |
|
||||
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `MT5Client` | Read-only data client with optional `order_check` / `order_send` |
|
||||
| `build_config` | Build `pdmt5.Mt5Config` from connection fields; `login` accepts `int \| str \| None` — numeric strings are coerced to `int`, blank strings are treated as unset, and `${ENV_VAR}` / `$ENV_NAME` placeholders in string parameters are expanded when `allow_whole_dollar_env=True` |
|
||||
| `mt5_session` | Context manager: initialize, login, yield client, shutdown |
|
||||
| `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle |
|
||||
| `AccountSpec` | Generic account group: symbols plus optional credentials |
|
||||
| `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders; opt-in `allow_whole_dollar_env` for bare `$NAME` |
|
||||
| `substitute_env_placeholders` | Replace `${NAME}` substrings from the environment; opt-in `allow_whole_dollar_env` for whole-value `$NAME` |
|
||||
| `substitute_mapping_values` | Recursively traverse a dict/list/scalar structure and substitute `${ENV_VAR}` placeholders for caller-selected mapping keys only; optionally normalise blank strings to `None` for a separate caller-selected key set; does not hard-code any application-specific key names |
|
||||
|
||||
Credential resolution is generic: any environment variable name may appear inside
|
||||
`${...}`. mt5cli does not hard-code application-specific keys such as
|
||||
`mt5_login` or `mt5_exe`.
|
||||
|
||||
### Read-only MT5 data access
|
||||
|
||||
Module-level helpers open a transient connection per call. Prefer `mt5_session`
|
||||
or `MT5Client` when making many requests in one process.
|
||||
|
||||
| Area | Symbols |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| Rates | `copy_rates_from`, `copy_rates_from_pos`, `copy_rates_range`, `latest_rates`, `collect_latest_rates` |
|
||||
| Ticks | `copy_ticks_from`, `copy_ticks_range`, `recent_ticks` |
|
||||
| Account / terminal | `account_info`, `terminal_info`, `mt5_version`, `last_error`, `mt5_summary`, `mt5_summary_as_df` |
|
||||
| Symbols / market | `symbols`, `symbol_info`, `symbol_info_tick`, `market_book`, `minimum_margins` |
|
||||
| Trading state (read) | `orders`, `positions`, `history_orders`, `history_deals`, `recent_history_deals` |
|
||||
|
||||
Use `mt5_version` for MetaTrader 5 terminal version data. The name `version` at
|
||||
the package root refers to `importlib.metadata.version` (package metadata), not
|
||||
the MT5 SDK helper.
|
||||
Pass `allow_whole_dollar_env=True` to `substitute_env_placeholders()`,
|
||||
`substitute_mapping_values()`, `resolve_account_spec()`, `resolve_account_specs()`,
|
||||
and `build_config()` to additionally expand strings whose entire value is a bare
|
||||
`$ENV_NAME` identifier.
|
||||
Partial strings such as `"plan$pass"`, `"abc$ENV"`, or `"$ENV-suffix"` are
|
||||
**never** expanded — only an exact `$IDENTIFIER` whole-string match qualifies.
|
||||
Default is `False` to preserve backward compatibility.
|
||||
|
||||
### Closed-bar rate helpers
|
||||
|
||||
@@ -56,27 +57,28 @@ MetaTrader 5 returns the still-forming bar as the last row when
|
||||
`start_pos=0`. Use these helpers instead of reimplementing bar trimming or
|
||||
timestamp normalization in downstream apps.
|
||||
|
||||
| Symbol | Role |
|
||||
| ------------------------------------------------ | ------------------------------------------------------------ |
|
||||
| `drop_forming_rate_bar` | Remove the last row from chronologically ordered rate data |
|
||||
| `fetch_latest_closed_rates` | Single connected client: fetch `count + 1`, drop forming bar |
|
||||
| `collect_latest_closed_rates_for_accounts` | Multi-account closed bars with optional retry wrapper |
|
||||
| `collect_latest_closed_rates_by_granularity` | Same data keyed by `(symbol, granularity_name)` |
|
||||
| `collect_latest_rates_for_accounts` | Latest bars including the forming bar when `start_pos=0` |
|
||||
| `collect_latest_rates_for_accounts_with_retries` | Bounded exponential backoff for transient MT5 errors |
|
||||
| Symbol | Role |
|
||||
| ------------------------------------------------ | ------------------------------------------------------------------------------- |
|
||||
| `drop_forming_rate_bar` | Remove the last row from chronologically ordered rate data |
|
||||
| `fetch_latest_closed_rates` | Single connected client: fetch `count + 1`, drop forming bar |
|
||||
| `fetch_latest_closed_rates_for_trading_client` | Closed bars from an active `Mt5TradingClient` session; returns RangeIndex |
|
||||
| `fetch_latest_closed_rates_indexed` | Same as above but returns a UTC `DatetimeIndex` named `"time"` (no time column) |
|
||||
| `collect_latest_closed_rates_for_accounts` | Multi-account closed bars with optional retry wrapper |
|
||||
| `collect_latest_closed_rates_by_granularity` | Same data keyed by `(symbol, granularity_name)` |
|
||||
| `collect_latest_rates_for_accounts_with_retries` | Bounded exponential backoff for transient MT5 errors |
|
||||
|
||||
### SQLite history collection and rate loading
|
||||
|
||||
| Symbol | Role |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| `collect_history` | One-shot date-range export into SQLite |
|
||||
| `update_history`, `update_history_with_config` | Incremental append from `MAX(time)` cursors |
|
||||
| `ThrottledHistoryUpdater` | Minimum interval between successful incremental updates |
|
||||
| `resolve_history_datasets`, `resolve_history_timeframes`, `resolve_history_tick_flags` | History pipeline configuration |
|
||||
| `build_rate_view_name`, `resolve_rate_table_name`, `resolve_rate_view_name`, `resolve_rate_view_names`, `resolve_rate_tables` | Map symbols/timeframes to mt5cli-managed table or view names |
|
||||
| `RateTarget`, `build_rate_targets` | Neutral `(symbol, timeframe)` series descriptors |
|
||||
| `load_rate_data`, `load_rate_data_from_connection` | Load one table/view into a time-indexed DataFrame |
|
||||
| `load_rate_series_from_sqlite`, `load_rate_series_by_granularity` | Load one or many series; fail clearly when managed views are missing |
|
||||
| Symbol | Role |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `collect_history` | One-shot date-range export into SQLite |
|
||||
| `update_history`, `update_history_with_config` | Incremental append from `MAX(time)` cursors |
|
||||
| `ThrottledHistoryUpdater` | Minimum interval between successful incremental updates; optional `update_backend` injection |
|
||||
| `resolve_history_datasets`, `resolve_history_timeframes`, `resolve_history_tick_flags` | History pipeline configuration |
|
||||
| `build_rate_view_name`, `resolve_rate_table_name`, `resolve_rate_view_name`, `resolve_rate_view_names`, `resolve_rate_tables` | Map symbols/timeframes to mt5cli-managed table or view names |
|
||||
| `RateTarget`, `build_rate_targets` | Neutral `(symbol, timeframe)` series descriptors |
|
||||
| `load_rate_data`, `load_rate_data_from_connection` | Load one table/view into a time-indexed DataFrame |
|
||||
| `load_rate_series_from_sqlite`, `load_rate_series_by_granularity` | Load one or many series; fail clearly when managed views are missing |
|
||||
|
||||
Pass `require_existing=True` to rate view resolution helpers when downstream
|
||||
code must fail instead of receiving a best-guess view name. Multi-series loaders
|
||||
@@ -90,17 +92,25 @@ diagrams.
|
||||
These helpers implement broker-facing calculations only. They do not encode
|
||||
strategy entries, exits, Kelly sizing, or signal logic.
|
||||
|
||||
| Symbol | Role |
|
||||
| -------------------------------------------------------------------------------------------------- | --------------------------------------------- |
|
||||
| `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views |
|
||||
| `detect_position_side` | Net long / short / flat from open positions |
|
||||
| `calculate_spread_ratio` | Relative bid-ask spread |
|
||||
| `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing |
|
||||
| `determine_order_limits` | SL/TP price levels from ratios |
|
||||
| `ensure_symbol_selected` | Select/verify Market Watch visibility |
|
||||
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions` | Order execution helpers (`dry_run` supported) |
|
||||
| `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers |
|
||||
| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers |
|
||||
| Symbol | Role |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
|
||||
| `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views |
|
||||
| `extract_tick_price` | Positive finite bid/ask extraction from tick mappings |
|
||||
| `detect_position_side` | Net long / short / flat from open positions |
|
||||
| `calculate_spread_ratio` | Relative bid-ask spread |
|
||||
| `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing |
|
||||
| `normalize_order_volume`, `estimate_order_margin`, `calculate_positions_margin` | Broker volume normalization and margin totals |
|
||||
| `calculate_positions_margin_by_symbol` | Per-symbol margin map (resilient, first-seen order) |
|
||||
| `calculate_positions_margin_safe` | Summed total margin across symbols (failed symbols skipped) |
|
||||
| `calculate_projected_margin_ratio` | Estimated symbol-scoped margin/equity after optional new exposure |
|
||||
| `calculate_account_projected_margin_ratio` | Account snapshot margin/equity after optional new exposure |
|
||||
| `calculate_symbol_group_margin_ratio` | Estimated symbol-group margin/equity with optional exposure |
|
||||
| `determine_order_limits` | SL/TP price levels from ratios |
|
||||
| `calculate_trailing_stop_updates` | Per-ticket generic trailing stop-loss update plan |
|
||||
| `ensure_symbol_selected` | Select/verify Market Watch visibility |
|
||||
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions`, `update_trailing_stop_loss_for_open_positions` | Order execution helpers (`dry_run` supported) |
|
||||
| `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers |
|
||||
| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers |
|
||||
|
||||
`MT5Client.order_send()` and CLI `order-send --yes` are live execution paths.
|
||||
|
||||
@@ -123,13 +133,43 @@ and returned as `status="failed"` with normalized `request` / `response` details
|
||||
| `normalize_mt5_exception`, `call_with_normalized_errors`, `is_recoverable_mt5_error` | Error normalization and retry classification |
|
||||
| `Mt5Config`, `Mt5RuntimeError`, `Mt5TradingClient`, `Mt5TradingError` | Re-exported pdmt5 types for adapter convenience |
|
||||
|
||||
### Additional public exports (secondary)
|
||||
## Secondary public exports
|
||||
|
||||
The package root also exports schema, storage, and parsing helpers (for example
|
||||
`DataKind`, `Dataset`, `normalize_dataframe`, `export_dataframe`,
|
||||
`parse_timeframe`, `TIMEFRAME_MAP`). These are public but oriented toward export
|
||||
pipelines and advanced integration. Prefer the stable symbols above for core
|
||||
infrastructure.
|
||||
These names remain importable from `mt5cli` and are covered by
|
||||
`SECONDARY_PUBLIC_EXPORTS`, but they are oriented toward CLI/export/schema
|
||||
integrations, parsing, and lower-level MT5 access rather than the stable core
|
||||
SDK surface. Prefer the stable symbols above for downstream infrastructure
|
||||
adapters.
|
||||
|
||||
### Read-only MT5 data wrappers
|
||||
|
||||
Module-level helpers open a transient connection per call. Prefer `mt5_session`
|
||||
or `MT5Client` when making many requests in one process.
|
||||
|
||||
| Area | Symbols |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| Rates | `copy_rates_from`, `copy_rates_from_pos`, `copy_rates_range`, `latest_rates`, `collect_latest_rates` |
|
||||
| Ticks | `copy_ticks_from`, `copy_ticks_range`, `recent_ticks` |
|
||||
| Account / terminal | `account_info`, `terminal_info`, `mt5_version`, `last_error`, `mt5_summary`, `mt5_summary_as_df` |
|
||||
| Symbols / market | `symbols`, `symbol_info`, `symbol_info_tick`, `market_book`, `minimum_margins` |
|
||||
| Trading state (read) | `orders`, `positions`, `history_orders`, `history_deals`, `recent_history_deals` |
|
||||
| Multi-account rates | `collect_latest_rates_for_accounts` |
|
||||
|
||||
Use `mt5_version` for MetaTrader 5 terminal version data. The name `version` at
|
||||
the package root refers to `importlib.metadata.version` (package metadata), not
|
||||
the MT5 SDK helper.
|
||||
|
||||
### Schema, export, and parser helpers
|
||||
|
||||
| Area | Symbols |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| Dataset contracts | `DataKind`, `Dataset`, `IfExists`, `DEDUP_KEYS`, `REQUIRED_COLUMNS`, `TIME_COLUMNS`, `KNOWN_MT5_TIME_COLUMNS` |
|
||||
| Schema normalization | `normalize_dataframe`, `normalize_time_columns`, `schema_columns`, `validate_schema` |
|
||||
| Export helpers | `detect_format`, `export_dataframe`, `export_dataframe_to_sqlite` |
|
||||
| Symbol parsing | `normalize_symbol`, `normalize_symbols` |
|
||||
| Time parsing | `ensure_utc`, `parse_date_range`, `parse_datetime`, `recent_window` |
|
||||
| MT5 parsing maps | `granularity_name`, `parse_tick_flags`, `parse_timeframe`, `TICK_FLAG_MAP`, `TIMEFRAME_MAP` |
|
||||
| Trading data shapes | `POSITION_COLUMNS` |
|
||||
|
||||
## CLI commands
|
||||
|
||||
@@ -178,7 +218,7 @@ their own adapter layer.
|
||||
|
||||
## Contract verification
|
||||
|
||||
`tests/test_contracts.py` asserts that every name in `STABLE_SDK_EXPORTS` is
|
||||
importable from `mt5cli`, documents key closed-bar, rate-view, SQLite loading,
|
||||
account-resolution, and trading-session behaviors, and keeps the contract set
|
||||
aligned with `__all__`.
|
||||
`tests/test_contracts.py` asserts that every name in the stable and secondary
|
||||
tier sets is importable from `mt5cli`, documents key closed-bar, rate-view,
|
||||
SQLite loading, account-resolution, and trading-session behaviors, and keeps the
|
||||
tier sets aligned with `__all__`.
|
||||
|
||||
+50
-5
@@ -31,9 +31,10 @@ 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. `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
|
||||
row. `fetch_latest_closed_rates()` handles one connected `MT5Client`; use
|
||||
`fetch_latest_closed_rates_for_trading_client()` from an active
|
||||
`Mt5TradingClient` session. Multi-account helpers fetch `count + 1` bars, drop
|
||||
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.
|
||||
|
||||
@@ -85,6 +86,28 @@ resolved = resolve_account_specs(accounts, server="Broker-Demo")
|
||||
# resolved[0].login == "12345", resolved[0].server == "Broker-Demo"
|
||||
```
|
||||
|
||||
Pass `allow_whole_dollar_env=True` to also expand strings whose **entire value**
|
||||
is a bare `$ENV_NAME` identifier (no braces). This opt-in covers
|
||||
`substitute_env_placeholders()`, `resolve_account_spec()`,
|
||||
`resolve_account_specs()`, and `build_config()`. Note: `build_config` cannot
|
||||
expand `login` because that parameter is `int | None`; use
|
||||
`resolve_account_spec` for a string `login` placeholder. Partial strings such as
|
||||
`"plan$pass"`, `"abc$ENV"`, or `"$ENV-suffix"` are never expanded — only an
|
||||
exact `$IDENTIFIER` whole-string match qualifies. The default is `False` to
|
||||
preserve backward compatibility.
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
from mt5cli import AccountSpec, resolve_account_specs
|
||||
|
||||
os.environ["MT5_PASSWORD"] = "secret"
|
||||
accounts = [AccountSpec(symbols=["EURUSD"], password="$MT5_PASSWORD")]
|
||||
|
||||
resolved = resolve_account_specs(accounts, allow_whole_dollar_env=True)
|
||||
# resolved[0].password == "secret"
|
||||
```
|
||||
|
||||
### Throttled incremental history updates
|
||||
|
||||
`ThrottledHistoryUpdater` wraps `update_history()` with a minimum interval
|
||||
@@ -113,6 +136,28 @@ finally:
|
||||
client.shutdown()
|
||||
```
|
||||
|
||||
Pass `update_backend` to substitute the default `update_history` implementation
|
||||
without monkey-patching `mt5cli.sdk.update_history`. The callable receives the
|
||||
same keyword arguments as `update_history` (`client`, `output`, `symbols`,
|
||||
`datasets`, `timeframes`, `flags`, `lookback_hours`, `with_views`,
|
||||
`include_account_events`). The resolved backend is stored on
|
||||
`updater.update_backend` for inspection or subclassing.
|
||||
|
||||
```python
|
||||
from mt5cli import ThrottledHistoryUpdater, update_history
|
||||
|
||||
|
||||
def app_update_history(**kwargs) -> None:
|
||||
update_history(**kwargs) # or delegate to application-specific logic
|
||||
|
||||
|
||||
updater = ThrottledHistoryUpdater(
|
||||
output="history.db",
|
||||
interval_seconds=60,
|
||||
update_backend=app_update_history,
|
||||
)
|
||||
```
|
||||
|
||||
By default recoverable errors (`Mt5TradingError`, `Mt5RuntimeError`,
|
||||
`sqlite3.Error`, `ValueError`, `OSError`, and MT5 client capability
|
||||
`AttributeError` / `TypeError` for history API methods) propagate so the caller
|
||||
@@ -125,5 +170,5 @@ resulting `ValueError` is suppressed along with other recoverable errors.
|
||||
## Trading-capable sessions
|
||||
|
||||
For order placement and trading calculations, use the dedicated
|
||||
[Trading module](trading.md). The read-only `Mt5CliClient` and `mt5_session()`
|
||||
helpers in this module are unchanged.
|
||||
[Trading module](trading.md). Use `mt5_session()` / `MT5Client` for read-only
|
||||
collection.
|
||||
|
||||
+47
-9
@@ -31,7 +31,7 @@ finally:
|
||||
`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.
|
||||
Use `mt5_session()` / `MT5Client` for read-only data collection.
|
||||
|
||||
## State and order helpers
|
||||
|
||||
@@ -40,15 +40,20 @@ betting logic, or scheduling code in downstream applications.
|
||||
|
||||
```python
|
||||
from mt5cli import (
|
||||
calculate_positions_margin,
|
||||
calculate_spread_ratio,
|
||||
calculate_margin_and_volume,
|
||||
close_open_positions,
|
||||
detect_position_side,
|
||||
determine_order_limits,
|
||||
estimate_order_margin,
|
||||
fetch_latest_closed_rates_for_trading_client,
|
||||
fetch_latest_closed_rates_indexed,
|
||||
get_account_snapshot,
|
||||
get_positions_frame,
|
||||
get_symbol_snapshot,
|
||||
get_tick_snapshot,
|
||||
normalize_order_volume,
|
||||
place_market_order,
|
||||
)
|
||||
|
||||
@@ -58,6 +63,30 @@ 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")
|
||||
volume = normalize_order_volume(
|
||||
0.15,
|
||||
volume_min=symbol["volume_min"],
|
||||
volume_max=symbol["volume_max"],
|
||||
volume_step=symbol["volume_step"],
|
||||
)
|
||||
buy_margin = (
|
||||
estimate_order_margin(client, "EURUSD", "BUY", volume) if volume > 0 else 0.0
|
||||
)
|
||||
open_margin = calculate_positions_margin(client, symbols=["EURUSD"])
|
||||
closed_bars = fetch_latest_closed_rates_for_trading_client(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=100,
|
||||
)
|
||||
# Or fetch with a UTC DatetimeIndex instead of a "time" column:
|
||||
indexed_bars = fetch_latest_closed_rates_indexed(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=100,
|
||||
)
|
||||
# indexed_bars.index is a UTC-aware DatetimeIndex named "time"
|
||||
sizing = calculate_margin_and_volume(
|
||||
client,
|
||||
"EURUSD",
|
||||
@@ -87,6 +116,12 @@ closed = close_open_positions(client, symbols="EURUSD", dry_run=True)
|
||||
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.
|
||||
`normalize_order_volume()` returns `0.0` for invalid constraints or
|
||||
sub-minimum requests; check the result before calling `estimate_order_margin()`,
|
||||
which requires a positive finite volume. `calculate_positions_margin()` silently
|
||||
skips rows with missing symbols, non-positive volumes, non-finite volumes, or
|
||||
unsupported position types, but propagates `Mt5TradingError` from `estimate_order_margin()` when a valid row
|
||||
encounters invalid tick data or margin results from the broker.
|
||||
|
||||
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
|
||||
@@ -148,14 +183,17 @@ through the stable package root without embedding entry/exit policy.
|
||||
|
||||
## Migration from application-local helpers
|
||||
|
||||
| Application-local concern | mt5cli replacement |
|
||||
| -------------------------------------------------------- | ----------------------------------------------- |
|
||||
| Manual terminal spawn/kill around trading code | `mt5_trading_session()` |
|
||||
| Local position-side detection | `detect_position_side()` |
|
||||
| Local margin/volume sizing | `calculate_margin_and_volume()` |
|
||||
| Local SL/TP price derivation | `determine_order_limits()` |
|
||||
| Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` |
|
||||
| Application-local concern | mt5cli replacement |
|
||||
| -------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| Manual terminal spawn/kill around trading code | `mt5_trading_session()` |
|
||||
| Local position-side detection | `detect_position_side()` |
|
||||
| Local margin/volume sizing | `calculate_margin_and_volume()` |
|
||||
| Local broker volume step normalization | `normalize_order_volume()` |
|
||||
| Local order or position margin estimation | `estimate_order_margin()`, `calculate_positions_margin()` |
|
||||
| Local closed-bar fetch from a trading session | `fetch_latest_closed_rates_for_trading_client()`, `fetch_latest_closed_rates_indexed()` |
|
||||
| Local SL/TP price derivation | `determine_order_limits()` |
|
||||
| Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` |
|
||||
|
||||
Keep read-only data collection on `mt5_session()` / `Mt5CliClient`; use
|
||||
Keep read-only data collection on `mt5_session()` / `MT5Client`; use
|
||||
`mt5_trading_session()` only where order placement or trading calculations are
|
||||
required.
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ pip install mt5cli
|
||||
|
||||
## Python API for downstream packages
|
||||
|
||||
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives. `Mt5CliClient` remains available as a backward-compatible alias.
|
||||
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives.
|
||||
|
||||
```python
|
||||
from datetime import UTC, datetime
|
||||
|
||||
+35
-3
@@ -11,7 +11,11 @@ from importlib.metadata import version
|
||||
from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
|
||||
|
||||
from .client import MT5Client, build_config, mt5_session
|
||||
from .contract import STABLE_SDK_EXPORTS
|
||||
from .contract import (
|
||||
PUBLIC_EXPORT_TIERS,
|
||||
SECONDARY_PUBLIC_EXPORTS,
|
||||
STABLE_SDK_EXPORTS,
|
||||
)
|
||||
from .converters import (
|
||||
ensure_utc,
|
||||
granularity_name,
|
||||
@@ -59,7 +63,6 @@ from .schemas import (
|
||||
)
|
||||
from .sdk import (
|
||||
AccountSpec,
|
||||
Mt5CliClient,
|
||||
ThrottledHistoryUpdater,
|
||||
account_info,
|
||||
collect_history,
|
||||
@@ -89,6 +92,7 @@ from .sdk import (
|
||||
resolve_account_spec,
|
||||
resolve_account_specs,
|
||||
substitute_env_placeholders,
|
||||
substitute_mapping_values,
|
||||
symbol_info,
|
||||
symbol_info_tick,
|
||||
symbols,
|
||||
@@ -116,22 +120,35 @@ from .trading import (
|
||||
OrderSide,
|
||||
OrderTimeMode,
|
||||
PositionSide,
|
||||
calculate_account_projected_margin_ratio,
|
||||
calculate_margin_and_volume,
|
||||
calculate_new_position_margin_ratio,
|
||||
calculate_positions_margin,
|
||||
calculate_positions_margin_by_symbol,
|
||||
calculate_positions_margin_safe,
|
||||
calculate_projected_margin_ratio,
|
||||
calculate_spread_ratio,
|
||||
calculate_symbol_group_margin_ratio,
|
||||
calculate_trailing_stop_updates,
|
||||
calculate_volume_by_margin,
|
||||
close_open_positions,
|
||||
create_trading_client,
|
||||
detect_position_side,
|
||||
determine_order_limits,
|
||||
ensure_symbol_selected,
|
||||
estimate_order_margin,
|
||||
extract_tick_price,
|
||||
fetch_latest_closed_rates_for_trading_client,
|
||||
fetch_latest_closed_rates_indexed,
|
||||
get_account_snapshot,
|
||||
get_positions_frame,
|
||||
get_symbol_snapshot,
|
||||
get_tick_snapshot,
|
||||
mt5_trading_session,
|
||||
normalize_order_volume,
|
||||
place_market_order,
|
||||
update_sltp_for_open_positions,
|
||||
update_trailing_stop_loss_for_open_positions,
|
||||
)
|
||||
from .utils import (
|
||||
TICK_FLAG_MAP,
|
||||
@@ -147,7 +164,9 @@ __all__ = [
|
||||
"DEDUP_KEYS",
|
||||
"KNOWN_MT5_TIME_COLUMNS",
|
||||
"POSITION_COLUMNS",
|
||||
"PUBLIC_EXPORT_TIERS",
|
||||
"REQUIRED_COLUMNS",
|
||||
"SECONDARY_PUBLIC_EXPORTS",
|
||||
"STABLE_SDK_EXPORTS",
|
||||
"TICK_FLAG_MAP",
|
||||
"TIMEFRAME_MAP",
|
||||
@@ -159,7 +178,6 @@ __all__ = [
|
||||
"IfExists",
|
||||
"MT5Client",
|
||||
"MarginVolume",
|
||||
"Mt5CliClient",
|
||||
"Mt5CliError",
|
||||
"Mt5Config",
|
||||
"Mt5ConnectionError",
|
||||
@@ -180,9 +198,16 @@ __all__ = [
|
||||
"build_config",
|
||||
"build_rate_targets",
|
||||
"build_rate_view_name",
|
||||
"calculate_account_projected_margin_ratio",
|
||||
"calculate_margin_and_volume",
|
||||
"calculate_new_position_margin_ratio",
|
||||
"calculate_positions_margin",
|
||||
"calculate_positions_margin_by_symbol",
|
||||
"calculate_positions_margin_safe",
|
||||
"calculate_projected_margin_ratio",
|
||||
"calculate_spread_ratio",
|
||||
"calculate_symbol_group_margin_ratio",
|
||||
"calculate_trailing_stop_updates",
|
||||
"calculate_volume_by_margin",
|
||||
"call_with_normalized_errors",
|
||||
"close_open_positions",
|
||||
@@ -204,9 +229,13 @@ __all__ = [
|
||||
"drop_forming_rate_bar",
|
||||
"ensure_symbol_selected",
|
||||
"ensure_utc",
|
||||
"estimate_order_margin",
|
||||
"export_dataframe",
|
||||
"export_dataframe_to_sqlite",
|
||||
"extract_tick_price",
|
||||
"fetch_latest_closed_rates",
|
||||
"fetch_latest_closed_rates_for_trading_client",
|
||||
"fetch_latest_closed_rates_indexed",
|
||||
"get_account_snapshot",
|
||||
"get_positions_frame",
|
||||
"get_symbol_snapshot",
|
||||
@@ -230,6 +259,7 @@ __all__ = [
|
||||
"mt5_version",
|
||||
"normalize_dataframe",
|
||||
"normalize_mt5_exception",
|
||||
"normalize_order_volume",
|
||||
"normalize_symbol",
|
||||
"normalize_symbols",
|
||||
"normalize_time_columns",
|
||||
@@ -254,6 +284,7 @@ __all__ = [
|
||||
"resolve_rate_view_names",
|
||||
"schema_columns",
|
||||
"substitute_env_placeholders",
|
||||
"substitute_mapping_values",
|
||||
"symbol_info",
|
||||
"symbol_info_tick",
|
||||
"symbols",
|
||||
@@ -261,5 +292,6 @@ __all__ = [
|
||||
"update_history",
|
||||
"update_history_with_config",
|
||||
"update_sltp_for_open_positions",
|
||||
"update_trailing_stop_loss_for_open_positions",
|
||||
"validate_schema",
|
||||
]
|
||||
|
||||
+1
-3
@@ -24,9 +24,7 @@ class MT5Client(Mt5CliClient):
|
||||
"""Public client for generic MT5 data access and order primitives.
|
||||
|
||||
Extends the read-only SDK client with optional order check/send helpers and
|
||||
exposes the same connection lifecycle as :class:`~mt5cli.sdk.Mt5CliClient`.
|
||||
Downstream applications such as private trading packages should prefer this
|
||||
type over the legacy ``Mt5CliClient`` name.
|
||||
exposes the same connection lifecycle as :func:`mt5_session`.
|
||||
|
||||
mt5cli intentionally exposes minimal execution primitives only. Trading
|
||||
decisions, signals, strategies, backtests, and optimization remain the
|
||||
|
||||
+78
-29
@@ -1,11 +1,10 @@
|
||||
"""Stable downstream SDK export names for mt5cli."""
|
||||
"""Downstream SDK export tiers for mt5cli."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
||||
"AccountSpec",
|
||||
"MT5Client",
|
||||
"Mt5CliClient",
|
||||
"Mt5CliError",
|
||||
"Mt5Config",
|
||||
"Mt5ConnectionError",
|
||||
@@ -24,61 +23,50 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
||||
"OrderLimits",
|
||||
"RateTarget",
|
||||
"ThrottledHistoryUpdater",
|
||||
"account_info",
|
||||
"build_config",
|
||||
"build_rate_targets",
|
||||
"build_rate_view_name",
|
||||
"calculate_account_projected_margin_ratio",
|
||||
"calculate_margin_and_volume",
|
||||
"calculate_new_position_margin_ratio",
|
||||
"calculate_projected_margin_ratio",
|
||||
"calculate_positions_margin",
|
||||
"calculate_positions_margin_by_symbol",
|
||||
"calculate_positions_margin_safe",
|
||||
"calculate_spread_ratio",
|
||||
"calculate_symbol_group_margin_ratio",
|
||||
"calculate_trailing_stop_updates",
|
||||
"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",
|
||||
"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",
|
||||
"create_trading_client",
|
||||
"detect_position_side",
|
||||
"determine_order_limits",
|
||||
"drop_forming_rate_bar",
|
||||
"ensure_symbol_selected",
|
||||
"export_dataframe",
|
||||
"export_dataframe_to_sqlite",
|
||||
"estimate_order_margin",
|
||||
"extract_tick_price",
|
||||
"fetch_latest_closed_rates",
|
||||
"fetch_latest_closed_rates_for_trading_client",
|
||||
"fetch_latest_closed_rates_indexed",
|
||||
"get_account_snapshot",
|
||||
"get_positions_frame",
|
||||
"get_symbol_snapshot",
|
||||
"get_tick_snapshot",
|
||||
"history_deals",
|
||||
"history_orders",
|
||||
"is_recoverable_mt5_error",
|
||||
"last_error",
|
||||
"latest_rates",
|
||||
"load_rate_data",
|
||||
"load_rate_data_from_connection",
|
||||
"load_rate_series_by_granularity",
|
||||
"load_rate_series_from_sqlite",
|
||||
"market_book",
|
||||
"minimum_margins",
|
||||
"mt5_session",
|
||||
"mt5_summary",
|
||||
"mt5_summary_as_df",
|
||||
"mt5_trading_session",
|
||||
"mt5_version",
|
||||
"normalize_mt5_exception",
|
||||
"orders",
|
||||
"normalize_order_volume",
|
||||
"place_market_order",
|
||||
"positions",
|
||||
"recent_history_deals",
|
||||
"recent_ticks",
|
||||
"resolve_account_spec",
|
||||
"resolve_account_specs",
|
||||
"resolve_history_datasets",
|
||||
@@ -89,13 +77,74 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
||||
"resolve_rate_view_name",
|
||||
"resolve_rate_view_names",
|
||||
"substitute_env_placeholders",
|
||||
"substitute_mapping_values",
|
||||
"update_history",
|
||||
"update_history_with_config",
|
||||
"update_sltp_for_open_positions",
|
||||
"update_trailing_stop_loss_for_open_positions",
|
||||
})
|
||||
|
||||
SECONDARY_PUBLIC_EXPORTS: frozenset[str] = frozenset({
|
||||
"DEDUP_KEYS",
|
||||
"DataKind",
|
||||
"Dataset",
|
||||
"IfExists",
|
||||
"KNOWN_MT5_TIME_COLUMNS",
|
||||
"POSITION_COLUMNS",
|
||||
"REQUIRED_COLUMNS",
|
||||
"TICK_FLAG_MAP",
|
||||
"TIMEFRAME_MAP",
|
||||
"TIME_COLUMNS",
|
||||
"account_info",
|
||||
"collect_latest_rates",
|
||||
"collect_latest_rates_for_accounts",
|
||||
"copy_rates_from",
|
||||
"copy_rates_from_pos",
|
||||
"copy_rates_range",
|
||||
"copy_ticks_from",
|
||||
"copy_ticks_range",
|
||||
"detect_format",
|
||||
"ensure_utc",
|
||||
"export_dataframe",
|
||||
"export_dataframe_to_sqlite",
|
||||
"granularity_name",
|
||||
"history_deals",
|
||||
"history_orders",
|
||||
"last_error",
|
||||
"latest_rates",
|
||||
"market_book",
|
||||
"minimum_margins",
|
||||
"mt5_summary",
|
||||
"mt5_summary_as_df",
|
||||
"mt5_version",
|
||||
"normalize_dataframe",
|
||||
"normalize_symbol",
|
||||
"normalize_symbols",
|
||||
"normalize_time_columns",
|
||||
"orders",
|
||||
"parse_date_range",
|
||||
"parse_datetime",
|
||||
"parse_tick_flags",
|
||||
"parse_timeframe",
|
||||
"positions",
|
||||
"recent_history_deals",
|
||||
"recent_ticks",
|
||||
"recent_window",
|
||||
"schema_columns",
|
||||
"symbol_info",
|
||||
"symbol_info_tick",
|
||||
"symbols",
|
||||
"terminal_info",
|
||||
"update_history",
|
||||
"update_history_with_config",
|
||||
"update_sltp_for_open_positions",
|
||||
"validate_schema",
|
||||
})
|
||||
|
||||
__all__ = ["STABLE_SDK_EXPORTS"]
|
||||
PUBLIC_EXPORT_TIERS: dict[str, frozenset[str]] = {
|
||||
"stable": STABLE_SDK_EXPORTS,
|
||||
"secondary": SECONDARY_PUBLIC_EXPORTS,
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"PUBLIC_EXPORT_TIERS",
|
||||
"SECONDARY_PUBLIC_EXPORTS",
|
||||
"STABLE_SDK_EXPORTS",
|
||||
]
|
||||
|
||||
+180
-17
@@ -40,7 +40,9 @@ from .utils import (
|
||||
from .utils import coerce_login as _coerce_login
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
from collections.abc import Callable, Collection, Iterator, Sequence
|
||||
|
||||
UpdateHistoryBackend = Callable[..., None]
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
@@ -140,6 +142,7 @@ __all__ = [
|
||||
"resolve_account_spec",
|
||||
"resolve_account_specs",
|
||||
"substitute_env_placeholders",
|
||||
"substitute_mapping_values",
|
||||
"symbol_info",
|
||||
"symbol_info_tick",
|
||||
"symbols",
|
||||
@@ -303,19 +306,47 @@ def _fetch_minimum_margins(client: Mt5DataClient, symbol: str) -> pd.DataFrame:
|
||||
def build_config(
|
||||
*,
|
||||
path: str | None = None,
|
||||
login: int | None = None,
|
||||
login: int | str | None = None,
|
||||
password: str | None = None,
|
||||
server: str | None = None,
|
||||
timeout: int | None = None,
|
||||
allow_whole_dollar_env: bool = False,
|
||||
) -> Mt5Config:
|
||||
"""Build an ``Mt5Config`` from optional connection parameters.
|
||||
|
||||
Args:
|
||||
path: Optional terminal executable path.
|
||||
login: Optional trading account login. Integers are preserved. String
|
||||
values are coerced: empty or whitespace-only strings become
|
||||
``None``; numeric strings such as ``"12345"`` are converted to
|
||||
``int``; non-numeric strings raise ``ValueError``. When
|
||||
``allow_whole_dollar_env=True``, ``$ENV_NAME`` and
|
||||
``${ENV_NAME}`` placeholders are expanded before coercion.
|
||||
password: Optional trading account password.
|
||||
server: Optional trading server name.
|
||||
timeout: Optional connection timeout in milliseconds.
|
||||
allow_whole_dollar_env: When ``True``, string parameters that are
|
||||
exactly ``$ENV_NAME`` are expanded from the environment. Applies
|
||||
to ``path``, ``login``, ``password``, and ``server``. Default
|
||||
``False`` preserves existing behavior.
|
||||
|
||||
Returns:
|
||||
Configured ``Mt5Config`` instance.
|
||||
"""
|
||||
if allow_whole_dollar_env:
|
||||
if path is not None:
|
||||
path = substitute_env_placeholders(path, allow_whole_dollar_env=True)
|
||||
if isinstance(login, str):
|
||||
login = substitute_env_placeholders(login, allow_whole_dollar_env=True)
|
||||
if password is not None:
|
||||
password = substitute_env_placeholders(
|
||||
password, allow_whole_dollar_env=True
|
||||
)
|
||||
if server is not None:
|
||||
server = substitute_env_placeholders(server, allow_whole_dollar_env=True)
|
||||
return Mt5Config(
|
||||
path=path,
|
||||
login=login,
|
||||
login=_coerce_login(login),
|
||||
password=password,
|
||||
server=server,
|
||||
timeout=timeout,
|
||||
@@ -1044,10 +1075,16 @@ def update_history_with_config( # noqa: PLR0913
|
||||
class ThrottledHistoryUpdater:
|
||||
"""Throttled incremental SQLite history updater for long-running apps.
|
||||
|
||||
Wraps :func:`update_history` with a minimum interval between successful
|
||||
updates, so a tight application loop can call :meth:`update` every
|
||||
iteration without re-fetching MT5 history more often than desired. Timing
|
||||
uses a monotonic clock, so it is unaffected by wall-clock changes.
|
||||
Wraps :func:`update_history` (or a custom ``update_backend``) with a minimum
|
||||
interval between successful updates, so a tight application loop can call
|
||||
:meth:`update` every iteration without re-fetching MT5 history more often
|
||||
than desired. Timing uses a monotonic clock, so it is unaffected by
|
||||
wall-clock changes.
|
||||
|
||||
Downstream applications may pass ``update_backend`` to substitute the
|
||||
default :func:`update_history` implementation—for example to add
|
||||
application-specific logging, metrics, or test doubles—without monkey-
|
||||
patching ``mt5cli.sdk.update_history``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -1062,6 +1099,7 @@ class ThrottledHistoryUpdater:
|
||||
include_account_events: bool = True,
|
||||
interval_seconds: float = 0.0,
|
||||
suppress_errors: bool = False,
|
||||
update_backend: UpdateHistoryBackend | None = None,
|
||||
) -> None:
|
||||
"""Initialize the throttled updater.
|
||||
|
||||
@@ -1085,6 +1123,12 @@ class ThrottledHistoryUpdater:
|
||||
the throttle. Other ``AttributeError`` / ``TypeError`` values
|
||||
always propagate. When False (default), recoverable errors
|
||||
propagate so callers control logging.
|
||||
update_backend: Callable invoked instead of :func:`update_history`
|
||||
when :meth:`update` runs. Receives the same keyword arguments as
|
||||
:func:`update_history` (``client``, ``output``, ``symbols``,
|
||||
``datasets``, ``timeframes``, ``flags``, ``lookback_hours``,
|
||||
``with_views``, ``include_account_events``). Defaults to
|
||||
:func:`update_history`.
|
||||
"""
|
||||
self.output = output
|
||||
self.datasets = datasets
|
||||
@@ -1095,6 +1139,9 @@ class ThrottledHistoryUpdater:
|
||||
self.include_account_events = include_account_events
|
||||
self.interval_seconds = interval_seconds
|
||||
self.suppress_errors = suppress_errors
|
||||
self.update_backend = (
|
||||
update_history if update_backend is None else update_backend
|
||||
)
|
||||
self._last_update_monotonic: float | None = None
|
||||
|
||||
@property
|
||||
@@ -1145,7 +1192,7 @@ class ThrottledHistoryUpdater:
|
||||
lookback_hours=self.lookback_hours,
|
||||
date_to=None,
|
||||
)
|
||||
update_history(
|
||||
self.update_backend(
|
||||
client=client,
|
||||
output=self.output,
|
||||
symbols=symbols,
|
||||
@@ -1358,13 +1405,22 @@ class AccountSpec:
|
||||
|
||||
|
||||
_ENV_PLACEHOLDER_PATTERN = re.compile(r"\$\{(?P<name>[A-Za-z_][A-Za-z0-9_]*)\}")
|
||||
_WHOLE_DOLLAR_PATTERN = re.compile(r"^\$(?P<name>[A-Za-z_][A-Za-z0-9_]*)$")
|
||||
|
||||
|
||||
def substitute_env_placeholders(value: str) -> str:
|
||||
def substitute_env_placeholders(
|
||||
value: str,
|
||||
*,
|
||||
allow_whole_dollar_env: bool = False,
|
||||
) -> str:
|
||||
"""Replace ``${ENV_VAR}`` placeholders in a string with environment values.
|
||||
|
||||
Args:
|
||||
value: String that may contain one or more ``${ENV_VAR}`` placeholders.
|
||||
allow_whole_dollar_env: When ``True``, a string that is exactly
|
||||
``$ENV_NAME`` (the whole value and nothing else) is also expanded
|
||||
from the environment. Partial occurrences such as ``"plan$pass"``
|
||||
or ``"$ENV-suffix"`` are left unchanged.
|
||||
|
||||
Returns:
|
||||
The string with every placeholder replaced by its environment value.
|
||||
@@ -1372,6 +1428,14 @@ def substitute_env_placeholders(value: str) -> str:
|
||||
Raises:
|
||||
ValueError: If a referenced environment variable is not set.
|
||||
"""
|
||||
if allow_whole_dollar_env:
|
||||
m = _WHOLE_DOLLAR_PATTERN.match(value)
|
||||
if m:
|
||||
name = m.group("name")
|
||||
if name not in os.environ:
|
||||
msg = f"Environment variable {name!r} is not set."
|
||||
raise ValueError(msg)
|
||||
return os.environ[name]
|
||||
parts: list[str] = []
|
||||
last_end = 0
|
||||
for match in _ENV_PLACEHOLDER_PATTERN.finditer(value):
|
||||
@@ -1386,7 +1450,81 @@ def substitute_env_placeholders(value: str) -> str:
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _resolve_field(override: str | None, account_value: str | None) -> str | None:
|
||||
def substitute_mapping_values(
|
||||
data: object,
|
||||
*,
|
||||
keys: Collection[str],
|
||||
allow_whole_dollar_env: bool = False,
|
||||
blank_string_keys_as_none: Collection[str] = (),
|
||||
) -> object:
|
||||
"""Recursively substitute environment placeholders for selected mapping keys.
|
||||
|
||||
Traverses nested dicts and lists, expanding ``${ENV_VAR}`` (and
|
||||
``$ENV_NAME`` when ``allow_whole_dollar_env=True``) in string values
|
||||
whose immediate parent dict key is in ``keys``. Fields whose key is
|
||||
not in ``keys`` are preserved exactly, including literal dollar signs.
|
||||
Strings that are direct elements of a list are never substituted;
|
||||
substitution only applies to strings that are immediate dict values.
|
||||
|
||||
This is a generic downstream config utility. Key names such as
|
||||
``mt5_login`` or ``mt5_password`` must be supplied by the caller;
|
||||
mt5cli does not hard-code any application-specific key names.
|
||||
Callers are responsible for ensuring ``data`` has bounded nesting depth;
|
||||
deeply nested or self-referential structures will hit Python's recursion
|
||||
limit.
|
||||
|
||||
Args:
|
||||
data: Arbitrarily nested dict/list/scalar value to process.
|
||||
keys: Mapping keys whose string values receive placeholder
|
||||
substitution.
|
||||
allow_whole_dollar_env: When ``True``, a string that is exactly
|
||||
``$ENV_NAME`` (whole value) is also expanded from the
|
||||
environment in addition to ``${ENV_NAME}`` placeholders.
|
||||
Default ``False`` expands ``${ENV_NAME}`` only.
|
||||
blank_string_keys_as_none: Mapping keys for which blank strings
|
||||
(after any substitution) are normalised to ``None``. A key
|
||||
may appear in ``blank_string_keys_as_none`` without also
|
||||
appearing in ``keys``.
|
||||
|
||||
Returns:
|
||||
The processed value. Dicts and lists are rebuilt into new
|
||||
containers with selected string values substituted and
|
||||
blank-normalised. Scalar inputs (non-dict, non-list) are
|
||||
returned as-is.
|
||||
"""
|
||||
keys_set: frozenset[str] = frozenset(keys)
|
||||
blank_keys_set: frozenset[str] = frozenset(blank_string_keys_as_none)
|
||||
|
||||
def _visit(node: object, current_key: str | None) -> object:
|
||||
if isinstance(node, dict):
|
||||
typed = cast("dict[object, object]", node)
|
||||
return {
|
||||
k: _visit(v, k if isinstance(k, str) else None)
|
||||
for k, v in typed.items()
|
||||
}
|
||||
if isinstance(node, list):
|
||||
typed_list = cast("list[object]", node)
|
||||
return [_visit(item, None) for item in typed_list]
|
||||
if not isinstance(node, str):
|
||||
return node
|
||||
text = node
|
||||
if current_key in keys_set:
|
||||
text = substitute_env_placeholders(
|
||||
node, allow_whole_dollar_env=allow_whole_dollar_env
|
||||
)
|
||||
if current_key in blank_keys_set and not text.strip():
|
||||
return None
|
||||
return text
|
||||
|
||||
return _visit(data, None)
|
||||
|
||||
|
||||
def _resolve_field(
|
||||
override: str | None,
|
||||
account_value: str | None,
|
||||
*,
|
||||
allow_whole_dollar_env: bool = False,
|
||||
) -> str | None:
|
||||
"""Resolve a string field from an override or account value with env subst.
|
||||
|
||||
Returns:
|
||||
@@ -1396,12 +1534,16 @@ def _resolve_field(override: str | None, account_value: str | None) -> str | Non
|
||||
value = override if override is not None else account_value
|
||||
if value is None:
|
||||
return None
|
||||
return substitute_env_placeholders(value)
|
||||
return substitute_env_placeholders(
|
||||
value, allow_whole_dollar_env=allow_whole_dollar_env
|
||||
)
|
||||
|
||||
|
||||
def _resolve_login(
|
||||
override: int | str | None,
|
||||
account_login: int | str | None,
|
||||
*,
|
||||
allow_whole_dollar_env: bool = False,
|
||||
) -> int | str | None:
|
||||
"""Resolve a login from an override or account value with env substitution.
|
||||
|
||||
@@ -1413,10 +1555,14 @@ def _resolve_login(
|
||||
if override is not None:
|
||||
if isinstance(override, int):
|
||||
return override
|
||||
return substitute_env_placeholders(override)
|
||||
return substitute_env_placeholders(
|
||||
override, allow_whole_dollar_env=allow_whole_dollar_env
|
||||
)
|
||||
if account_login is None or isinstance(account_login, int):
|
||||
return account_login
|
||||
return substitute_env_placeholders(account_login)
|
||||
return substitute_env_placeholders(
|
||||
account_login, allow_whole_dollar_env=allow_whole_dollar_env
|
||||
)
|
||||
|
||||
|
||||
def resolve_account_spec(
|
||||
@@ -1427,6 +1573,7 @@ def resolve_account_spec(
|
||||
server: str | None = None,
|
||||
path: str | None = None,
|
||||
timeout: int | None = None,
|
||||
allow_whole_dollar_env: bool = False,
|
||||
) -> AccountSpec:
|
||||
"""Resolve an account's credentials from overrides and ``${ENV_VAR}`` values.
|
||||
|
||||
@@ -1442,6 +1589,9 @@ def resolve_account_spec(
|
||||
server: Optional explicit server override.
|
||||
path: Optional explicit terminal path override.
|
||||
timeout: Optional explicit connection timeout override.
|
||||
allow_whole_dollar_env: When ``True``, string fields that are exactly
|
||||
``$ENV_NAME`` are also expanded from the environment. Default
|
||||
``False`` preserves existing behavior.
|
||||
|
||||
Returns:
|
||||
A new :class:`AccountSpec` with resolved credentials and the original
|
||||
@@ -1451,10 +1601,18 @@ def resolve_account_spec(
|
||||
"""
|
||||
return AccountSpec(
|
||||
symbols=account.symbols,
|
||||
login=_resolve_login(login, account.login),
|
||||
password=_resolve_field(password, account.password),
|
||||
server=_resolve_field(server, account.server),
|
||||
path=_resolve_field(path, account.path),
|
||||
login=_resolve_login(
|
||||
login, account.login, allow_whole_dollar_env=allow_whole_dollar_env
|
||||
),
|
||||
password=_resolve_field(
|
||||
password, account.password, allow_whole_dollar_env=allow_whole_dollar_env
|
||||
),
|
||||
server=_resolve_field(
|
||||
server, account.server, allow_whole_dollar_env=allow_whole_dollar_env
|
||||
),
|
||||
path=_resolve_field(
|
||||
path, account.path, allow_whole_dollar_env=allow_whole_dollar_env
|
||||
),
|
||||
timeout=timeout if timeout is not None else account.timeout,
|
||||
)
|
||||
|
||||
@@ -1467,6 +1625,7 @@ def resolve_account_specs(
|
||||
server: str | None = None,
|
||||
path: str | None = None,
|
||||
timeout: int | None = None,
|
||||
allow_whole_dollar_env: bool = False,
|
||||
) -> list[AccountSpec]:
|
||||
"""Resolve credentials for multiple accounts.
|
||||
|
||||
@@ -1480,6 +1639,9 @@ def resolve_account_specs(
|
||||
server: Optional explicit server override applied to each account.
|
||||
path: Optional explicit terminal path override applied to each account.
|
||||
timeout: Optional explicit timeout override applied to each account.
|
||||
allow_whole_dollar_env: When ``True``, string fields that are exactly
|
||||
``$ENV_NAME`` are also expanded from the environment. Default
|
||||
``False`` preserves existing behavior.
|
||||
|
||||
Returns:
|
||||
Resolved account specifications in the original order. Raises
|
||||
@@ -1494,6 +1656,7 @@ def resolve_account_specs(
|
||||
server=server,
|
||||
path=path,
|
||||
timeout=timeout,
|
||||
allow_whole_dollar_env=allow_whole_dollar_env,
|
||||
)
|
||||
for account in accounts
|
||||
]
|
||||
|
||||
+706
-54
@@ -2,19 +2,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from math import floor, isfinite
|
||||
from numbers import Integral
|
||||
from numbers import Integral, Real
|
||||
from typing import TYPE_CHECKING, Literal, TypedDict, cast
|
||||
|
||||
import pandas as pd
|
||||
from pdmt5 import Mt5Config, Mt5TradingClient, Mt5TradingError
|
||||
from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
|
||||
|
||||
from .history import drop_forming_rate_bar
|
||||
from .sdk import build_config
|
||||
from .utils import coerce_login as _coerce_login
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
PositionSide = Literal["long", "short"]
|
||||
OrderSide = Literal["BUY", "SELL"]
|
||||
@@ -123,22 +127,35 @@ __all__ = [
|
||||
"OrderSide",
|
||||
"OrderTimeMode",
|
||||
"PositionSide",
|
||||
"calculate_account_projected_margin_ratio",
|
||||
"calculate_margin_and_volume",
|
||||
"calculate_new_position_margin_ratio",
|
||||
"calculate_positions_margin",
|
||||
"calculate_positions_margin_by_symbol",
|
||||
"calculate_positions_margin_safe",
|
||||
"calculate_projected_margin_ratio",
|
||||
"calculate_spread_ratio",
|
||||
"calculate_symbol_group_margin_ratio",
|
||||
"calculate_trailing_stop_updates",
|
||||
"calculate_volume_by_margin",
|
||||
"close_open_positions",
|
||||
"create_trading_client",
|
||||
"detect_position_side",
|
||||
"determine_order_limits",
|
||||
"ensure_symbol_selected",
|
||||
"estimate_order_margin",
|
||||
"extract_tick_price",
|
||||
"fetch_latest_closed_rates_for_trading_client",
|
||||
"fetch_latest_closed_rates_indexed",
|
||||
"get_account_snapshot",
|
||||
"get_positions_frame",
|
||||
"get_symbol_snapshot",
|
||||
"get_tick_snapshot",
|
||||
"mt5_trading_session",
|
||||
"normalize_order_volume",
|
||||
"place_market_order",
|
||||
"update_sltp_for_open_positions",
|
||||
"update_trailing_stop_loss_for_open_positions",
|
||||
]
|
||||
|
||||
|
||||
@@ -276,6 +293,48 @@ def _normalize_order_side(side: str) -> OrderSide:
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def _is_finite_number(value: object) -> bool:
|
||||
return isinstance(value, int | float) and isfinite(value)
|
||||
|
||||
|
||||
def _is_positive_finite_number(value: object) -> bool:
|
||||
if not _is_finite_number(value):
|
||||
return False
|
||||
return float(cast("float | int", value)) > 0
|
||||
|
||||
|
||||
def normalize_order_volume(
|
||||
volume: float,
|
||||
*,
|
||||
volume_min: float,
|
||||
volume_max: float,
|
||||
volume_step: float,
|
||||
) -> float:
|
||||
"""Normalize a requested order volume to broker volume constraints.
|
||||
|
||||
Returns:
|
||||
Volume floored to the nearest valid broker step from ``volume_min``,
|
||||
capped at ``volume_max`` when finite and positive, and rounded
|
||||
deterministically. Returns ``0.0`` when inputs or constraints are
|
||||
invalid, non-finite, or the capped request is below ``volume_min``.
|
||||
"""
|
||||
if not _is_finite_number(volume):
|
||||
return 0.0
|
||||
if not _is_positive_finite_number(volume_min):
|
||||
return 0.0
|
||||
if not _is_positive_finite_number(volume_step):
|
||||
return 0.0
|
||||
has_volume_cap = _is_positive_finite_number(volume_max)
|
||||
capped = min(volume, volume_max) if has_volume_cap else volume
|
||||
if capped < volume_min:
|
||||
return 0.0
|
||||
steps = floor(((capped - volume_min) / volume_step) + 1e-12)
|
||||
normalized = volume_min + max(0, steps) * volume_step
|
||||
if has_volume_cap:
|
||||
normalized = min(normalized, volume_max)
|
||||
return round(normalized, 10)
|
||||
|
||||
|
||||
def _position_side_from_order_side(side: str) -> PositionSide:
|
||||
normalized = side.lower()
|
||||
if normalized in {"long", "buy"}:
|
||||
@@ -374,6 +433,30 @@ def _optional_price(value: object) -> float | None:
|
||||
return price
|
||||
|
||||
|
||||
def extract_tick_price(tick: Mapping[str, object], key: str) -> float | None:
|
||||
"""Return a positive finite float from tick[key], or None if invalid.
|
||||
|
||||
Accepts int, float, or numeric string values. Returns None when the key is
|
||||
missing, the value is None, non-numeric, NaN, infinite, zero, or negative.
|
||||
Booleans are treated as non-numeric and return None.
|
||||
"""
|
||||
value = tick.get(key)
|
||||
if value is None or isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int | float):
|
||||
price = float(value)
|
||||
elif isinstance(value, str):
|
||||
try:
|
||||
price = float(value)
|
||||
except ValueError:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
if not isfinite(price) or price <= 0:
|
||||
return None
|
||||
return price
|
||||
|
||||
|
||||
def _success_retcodes(mt5: object) -> frozenset[int]:
|
||||
values = {
|
||||
value
|
||||
@@ -412,9 +495,10 @@ def _calculate_min_volume_if_affordable(
|
||||
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:
|
||||
price = extract_tick_price(
|
||||
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
|
||||
)
|
||||
if price is None:
|
||||
msg = f"Tick price is unavailable for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
order_type = (
|
||||
@@ -531,22 +615,187 @@ def get_positions_frame(
|
||||
return frame
|
||||
|
||||
|
||||
def _order_side_from_position_type(
|
||||
client: Mt5TradingClient,
|
||||
position_type: object,
|
||||
) -> OrderSide | None:
|
||||
if position_type == client.mt5.POSITION_TYPE_BUY:
|
||||
return "BUY"
|
||||
if position_type == client.mt5.POSITION_TYPE_SELL:
|
||||
return "SELL"
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_rate_time_column(frame: pd.DataFrame) -> pd.DataFrame:
|
||||
if frame.empty or "time" in frame.columns:
|
||||
return frame
|
||||
if frame.index.name == "time" or isinstance(frame.index, pd.DatetimeIndex):
|
||||
normalized = frame.reset_index()
|
||||
if "time" not in normalized.columns and not normalized.empty:
|
||||
normalized = normalized.rename(columns={normalized.columns[0]: "time"})
|
||||
return normalized
|
||||
return frame
|
||||
|
||||
|
||||
def estimate_order_margin(
|
||||
client: Mt5TradingClient,
|
||||
symbol: str,
|
||||
order_side: OrderSide | str,
|
||||
volume: float,
|
||||
) -> float:
|
||||
"""Estimate required margin for one order at the current market price.
|
||||
|
||||
Returns:
|
||||
Positive finite margin required for the order at the current quote.
|
||||
|
||||
Raises:
|
||||
Mt5TradingError: If volume, tick data, or margin estimation is invalid.
|
||||
"""
|
||||
if not _is_positive_finite_number(volume):
|
||||
msg = "Volume must be a positive finite number to estimate order margin."
|
||||
raise Mt5TradingError(msg)
|
||||
side = _normalize_order_side(order_side)
|
||||
tick = get_tick_snapshot(client, symbol)
|
||||
price = extract_tick_price(tick, "ask" if side == "BUY" else "bid")
|
||||
if price is None:
|
||||
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
|
||||
)
|
||||
raw_margin = client.order_calc_margin(order_type, symbol, volume, price)
|
||||
try:
|
||||
margin = float(raw_margin)
|
||||
except (TypeError, ValueError) as exc:
|
||||
msg = f"Margin estimate is invalid for {symbol!r}."
|
||||
raise Mt5TradingError(msg) from exc
|
||||
if margin <= 0 or not isfinite(margin):
|
||||
msg = f"Margin estimate is invalid for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
return margin
|
||||
|
||||
|
||||
def calculate_positions_margin(
|
||||
client: Mt5TradingClient,
|
||||
*,
|
||||
symbols: Sequence[str] | None = None,
|
||||
) -> float:
|
||||
"""Return the sum of estimated current margin for open positions.
|
||||
|
||||
Args:
|
||||
client: Connected ``Mt5TradingClient`` instance.
|
||||
symbols: Optional symbol filter. When omitted, all open positions are
|
||||
included.
|
||||
|
||||
Returns:
|
||||
Total estimated margin, or ``0.0`` when no matching positions exist.
|
||||
"""
|
||||
frame = get_positions_frame(client)
|
||||
if frame.empty or "symbol" not in frame.columns:
|
||||
return 0.0
|
||||
if symbols is not None:
|
||||
frame = frame[frame["symbol"].isin(list(symbols))]
|
||||
if frame.empty:
|
||||
return 0.0
|
||||
grouped_volumes: dict[tuple[str, OrderSide], float] = {}
|
||||
for _, row in frame.iterrows():
|
||||
symbol = row.get("symbol")
|
||||
if not isinstance(symbol, str) or not symbol:
|
||||
continue
|
||||
volume = row.get("volume")
|
||||
if not _is_positive_finite_number(volume):
|
||||
continue
|
||||
order_side = _order_side_from_position_type(client, row.get("type"))
|
||||
if order_side is None:
|
||||
continue
|
||||
key = (symbol, order_side)
|
||||
finite_volume = float(cast("float | int", volume))
|
||||
grouped_volumes[key] = grouped_volumes.get(key, 0.0) + finite_volume
|
||||
total = 0.0
|
||||
for (symbol, order_side), volume in grouped_volumes.items():
|
||||
total += estimate_order_margin(client, symbol, order_side, volume)
|
||||
return total
|
||||
|
||||
|
||||
def calculate_positions_margin_by_symbol(
|
||||
client: Mt5TradingClient,
|
||||
*,
|
||||
symbols: Sequence[str],
|
||||
suppress_errors: bool = True,
|
||||
) -> dict[str, float]:
|
||||
"""Return per-symbol estimated margin for open positions.
|
||||
|
||||
Computes margin for each unique input symbol independently using the strict
|
||||
:func:`calculate_positions_margin` helper. Duplicates are deduplicated in
|
||||
first-seen order.
|
||||
|
||||
Args:
|
||||
client: Connected ``Mt5TradingClient`` instance.
|
||||
symbols: Symbols to compute margin for.
|
||||
suppress_errors: When ``True``, log and skip symbols that raise
|
||||
``Mt5TradingError``, ``Mt5RuntimeError``, or ``AttributeError``.
|
||||
When ``False``, re-raise the first failure.
|
||||
|
||||
Returns:
|
||||
Mapping of symbol to margin total in first-seen unique-symbol order.
|
||||
Returns an empty dict when ``symbols`` is empty or all symbols fail
|
||||
with ``suppress_errors=True``.
|
||||
|
||||
Raises:
|
||||
Mt5TradingError: When a symbol raises ``Mt5TradingError`` and
|
||||
``suppress_errors=False``.
|
||||
Mt5RuntimeError: When a symbol raises ``Mt5RuntimeError`` and
|
||||
``suppress_errors=False``.
|
||||
AttributeError: When a symbol raises ``AttributeError`` and
|
||||
``suppress_errors=False``.
|
||||
"""
|
||||
result: dict[str, float] = {}
|
||||
for symbol in dict.fromkeys(symbols):
|
||||
try:
|
||||
result[symbol] = calculate_positions_margin(client, symbols=[symbol])
|
||||
except (Mt5TradingError, Mt5RuntimeError, AttributeError) as exc:
|
||||
if not suppress_errors:
|
||||
raise
|
||||
_logger.warning("Skipping margin for %r: %s", symbol, exc)
|
||||
return result
|
||||
|
||||
|
||||
def calculate_positions_margin_safe(
|
||||
client: Mt5TradingClient,
|
||||
*,
|
||||
symbols: Sequence[str],
|
||||
) -> float:
|
||||
"""Return the total estimated margin for open positions across symbols.
|
||||
|
||||
Internally calls :func:`calculate_positions_margin_by_symbol` with
|
||||
``suppress_errors=True``. Failed symbols are silently skipped.
|
||||
|
||||
Args:
|
||||
client: Connected ``Mt5TradingClient`` instance.
|
||||
symbols: Symbols to include.
|
||||
|
||||
Returns:
|
||||
Sum of per-symbol margins; ``0.0`` when no symbols or all fail.
|
||||
"""
|
||||
return sum(
|
||||
calculate_positions_margin_by_symbol(client, symbols=symbols).values(),
|
||||
0.0,
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
Mt5TradingError: If bid or ask is unavailable.
|
||||
"""
|
||||
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):
|
||||
bid = extract_tick_price(tick, "bid")
|
||||
ask = extract_tick_price(tick, "ask")
|
||||
if bid is None or ask is None:
|
||||
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)
|
||||
return (ask - bid) / ((ask + bid) / 2.0)
|
||||
|
||||
|
||||
def calculate_new_position_margin_ratio(
|
||||
@@ -569,9 +818,10 @@ def calculate_new_position_margin_ratio(
|
||||
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:
|
||||
price = extract_tick_price(
|
||||
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
|
||||
)
|
||||
if price is None:
|
||||
msg = f"Tick price is unavailable for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
order_type = (
|
||||
@@ -583,6 +833,147 @@ def calculate_new_position_margin_ratio(
|
||||
return margin / equity
|
||||
|
||||
|
||||
def _account_equity(client: Mt5TradingClient) -> float:
|
||||
account = get_account_snapshot(client)
|
||||
return _required_account_number(account, "equity", allow_zero=False)
|
||||
|
||||
|
||||
def _required_account_number(
|
||||
account: Mapping[str, object],
|
||||
field: str,
|
||||
*,
|
||||
allow_zero: bool,
|
||||
) -> float:
|
||||
raw_value = account.get(field)
|
||||
if isinstance(raw_value, bool) or not isinstance(raw_value, Real):
|
||||
msg = f"Account {field} must be a finite number to calculate margin ratio."
|
||||
raise Mt5TradingError(msg)
|
||||
value = float(raw_value)
|
||||
if (
|
||||
not isfinite(value)
|
||||
or (not allow_zero and value <= 0)
|
||||
or (allow_zero and value < 0)
|
||||
):
|
||||
msg = (
|
||||
f"Account {field} must be a non-negative finite number."
|
||||
if allow_zero
|
||||
else f"Account {field} must be a positive finite number."
|
||||
)
|
||||
raise Mt5TradingError(msg)
|
||||
return value
|
||||
|
||||
|
||||
def calculate_account_projected_margin_ratio(
|
||||
client: Mt5TradingClient,
|
||||
*,
|
||||
symbol: str | None = None,
|
||||
new_position_side: OrderSide | None = None,
|
||||
new_position_volume: float = 0.0,
|
||||
) -> float:
|
||||
"""Return account-wide current plus optional new-position margin over equity.
|
||||
|
||||
Current exposure comes from the broker account snapshot ``margin`` field so
|
||||
unrelated open positions remain in the baseline. Optional projected
|
||||
exposure is added via :func:`estimate_order_margin` only when a symbol, side,
|
||||
and positive volume are all supplied.
|
||||
|
||||
"""
|
||||
account = get_account_snapshot(client)
|
||||
equity = _required_account_number(account, "equity", allow_zero=False)
|
||||
margin = _required_account_number(account, "margin", allow_zero=True)
|
||||
if symbol is not None and new_position_side is not None and new_position_volume > 0:
|
||||
margin += estimate_order_margin(
|
||||
client,
|
||||
symbol,
|
||||
new_position_side,
|
||||
new_position_volume,
|
||||
)
|
||||
return margin / equity
|
||||
|
||||
|
||||
def calculate_projected_margin_ratio(
|
||||
client: Mt5TradingClient,
|
||||
*,
|
||||
symbol: str,
|
||||
new_position_side: OrderSide | None = None,
|
||||
new_position_volume: float = 0.0,
|
||||
) -> float:
|
||||
"""Return estimated current plus optional new-position margin over equity.
|
||||
|
||||
Current exposure is estimated from open positions with
|
||||
:func:`calculate_positions_margin`. Optional projected exposure is added via
|
||||
:func:`estimate_order_margin`. Thresholds and guard actions are intentionally
|
||||
left to downstream applications.
|
||||
|
||||
Account equity, position margin, and optional projected margin errors from
|
||||
the composed MT5 helpers propagate to the caller.
|
||||
"""
|
||||
equity = _account_equity(client)
|
||||
margin = calculate_positions_margin(client, symbols=[symbol])
|
||||
if new_position_side is not None and new_position_volume > 0:
|
||||
margin += estimate_order_margin(
|
||||
client,
|
||||
symbol,
|
||||
new_position_side,
|
||||
new_position_volume,
|
||||
)
|
||||
return margin / equity
|
||||
|
||||
|
||||
def calculate_symbol_group_margin_ratio(
|
||||
client: Mt5TradingClient,
|
||||
*,
|
||||
symbols: Sequence[str],
|
||||
new_symbol: str | None = None,
|
||||
new_position_side: OrderSide | None = None,
|
||||
new_position_volume: float = 0.0,
|
||||
suppress_errors: bool = True,
|
||||
) -> float:
|
||||
"""Return estimated symbol-group margin over account equity.
|
||||
|
||||
Per-symbol current exposure is summed with
|
||||
:func:`calculate_positions_margin_by_symbol`. When ``new_symbol`` is inside
|
||||
the input symbol group, optional projected order margin is added for that
|
||||
symbol. Invalid equity always raises to fail closed.
|
||||
|
||||
Raises:
|
||||
AttributeError: When symbol margin lookup or projected margin lookup
|
||||
fails and ``suppress_errors`` is ``False``.
|
||||
Mt5RuntimeError: When symbol margin lookup or projected margin lookup
|
||||
fails and ``suppress_errors`` is ``False``.
|
||||
Mt5TradingError: When account equity is invalid, or when symbol margin
|
||||
lookup or projected margin lookup fails and ``suppress_errors`` is
|
||||
``False``.
|
||||
"""
|
||||
equity = _account_equity(client)
|
||||
unique_symbols = list(dict.fromkeys(symbols))
|
||||
margin = sum(
|
||||
calculate_positions_margin_by_symbol(
|
||||
client,
|
||||
symbols=unique_symbols,
|
||||
suppress_errors=suppress_errors,
|
||||
).values(),
|
||||
0.0,
|
||||
)
|
||||
if (
|
||||
new_symbol in unique_symbols
|
||||
and new_position_side is not None
|
||||
and new_position_volume > 0
|
||||
):
|
||||
try:
|
||||
margin += estimate_order_margin(
|
||||
client,
|
||||
new_symbol,
|
||||
new_position_side,
|
||||
new_position_volume,
|
||||
)
|
||||
except (Mt5TradingError, Mt5RuntimeError, AttributeError):
|
||||
if not suppress_errors:
|
||||
raise
|
||||
_logger.warning("Skipping projected margin for %r.", new_symbol)
|
||||
return margin / equity
|
||||
|
||||
|
||||
def calculate_margin_and_volume(
|
||||
client: Mt5TradingClient,
|
||||
symbol: str,
|
||||
@@ -629,28 +1020,8 @@ def calculate_margin_and_volume(
|
||||
"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",
|
||||
)
|
||||
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)
|
||||
@@ -679,7 +1050,9 @@ def calculate_volume_by_margin(
|
||||
"""Calculate max normalized volume affordable for one side.
|
||||
|
||||
Returns:
|
||||
Affordable volume rounded down to symbol volume constraints.
|
||||
Largest stepped volume whose actual margin (from ``order_calc_margin``)
|
||||
fits within ``available_margin``, rounded down to symbol volume
|
||||
constraints; ``0.0`` when no affordable step exists.
|
||||
|
||||
Raises:
|
||||
Mt5TradingError: If symbol volume constraints or tick data are invalid.
|
||||
@@ -694,9 +1067,10 @@ def calculate_volume_by_margin(
|
||||
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:
|
||||
price = extract_tick_price(
|
||||
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
|
||||
)
|
||||
if price is None:
|
||||
msg = f"Tick price is unavailable for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
order_type = (
|
||||
@@ -705,11 +1079,38 @@ def calculate_volume_by_margin(
|
||||
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
|
||||
lo = 0
|
||||
hi = int(
|
||||
max(
|
||||
0,
|
||||
floor(
|
||||
(
|
||||
(
|
||||
min(available_margin / min_margin * volume_min, volume_max)
|
||||
if volume_max > 0
|
||||
else available_margin / min_margin * volume_min
|
||||
)
|
||||
- volume_min
|
||||
)
|
||||
/ volume_step
|
||||
+ 1e-12
|
||||
),
|
||||
)
|
||||
)
|
||||
best = -1
|
||||
|
||||
while lo <= hi:
|
||||
mid = (lo + hi) // 2
|
||||
normalized = round(volume_min + mid * volume_step, 10)
|
||||
actual = float(client.order_calc_margin(order_type, symbol, normalized, price))
|
||||
|
||||
if actual > 0 and actual <= available_margin:
|
||||
best = mid
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid - 1
|
||||
|
||||
return round(volume_min + best * volume_step, 10) if best >= 0 else 0.0
|
||||
|
||||
|
||||
def determine_order_limits(
|
||||
@@ -745,11 +1146,11 @@ def determine_order_limits(
|
||||
_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):
|
||||
entry_key = "ask" if normalized_side == "long" else "bid"
|
||||
entry = extract_tick_price(tick, entry_key)
|
||||
if entry is None:
|
||||
msg = f"Tick price is unavailable for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
entry = float(entry_value)
|
||||
try:
|
||||
symbol_info = get_symbol_snapshot(client, symbol)
|
||||
except (AttributeError, KeyError, TypeError, ValueError):
|
||||
@@ -825,8 +1226,8 @@ def place_market_order(
|
||||
if not dry_run:
|
||||
ensure_symbol_selected(client, symbol)
|
||||
tick = get_tick_snapshot(client, symbol)
|
||||
price = tick["ask"] if side == "BUY" else tick["bid"]
|
||||
if not isinstance(price, int | float) or price <= 0:
|
||||
price = extract_tick_price(tick, "ask" if side == "BUY" else "bid")
|
||||
if price is None:
|
||||
msg = f"Tick price is unavailable for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
request = {
|
||||
@@ -836,7 +1237,7 @@ def place_market_order(
|
||||
"type": (
|
||||
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
|
||||
),
|
||||
"price": float(price),
|
||||
"price": price,
|
||||
"type_filling": _resolve_mt5_constant(
|
||||
client.mt5,
|
||||
"ORDER_FILLING",
|
||||
@@ -933,6 +1334,125 @@ def close_open_positions(
|
||||
return results
|
||||
|
||||
|
||||
def _symbol_digits(client: Mt5TradingClient, symbol: str) -> int | None:
|
||||
try:
|
||||
raw_digits = get_symbol_snapshot(client, symbol).get("digits")
|
||||
if raw_digits is None:
|
||||
return None
|
||||
digits = int(raw_digits)
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return None
|
||||
return digits if digits >= 0 else None
|
||||
|
||||
|
||||
def _position_ticket(value: object) -> int | None:
|
||||
ticket = _optional_int(value)
|
||||
return ticket if ticket is not None and ticket > 0 else None
|
||||
|
||||
|
||||
def _current_stop_loss(value: object) -> float | None:
|
||||
return _optional_price(value)
|
||||
|
||||
|
||||
def _trailing_stop_loss(
|
||||
client: Mt5TradingClient,
|
||||
*,
|
||||
position_type: object,
|
||||
current_sl: float | None,
|
||||
bid: float | None,
|
||||
ask: float | None,
|
||||
digits: int,
|
||||
trailing_stop_ratio: float,
|
||||
) -> float | None:
|
||||
next_sl: float | None = None
|
||||
if position_type == client.mt5.POSITION_TYPE_BUY:
|
||||
if bid is not None:
|
||||
next_sl = round(bid * (1.0 - trailing_stop_ratio), digits)
|
||||
if current_sl is not None and current_sl >= next_sl:
|
||||
next_sl = None
|
||||
elif position_type == client.mt5.POSITION_TYPE_SELL and ask is not None:
|
||||
next_sl = round(ask * (1.0 + trailing_stop_ratio), digits)
|
||||
if current_sl is not None and current_sl <= next_sl:
|
||||
next_sl = None
|
||||
return next_sl
|
||||
|
||||
|
||||
def calculate_trailing_stop_updates(
|
||||
client: Mt5TradingClient,
|
||||
*,
|
||||
symbol: str,
|
||||
trailing_stop_ratio: float,
|
||||
) -> dict[int, float]:
|
||||
"""Return per-ticket trailing stop-loss updates for open symbol positions.
|
||||
|
||||
Buy positions trail from bid using ``bid * (1 - trailing_stop_ratio)``.
|
||||
Sell positions trail from ask using ``ask * (1 + trailing_stop_ratio)``.
|
||||
Existing stop losses are preserved when they are already more favorable.
|
||||
Missing symbol metadata returns an empty update map. Positions with a
|
||||
missing side-specific tick price are skipped.
|
||||
"""
|
||||
_require_protective_ratio(trailing_stop_ratio, "trailing_stop_ratio")
|
||||
positions = get_positions_frame(client, symbol=symbol)
|
||||
if positions.empty:
|
||||
return {}
|
||||
tick = get_tick_snapshot(client, symbol)
|
||||
bid = extract_tick_price(tick, "bid")
|
||||
ask = extract_tick_price(tick, "ask")
|
||||
digits = _symbol_digits(client, symbol)
|
||||
if digits is None:
|
||||
return {}
|
||||
|
||||
updates: dict[int, float] = {}
|
||||
for row in positions.to_dict("records"):
|
||||
ticket = _position_ticket(row.get("ticket"))
|
||||
if ticket is None:
|
||||
continue
|
||||
next_sl = _trailing_stop_loss(
|
||||
client,
|
||||
position_type=row.get("type"),
|
||||
current_sl=_current_stop_loss(row.get("sl")),
|
||||
bid=bid,
|
||||
ask=ask,
|
||||
digits=digits,
|
||||
trailing_stop_ratio=trailing_stop_ratio,
|
||||
)
|
||||
if next_sl is None:
|
||||
continue
|
||||
updates[ticket] = next_sl
|
||||
return updates
|
||||
|
||||
|
||||
def update_trailing_stop_loss_for_open_positions(
|
||||
client: Mt5TradingClient,
|
||||
*,
|
||||
symbol: str,
|
||||
trailing_stop_ratio: float,
|
||||
dry_run: bool = False,
|
||||
) -> list[OrderExecutionResult]:
|
||||
"""Update open positions whose trailing stop loss should move favorably.
|
||||
|
||||
Returns:
|
||||
Normalized execution results for positions that need an SL update.
|
||||
"""
|
||||
updates = calculate_trailing_stop_updates(
|
||||
client,
|
||||
symbol=symbol,
|
||||
trailing_stop_ratio=trailing_stop_ratio,
|
||||
)
|
||||
results: list[OrderExecutionResult] = []
|
||||
for ticket, stop_loss in updates.items():
|
||||
results.extend(
|
||||
update_sltp_for_open_positions(
|
||||
client,
|
||||
symbol=symbol,
|
||||
tickets=[ticket],
|
||||
stop_loss=stop_loss,
|
||||
dry_run=dry_run,
|
||||
),
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def update_sltp_for_open_positions(
|
||||
client: Mt5TradingClient,
|
||||
*,
|
||||
@@ -997,6 +1517,138 @@ def update_sltp_for_open_positions(
|
||||
return results
|
||||
|
||||
|
||||
def fetch_latest_closed_rates_for_trading_client(
|
||||
client: Mt5TradingClient,
|
||||
*,
|
||||
symbol: str,
|
||||
granularity: str,
|
||||
count: int,
|
||||
) -> pd.DataFrame:
|
||||
"""Fetch the latest closed bars from a connected trading client.
|
||||
|
||||
Returns:
|
||||
Up to ``count`` closed bars ordered oldest to newest.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``count`` is not positive, rate data is empty or
|
||||
malformed, or the ``time`` column is missing.
|
||||
Mt5TradingError: If the trading client cannot fetch rate data.
|
||||
"""
|
||||
if count <= 0:
|
||||
msg = "count must be positive."
|
||||
raise ValueError(msg)
|
||||
fetch_method = getattr(client, "fetch_latest_rates_as_df", None)
|
||||
if not callable(fetch_method):
|
||||
msg = "MT5 trading client cannot fetch rate data."
|
||||
raise Mt5TradingError(msg)
|
||||
fetched = fetch_method(symbol, granularity, count + 1)
|
||||
if not isinstance(fetched, pd.DataFrame):
|
||||
msg = (
|
||||
f"Malformed rate data for {symbol!r} at granularity {granularity!r}: "
|
||||
"expected a DataFrame."
|
||||
)
|
||||
raise ValueError(msg) # noqa: TRY004
|
||||
frame = fetched
|
||||
frame = _ensure_rate_time_column(frame)
|
||||
if "time" not in frame.columns:
|
||||
msg = f"Rate data is missing a time column for {symbol!r}."
|
||||
raise ValueError(msg)
|
||||
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 _rate_time_to_utc(series: pd.Series, symbol: str) -> pd.DatetimeIndex:
|
||||
"""Convert a rate time series to a UTC-aware DatetimeIndex.
|
||||
|
||||
Handles MT5 epoch seconds (including object-dtype Python numbers), timezone-
|
||||
naive datetime-like values, and timezone-aware datetime-like values.
|
||||
|
||||
Returns:
|
||||
UTC-aware DatetimeIndex.
|
||||
|
||||
Raises:
|
||||
ValueError: If the time data is invalid, unparseable, or contains NaT.
|
||||
"""
|
||||
try:
|
||||
arr = series.to_numpy()
|
||||
non_null = series.dropna()
|
||||
object_numbers = (
|
||||
pd.api.types.is_object_dtype(series)
|
||||
and non_null.map(
|
||||
lambda value: type(value) is not bool and isinstance(value, Real),
|
||||
).all()
|
||||
)
|
||||
numeric_dtype = pd.api.types.is_numeric_dtype(
|
||||
series
|
||||
) and not pd.api.types.is_bool_dtype(
|
||||
series,
|
||||
)
|
||||
if numeric_dtype or object_numbers:
|
||||
idx = pd.to_datetime(arr, unit="s", utc=True)
|
||||
else:
|
||||
idx = pd.to_datetime(arr, utc=True)
|
||||
except Exception as exc:
|
||||
msg = f"Rate data for {symbol!r} has invalid or unparseable time data."
|
||||
raise ValueError(msg) from exc
|
||||
if any(idx.isna()):
|
||||
msg = f"Rate data for {symbol!r} contains missing (NaT) timestamp values."
|
||||
raise ValueError(msg)
|
||||
return idx
|
||||
|
||||
|
||||
def fetch_latest_closed_rates_indexed(
|
||||
client: Mt5TradingClient,
|
||||
*,
|
||||
symbol: str,
|
||||
granularity: str,
|
||||
count: int,
|
||||
) -> pd.DataFrame:
|
||||
"""Fetch the latest closed bars with a UTC DatetimeIndex from a trading client.
|
||||
|
||||
Internally reuses :func:`fetch_latest_closed_rates_for_trading_client` for
|
||||
closed-bar detection and validation, then converts the ``time`` column to a
|
||||
UTC-aware :class:`~pandas.DatetimeIndex` named ``"time"`` and drops the
|
||||
original column. Intended for downstream time-series consumers that require
|
||||
a datetime index rather than a ``time`` column.
|
||||
|
||||
Args:
|
||||
client: Connected trading client with rate-fetch capability.
|
||||
symbol: Symbol name.
|
||||
granularity: Timeframe string (for example ``"M1"``, ``"H1"``).
|
||||
count: Maximum number of closed bars to return.
|
||||
|
||||
Returns:
|
||||
Up to ``count`` closed bars ordered oldest to newest, with a
|
||||
UTC-aware ``DatetimeIndex`` named ``"time"``. The original ``time``
|
||||
column is dropped.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``count`` is not positive, rate data is empty or
|
||||
malformed, the ``time`` column is missing, or timestamp data
|
||||
is invalid or unparseable.
|
||||
"""
|
||||
frame = fetch_latest_closed_rates_for_trading_client(
|
||||
client,
|
||||
symbol=symbol,
|
||||
granularity=granularity,
|
||||
count=count,
|
||||
)
|
||||
if "time" not in frame.columns:
|
||||
msg = f"Rate data is missing a time column for {symbol!r}."
|
||||
raise ValueError(msg)
|
||||
idx = _rate_time_to_utc(frame["time"], symbol)
|
||||
idx.name = "time"
|
||||
result = frame.drop(columns=["time"])
|
||||
result.index = idx
|
||||
return result
|
||||
|
||||
|
||||
@contextmanager
|
||||
def mt5_trading_session(
|
||||
config: Mt5Config | None = None,
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mt5cli"
|
||||
version = "0.8.1"
|
||||
version = "0.9.5"
|
||||
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"}]
|
||||
@@ -124,7 +124,6 @@ ignore = [
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"mt5cli/history.py" = ["TC003"]
|
||||
"tests/**/*.py" = [
|
||||
"DOC201", # Missing return documentation
|
||||
"DOC501", # Raised exception missing from docstring
|
||||
|
||||
+150
-4
@@ -2,9 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, get_type_hints
|
||||
from pathlib import Path
|
||||
from typing import get_type_hints
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pandas as pd
|
||||
@@ -15,7 +17,9 @@ from pytest_mock import MockerFixture # noqa: TC002
|
||||
import mt5cli
|
||||
from mt5cli import (
|
||||
DEDUP_KEYS,
|
||||
PUBLIC_EXPORT_TIERS,
|
||||
REQUIRED_COLUMNS,
|
||||
SECONDARY_PUBLIC_EXPORTS,
|
||||
STABLE_SDK_EXPORTS,
|
||||
TIME_COLUMNS,
|
||||
AccountSpec,
|
||||
@@ -33,7 +37,12 @@ from mt5cli import (
|
||||
RateTarget,
|
||||
build_config,
|
||||
build_rate_targets,
|
||||
calculate_account_projected_margin_ratio,
|
||||
calculate_margin_and_volume,
|
||||
calculate_positions_margin,
|
||||
calculate_projected_margin_ratio,
|
||||
calculate_symbol_group_margin_ratio,
|
||||
calculate_trailing_stop_updates,
|
||||
call_with_normalized_errors,
|
||||
detect_format,
|
||||
drop_forming_rate_bar,
|
||||
@@ -41,7 +50,10 @@ from mt5cli import (
|
||||
ensure_utc,
|
||||
export_dataframe,
|
||||
export_dataframe_to_sqlite,
|
||||
extract_tick_price,
|
||||
fetch_latest_closed_rates,
|
||||
fetch_latest_closed_rates_for_trading_client,
|
||||
fetch_latest_closed_rates_indexed,
|
||||
granularity_name,
|
||||
is_recoverable_mt5_error,
|
||||
load_rate_data,
|
||||
@@ -50,6 +62,7 @@ from mt5cli import (
|
||||
mt5_trading_session,
|
||||
normalize_dataframe,
|
||||
normalize_mt5_exception,
|
||||
normalize_order_volume,
|
||||
normalize_symbol,
|
||||
normalize_symbols,
|
||||
parse_date_range,
|
||||
@@ -65,9 +78,6 @@ from mt5cli.history import create_rate_compatibility_views
|
||||
from mt5cli.retry import retry_with_backoff
|
||||
from mt5cli.schemas import ensure_utc_columns, normalize_time_columns
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _sample_frame(kind: DataKind) -> pd.DataFrame:
|
||||
if kind is DataKind.rates:
|
||||
@@ -543,11 +553,69 @@ class TestStableSdkContract:
|
||||
missing = sorted(STABLE_SDK_EXPORTS - set(mt5cli.__all__))
|
||||
assert not missing, f"STABLE_SDK_EXPORTS missing from __all__: {missing}"
|
||||
|
||||
def test_public_export_tiers_are_disjoint_and_complete(self) -> None:
|
||||
"""Documented public tiers do not overlap and classify root exports."""
|
||||
assert PUBLIC_EXPORT_TIERS == {
|
||||
"stable": STABLE_SDK_EXPORTS,
|
||||
"secondary": SECONDARY_PUBLIC_EXPORTS,
|
||||
}
|
||||
assert not (STABLE_SDK_EXPORTS & SECONDARY_PUBLIC_EXPORTS)
|
||||
tiered_exports = STABLE_SDK_EXPORTS | SECONDARY_PUBLIC_EXPORTS
|
||||
root_exports = set(mt5cli.__all__)
|
||||
|
||||
missing_from_root = sorted(tiered_exports - root_exports)
|
||||
assert not missing_from_root, (
|
||||
f"Tiered exports missing from __all__: {missing_from_root}"
|
||||
)
|
||||
|
||||
tier_metadata_exports = {
|
||||
"PUBLIC_EXPORT_TIERS",
|
||||
"SECONDARY_PUBLIC_EXPORTS",
|
||||
"STABLE_SDK_EXPORTS",
|
||||
}
|
||||
unclassified_root_exports = sorted(
|
||||
root_exports - tiered_exports - tier_metadata_exports,
|
||||
)
|
||||
assert not unclassified_root_exports, (
|
||||
f"Root exports missing from public API tiers: {unclassified_root_exports}"
|
||||
)
|
||||
|
||||
def test_stable_docs_do_not_document_nonstable_exports(self) -> None:
|
||||
"""Stable docs do not promote secondary root exports."""
|
||||
docs_path = Path("docs/api/public-contract.md")
|
||||
docs = docs_path.read_text(encoding="utf-8")
|
||||
stable_section = docs.split("## Stable downstream SDK API", maxsplit=1)[
|
||||
1
|
||||
].split(
|
||||
"## Secondary public exports",
|
||||
maxsplit=1,
|
||||
)[0]
|
||||
documented_symbols = set(
|
||||
re.findall(r"`([A-Za-z_][A-Za-z0-9_]*)`", stable_section)
|
||||
)
|
||||
nonstable_exports = SECONDARY_PUBLIC_EXPORTS
|
||||
|
||||
wrongly_stable = sorted(documented_symbols & nonstable_exports)
|
||||
assert not wrongly_stable, (
|
||||
f"Non-stable exports documented in stable section: {wrongly_stable}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(STABLE_SDK_EXPORTS))
|
||||
def test_stable_exports_are_importable_from_package_root(self, name: str) -> None:
|
||||
"""Stable SDK names resolve through ``from mt5cli import ...``."""
|
||||
assert hasattr(mt5cli, name), f"{name!r} missing from mt5cli package root"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
sorted(SECONDARY_PUBLIC_EXPORTS),
|
||||
)
|
||||
def test_secondary_exports_are_importable(
|
||||
self,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Non-stable public names remain available from the package root."""
|
||||
assert hasattr(mt5cli, name), f"{name!r} missing from mt5cli package root"
|
||||
|
||||
def test_drop_forming_rate_bar_from_package_root(self) -> None:
|
||||
"""Closed-bar trimming is available from the stable package surface."""
|
||||
frame = pd.DataFrame({"time": [1, 2, 3], "close": [1.0, 1.1, 1.2]})
|
||||
@@ -572,6 +640,55 @@ class TestStableSdkContract:
|
||||
client.latest_rates.assert_called_once_with("EURUSD", "M1", 3, start_pos=0)
|
||||
assert list(result["close"]) == [1.0, 1.1]
|
||||
|
||||
def test_fetch_latest_closed_rates_for_trading_client_from_package_root(
|
||||
self,
|
||||
) -> None:
|
||||
"""Trading-client closed-bar helper is importable from the stable surface."""
|
||||
client = MagicMock()
|
||||
client.fetch_latest_rates_as_df.return_value = pd.DataFrame(
|
||||
{"time": [1, 2, 3], "close": [1.0, 1.1, 1.2]},
|
||||
)
|
||||
|
||||
result = fetch_latest_closed_rates_for_trading_client(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=2,
|
||||
)
|
||||
|
||||
assert list(result["close"]) == [1.0, 1.1]
|
||||
|
||||
def test_normalize_order_volume_from_package_root(self) -> None:
|
||||
"""Volume normalization helper is importable from the stable surface."""
|
||||
result = normalize_order_volume(
|
||||
0.25,
|
||||
volume_min=0.1,
|
||||
volume_max=1.0,
|
||||
volume_step=0.1,
|
||||
)
|
||||
assert abs(result - 0.2) < 1e-9
|
||||
|
||||
def test_calculate_positions_margin_from_package_root(self) -> None:
|
||||
"""Position margin helper is importable from the stable surface."""
|
||||
client = MagicMock()
|
||||
client.mt5.POSITION_TYPE_BUY = 0
|
||||
client.mt5.POSITION_TYPE_SELL = 1
|
||||
client.mt5.ORDER_TYPE_BUY = 10
|
||||
client.mt5.ORDER_TYPE_SELL = 11
|
||||
client.positions_get_as_df.return_value = pd.DataFrame()
|
||||
|
||||
assert calculate_positions_margin(client) == 0
|
||||
|
||||
def test_generic_trading_helpers_from_package_root(self) -> None:
|
||||
"""New generic trading helpers resolve through the stable surface."""
|
||||
price = extract_tick_price({"bid": "1.2"}, "bid")
|
||||
assert price is not None
|
||||
assert abs(price - 1.2) < 1e-9
|
||||
assert callable(calculate_trailing_stop_updates)
|
||||
assert callable(calculate_account_projected_margin_ratio)
|
||||
assert callable(calculate_projected_margin_ratio)
|
||||
assert callable(calculate_symbol_group_margin_ratio)
|
||||
|
||||
def test_resolve_rate_view_name_from_package_root(self, tmp_path: Path) -> None:
|
||||
"""Rate view resolution is importable and honors require_existing."""
|
||||
db_path = tmp_path / "rates.db"
|
||||
@@ -692,3 +809,32 @@ class TestStableSdkContract:
|
||||
raise RuntimeError(message)
|
||||
|
||||
mock_client.shutdown.assert_called_once()
|
||||
|
||||
def test_fetch_latest_closed_rates_indexed_from_package_root(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Indexed closed-bar helper returns a UTC DatetimeIndex named 'time'."""
|
||||
client = MagicMock()
|
||||
mocker.patch(
|
||||
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
||||
return_value=pd.DataFrame(
|
||||
{
|
||||
"time": [1704067200, 1704153600, 1704240000],
|
||||
"close": [1.0, 1.1, 1.2],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
result = fetch_latest_closed_rates_indexed(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=2,
|
||||
)
|
||||
|
||||
assert isinstance(result.index, pd.DatetimeIndex)
|
||||
assert result.index.name == "time"
|
||||
assert result.index.tz is not None
|
||||
assert "time" not in result.columns
|
||||
assert "close" in result.columns
|
||||
|
||||
@@ -54,6 +54,7 @@ from mt5cli.sdk import (
|
||||
resolve_account_spec,
|
||||
resolve_account_specs,
|
||||
substitute_env_placeholders,
|
||||
substitute_mapping_values,
|
||||
symbol_info,
|
||||
symbol_info_tick,
|
||||
symbols,
|
||||
@@ -1777,6 +1778,80 @@ class TestSubstituteEnvPlaceholders:
|
||||
with pytest.raises(ValueError, match="'MT5_MISSING' is not set"):
|
||||
substitute_env_placeholders("${MT5_MISSING}")
|
||||
|
||||
def test_whole_dollar_not_substituted_by_default(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test $ENV_NAME is not expanded without allow_whole_dollar_env=True."""
|
||||
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||
|
||||
assert substitute_env_placeholders("$MT5_PASSWORD") == "$MT5_PASSWORD"
|
||||
|
||||
def test_whole_dollar_substituted_with_opt_in(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test $ENV_NAME is expanded when allow_whole_dollar_env=True."""
|
||||
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||
|
||||
result = substitute_env_placeholders(
|
||||
"$MT5_PASSWORD", allow_whole_dollar_env=True
|
||||
)
|
||||
|
||||
assert result == "secret"
|
||||
|
||||
def test_whole_dollar_missing_variable_raises_value_error(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test missing $ENV_NAME raises ValueError when opt-in is enabled."""
|
||||
monkeypatch.delenv("MT5_MISSING", raising=False)
|
||||
|
||||
with pytest.raises(ValueError, match="'MT5_MISSING' is not set"):
|
||||
substitute_env_placeholders("$MT5_MISSING", allow_whole_dollar_env=True)
|
||||
|
||||
def test_partial_dollar_not_expanded_with_opt_in(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test $ENV embedded in a larger string is not expanded."""
|
||||
monkeypatch.setenv("pass", "secret")
|
||||
monkeypatch.setenv("ENV", "val")
|
||||
|
||||
assert (
|
||||
substitute_env_placeholders("plan$pass", allow_whole_dollar_env=True)
|
||||
== "plan$pass"
|
||||
)
|
||||
assert (
|
||||
substitute_env_placeholders("abc$ENV", allow_whole_dollar_env=True)
|
||||
== "abc$ENV"
|
||||
)
|
||||
|
||||
def test_dollar_with_suffix_not_expanded_with_opt_in(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test $ENV-suffix is not expanded (not a whole-value placeholder)."""
|
||||
monkeypatch.setenv("ENV", "val")
|
||||
|
||||
assert (
|
||||
substitute_env_placeholders("$ENV-suffix", allow_whole_dollar_env=True)
|
||||
== "$ENV-suffix"
|
||||
)
|
||||
|
||||
def test_brace_format_works_with_opt_in(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test ${ENV_VAR} substitution still works when allow_whole_dollar_env=True."""
|
||||
monkeypatch.setenv("MT5_LOGIN", "12345")
|
||||
|
||||
result = substitute_env_placeholders(
|
||||
"${MT5_LOGIN}", allow_whole_dollar_env=True
|
||||
)
|
||||
|
||||
assert result == "12345"
|
||||
|
||||
|
||||
class TestResolveAccountSpec:
|
||||
"""Tests for resolve_account_spec and resolve_account_specs."""
|
||||
@@ -1863,6 +1938,117 @@ class TestResolveAccountSpec:
|
||||
assert [a.server for a in resolved] == ["Shared", "Fixed"]
|
||||
assert all(a.timeout == 1000 for a in resolved)
|
||||
|
||||
def test_resolve_account_spec_with_whole_dollar_env(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Account spec expands $ENV_NAME when allow_whole_dollar_env=True."""
|
||||
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||
account = AccountSpec(symbols=["EURUSD"], password="$MT5_PASSWORD")
|
||||
|
||||
resolved = resolve_account_spec(account, allow_whole_dollar_env=True)
|
||||
|
||||
assert resolved.password == "secret" # noqa: S105
|
||||
|
||||
def test_resolve_account_spec_whole_dollar_not_expanded_by_default(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test resolve_account_spec leaves $ENV_NAME literal by default."""
|
||||
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||
account = AccountSpec(symbols=["EURUSD"], password="$MT5_PASSWORD")
|
||||
|
||||
resolved = resolve_account_spec(account)
|
||||
|
||||
assert resolved.password == "$MT5_PASSWORD" # noqa: S105
|
||||
|
||||
def test_resolve_account_specs_with_whole_dollar_env(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test resolve_account_specs threads allow_whole_dollar_env to each account."""
|
||||
monkeypatch.setenv("MT5_SERVER", "Broker-Demo")
|
||||
accounts = [
|
||||
AccountSpec(symbols=["EURUSD"], server="$MT5_SERVER"),
|
||||
AccountSpec(symbols=["GBPUSD"], server="Fixed"),
|
||||
]
|
||||
|
||||
resolved = resolve_account_specs(accounts, allow_whole_dollar_env=True)
|
||||
|
||||
assert resolved[0].server == "Broker-Demo"
|
||||
assert resolved[1].server == "Fixed"
|
||||
|
||||
def test_resolve_account_spec_whole_dollar_login(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test $ENV_NAME login string is expanded when allow_whole_dollar_env=True."""
|
||||
monkeypatch.setenv("MT5_LOGIN", "12345")
|
||||
account = AccountSpec(symbols=["EURUSD"], login="$MT5_LOGIN")
|
||||
|
||||
resolved = resolve_account_spec(account, allow_whole_dollar_env=True)
|
||||
|
||||
assert resolved.login == "12345"
|
||||
|
||||
|
||||
class TestBuildConfigWholeDollarEnv:
|
||||
"""Tests for build_config with allow_whole_dollar_env."""
|
||||
|
||||
def test_build_config_substitutes_server_with_opt_in(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""build_config expands $ENV_NAME server when allow_whole_dollar_env=True."""
|
||||
monkeypatch.setenv("MT5_SERVER", "Broker-Demo")
|
||||
|
||||
config = build_config(server="$MT5_SERVER", allow_whole_dollar_env=True)
|
||||
|
||||
assert config.server == "Broker-Demo"
|
||||
|
||||
def test_build_config_substitutes_password_with_opt_in(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""build_config expands $ENV_NAME password when allow_whole_dollar_env=True."""
|
||||
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||
|
||||
config = build_config(password="$MT5_PASSWORD", allow_whole_dollar_env=True)
|
||||
|
||||
assert config.password == "secret" # noqa: S105
|
||||
|
||||
def test_build_config_substitutes_path_with_opt_in(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test build_config expands $ENV_NAME path when allow_whole_dollar_env=True."""
|
||||
monkeypatch.setenv("MT5_PATH", "/opt/mt5/terminal64.exe")
|
||||
|
||||
config = build_config(path="$MT5_PATH", allow_whole_dollar_env=True)
|
||||
|
||||
assert config.path == "/opt/mt5/terminal64.exe"
|
||||
|
||||
def test_build_config_leaves_dollar_literal_by_default(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test build_config does not substitute $ENV without opt-in."""
|
||||
monkeypatch.setenv("MT5_SERVER", "Broker-Demo")
|
||||
|
||||
config = build_config(server="$MT5_SERVER")
|
||||
|
||||
assert config.server == "$MT5_SERVER"
|
||||
|
||||
def test_build_config_none_params_not_substituted(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch, # noqa: ARG002
|
||||
) -> None:
|
||||
"""Test build_config with None params does not raise even with opt-in."""
|
||||
config = build_config(allow_whole_dollar_env=True)
|
||||
|
||||
assert config.server is None
|
||||
assert config.password is None
|
||||
assert config.path is None
|
||||
|
||||
|
||||
class TestThrottledHistoryUpdater:
|
||||
"""Tests for the throttled incremental history updater."""
|
||||
@@ -2100,3 +2286,414 @@ class TestThrottledHistoryUpdater:
|
||||
assert updater.update(MagicMock(), []) is False
|
||||
update.assert_not_called()
|
||||
assert updater.last_update_monotonic is None
|
||||
|
||||
def test_default_update_backend_is_update_history(self) -> None:
|
||||
"""Test the default backend resolves to update_history."""
|
||||
updater = ThrottledHistoryUpdater(output="history.db")
|
||||
assert updater.update_backend is update_history
|
||||
|
||||
def test_falsy_callable_update_backend_is_preserved(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test only None selects the default backend, not falsy callables."""
|
||||
|
||||
class FalsyCallable:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, object]] = []
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return False
|
||||
|
||||
def __call__(self, **kwargs: object) -> None:
|
||||
self.calls.append(kwargs)
|
||||
|
||||
falsy_backend = FalsyCallable()
|
||||
default_backend = mocker.patch("mt5cli.sdk.update_history")
|
||||
updater = ThrottledHistoryUpdater(
|
||||
output="history.db",
|
||||
update_backend=falsy_backend,
|
||||
)
|
||||
|
||||
assert updater.update_backend is falsy_backend
|
||||
client = MagicMock()
|
||||
assert updater.update(client, ["EURUSD"]) is True
|
||||
assert len(falsy_backend.calls) == 1
|
||||
assert falsy_backend.calls[0]["client"] is client
|
||||
assert falsy_backend.calls[0]["symbols"] == ["EURUSD"]
|
||||
default_backend.assert_not_called()
|
||||
|
||||
def test_custom_update_backend_receives_expected_kwargs(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test a custom backend receives update_history keyword arguments."""
|
||||
backend = mocker.Mock()
|
||||
client = MagicMock()
|
||||
updater = ThrottledHistoryUpdater(
|
||||
output="history.db",
|
||||
datasets={Dataset.rates},
|
||||
timeframes=["M1", "H1"],
|
||||
flags="INFO",
|
||||
lookback_hours=12.0,
|
||||
with_views=True,
|
||||
include_account_events=False,
|
||||
update_backend=backend,
|
||||
)
|
||||
|
||||
updater.update(client, ["EURUSD", "GBPUSD"])
|
||||
|
||||
backend.assert_called_once_with(
|
||||
client=client,
|
||||
output="history.db",
|
||||
symbols=["EURUSD", "GBPUSD"],
|
||||
datasets={Dataset.rates},
|
||||
timeframes=["M1", "H1"],
|
||||
flags="INFO",
|
||||
lookback_hours=12.0,
|
||||
with_views=True,
|
||||
include_account_events=False,
|
||||
)
|
||||
|
||||
def test_throttled_calls_do_not_invoke_custom_backend(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test throttled update cycles skip the injected backend."""
|
||||
backend = mocker.Mock()
|
||||
monotonic = mocker.patch("mt5cli.sdk.time.monotonic")
|
||||
monotonic.side_effect = [100.0, 105.0, 200.0, 200.0]
|
||||
client = MagicMock()
|
||||
updater = ThrottledHistoryUpdater(
|
||||
output="history.db",
|
||||
interval_seconds=60,
|
||||
update_backend=backend,
|
||||
)
|
||||
|
||||
assert updater.update(client, ["EURUSD"]) is True
|
||||
assert updater.update(client, ["EURUSD"]) is False
|
||||
assert updater.update(client, ["EURUSD"]) is True
|
||||
assert backend.call_count == 2
|
||||
|
||||
def test_successful_custom_backend_advances_throttle(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test a successful custom backend updates _last_update_monotonic."""
|
||||
backend = mocker.Mock()
|
||||
monotonic = mocker.patch("mt5cli.sdk.time.monotonic", return_value=42.0)
|
||||
updater = ThrottledHistoryUpdater(
|
||||
output="history.db",
|
||||
update_backend=backend,
|
||||
)
|
||||
|
||||
assert updater.update(MagicMock(), ["EURUSD"]) is True
|
||||
assert updater.last_update_monotonic is monotonic.return_value
|
||||
monotonic.assert_called_once()
|
||||
|
||||
def test_failed_custom_backend_does_not_advance_throttle(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test a failing custom backend leaves _last_update_monotonic unchanged."""
|
||||
backend = mocker.Mock(side_effect=Mt5RuntimeError("boom"))
|
||||
updater = ThrottledHistoryUpdater(
|
||||
output="history.db",
|
||||
update_backend=backend,
|
||||
)
|
||||
|
||||
with pytest.raises(Mt5RuntimeError, match="boom"):
|
||||
updater.update(MagicMock(), ["EURUSD"])
|
||||
|
||||
assert updater.last_update_monotonic is None
|
||||
|
||||
def test_custom_backend_suppresses_recoverable_errors_when_requested(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test suppress_errors swallows recoverable custom backend errors."""
|
||||
backend = mocker.Mock(side_effect=Mt5RuntimeError("boom"))
|
||||
updater = ThrottledHistoryUpdater(
|
||||
output="history.db",
|
||||
suppress_errors=True,
|
||||
update_backend=backend,
|
||||
)
|
||||
|
||||
assert updater.update(MagicMock(), ["EURUSD"]) is False
|
||||
assert updater.last_update_monotonic is None
|
||||
|
||||
def test_custom_backend_propagates_errors_when_not_suppressed(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test recoverable custom backend errors propagate by default."""
|
||||
backend = mocker.Mock(side_effect=Mt5RuntimeError("boom"))
|
||||
updater = ThrottledHistoryUpdater(
|
||||
output="history.db",
|
||||
update_backend=backend,
|
||||
)
|
||||
|
||||
with pytest.raises(Mt5RuntimeError, match="boom"):
|
||||
updater.update(MagicMock(), ["EURUSD"])
|
||||
|
||||
assert updater.last_update_monotonic is None
|
||||
|
||||
|
||||
class TestBuildConfigStringLogin:
|
||||
"""Tests for build_config() string login coercion (issue #61)."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("login", "expected"),
|
||||
[
|
||||
(None, None),
|
||||
(12345, 12345),
|
||||
("12345", 12345),
|
||||
(" 12345 ", 12345),
|
||||
("", None),
|
||||
(" ", None),
|
||||
],
|
||||
)
|
||||
def test_coerces_login_from_string(
|
||||
self,
|
||||
login: int | str | None,
|
||||
expected: int | None,
|
||||
) -> None:
|
||||
"""Test build_config coerces string login to int or None."""
|
||||
config = build_config(login=login)
|
||||
assert config.login == expected
|
||||
|
||||
def test_rejects_non_numeric_string_login(self) -> None:
|
||||
"""Test build_config raises ValueError for non-numeric string login."""
|
||||
with pytest.raises(ValueError, match="invalid literal"):
|
||||
build_config(login="abc")
|
||||
|
||||
def test_expands_dollar_brace_login_with_opt_in(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test build_config expands ${MT5_LOGIN} and coerces with opt-in."""
|
||||
monkeypatch.setenv("MT5_LOGIN", "12345")
|
||||
config = build_config(login="${MT5_LOGIN}", allow_whole_dollar_env=True)
|
||||
assert config.login == 12345
|
||||
|
||||
def test_expands_whole_dollar_login_with_opt_in(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test build_config expands $MT5_LOGIN and coerces with opt-in."""
|
||||
monkeypatch.setenv("MT5_LOGIN", "99999")
|
||||
config = build_config(login="$MT5_LOGIN", allow_whole_dollar_env=True)
|
||||
assert config.login == 99999
|
||||
|
||||
def test_missing_env_variable_raises(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test build_config raises ValueError when referenced env var is not set."""
|
||||
monkeypatch.delenv("MT5_LOGIN", raising=False)
|
||||
with pytest.raises(ValueError, match="'MT5_LOGIN' is not set"):
|
||||
build_config(login="${MT5_LOGIN}", allow_whole_dollar_env=True)
|
||||
|
||||
def test_env_expands_to_blank_becomes_none(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test build_config coerces blank env-expanded login to None."""
|
||||
monkeypatch.setenv("MT5_LOGIN", "")
|
||||
config = build_config(login="${MT5_LOGIN}", allow_whole_dollar_env=True)
|
||||
assert config.login is None
|
||||
|
||||
def test_dollar_brace_login_not_expanded_without_opt_in(self) -> None:
|
||||
"""Test ${MT5_LOGIN} is not expanded when allow_whole_dollar_env=False."""
|
||||
with pytest.raises(ValueError, match="invalid literal"):
|
||||
build_config(login="${MT5_LOGIN}")
|
||||
|
||||
def test_integer_login_preserved_backward_compat(self) -> None:
|
||||
"""Test existing int login callers remain backward-compatible."""
|
||||
config = build_config(login=54321)
|
||||
assert config.login == 54321
|
||||
|
||||
def test_none_login_preserved_backward_compat(self) -> None:
|
||||
"""Test existing None login callers remain backward-compatible."""
|
||||
config = build_config(login=None)
|
||||
assert config.login is None
|
||||
|
||||
|
||||
class TestSubstituteMappingValues:
|
||||
"""Tests for substitute_mapping_values() (issue #62)."""
|
||||
|
||||
def test_substitutes_selected_keys_in_flat_dict(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test selected keys are substituted in a flat mapping."""
|
||||
monkeypatch.setenv("MT5_LOGIN", "12345")
|
||||
data: dict[str, object] = {
|
||||
"mt5_login": "${MT5_LOGIN}",
|
||||
"strategy_name": "${MT5_LOGIN}",
|
||||
}
|
||||
result = substitute_mapping_values(data, keys={"mt5_login"})
|
||||
assert result == {"mt5_login": "12345", "strategy_name": "${MT5_LOGIN}"}
|
||||
|
||||
def test_preserves_non_selected_literal_dollar_signs(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test literal dollar signs in non-selected fields are preserved exactly."""
|
||||
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||
data: dict[str, object] = {
|
||||
"mt5_password": "${MT5_PASSWORD}",
|
||||
"notes": "$NOT_EXPANDED",
|
||||
}
|
||||
result = substitute_mapping_values(data, keys={"mt5_password"})
|
||||
assert result == {"mt5_password": "secret", "notes": "$NOT_EXPANDED"}
|
||||
|
||||
def test_nested_dict_traversal_substitutes_selected_keys(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test selected keys inside nested dicts are substituted."""
|
||||
monkeypatch.setenv("MT5_SERVER", "Broker-Demo")
|
||||
data: dict[str, object] = {
|
||||
"outer": {
|
||||
"mt5_server": "${MT5_SERVER}",
|
||||
"other": "${MT5_SERVER}",
|
||||
}
|
||||
}
|
||||
result = substitute_mapping_values(data, keys={"mt5_server"})
|
||||
assert result == {
|
||||
"outer": {"mt5_server": "Broker-Demo", "other": "${MT5_SERVER}"}
|
||||
}
|
||||
|
||||
def test_nested_list_traversal_substitutes_selected_keys(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test selected keys inside list elements are substituted."""
|
||||
monkeypatch.setenv("MT5_LOGIN", "42")
|
||||
data: dict[str, object] = {
|
||||
"accounts": [
|
||||
{"mt5_login": "${MT5_LOGIN}", "name": "${MT5_LOGIN}"},
|
||||
{"mt5_login": "${MT5_LOGIN}", "name": "fixed"},
|
||||
]
|
||||
}
|
||||
result = substitute_mapping_values(data, keys={"mt5_login"})
|
||||
assert result == {
|
||||
"accounts": [
|
||||
{"mt5_login": "42", "name": "${MT5_LOGIN}"},
|
||||
{"mt5_login": "42", "name": "fixed"},
|
||||
]
|
||||
}
|
||||
|
||||
def test_whole_dollar_expanded_with_opt_in(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test $ENV_NAME is expanded when allow_whole_dollar_env=True."""
|
||||
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||
data: dict[str, object] = {"mt5_password": "$MT5_PASSWORD"}
|
||||
result = substitute_mapping_values(
|
||||
data,
|
||||
keys={"mt5_password"},
|
||||
allow_whole_dollar_env=True,
|
||||
)
|
||||
assert result == {"mt5_password": "secret"}
|
||||
|
||||
def test_whole_dollar_not_expanded_by_default(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test $ENV_NAME in a selected key is preserved when opt-in is False."""
|
||||
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||
data: dict[str, object] = {"mt5_password": "$MT5_PASSWORD"}
|
||||
result = substitute_mapping_values(data, keys={"mt5_password"})
|
||||
assert result == {"mt5_password": "$MT5_PASSWORD"}
|
||||
|
||||
def test_blank_string_becomes_none_for_blank_keys(self) -> None:
|
||||
"""Test blank strings are normalised to None for blank_string_keys_as_none."""
|
||||
data: dict[str, object] = {
|
||||
"mt5_login": "",
|
||||
"mt5_password": " ",
|
||||
"other": "",
|
||||
}
|
||||
result = substitute_mapping_values(
|
||||
data,
|
||||
keys=set(),
|
||||
blank_string_keys_as_none={"mt5_login", "mt5_password"},
|
||||
)
|
||||
assert result == {"mt5_login": None, "mt5_password": None, "other": ""}
|
||||
|
||||
def test_env_expanded_blank_becomes_none(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test env-expanded blank string is normalised to None."""
|
||||
monkeypatch.setenv("MT5_LOGIN", "")
|
||||
data: dict[str, object] = {"mt5_login": "${MT5_LOGIN}"}
|
||||
result = substitute_mapping_values(
|
||||
data,
|
||||
keys={"mt5_login"},
|
||||
blank_string_keys_as_none={"mt5_login"},
|
||||
)
|
||||
assert result == {"mt5_login": None}
|
||||
|
||||
def test_missing_env_variable_raises_for_selected_key(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test missing env var for a selected key raises ValueError."""
|
||||
monkeypatch.delenv("MT5_MISSING", raising=False)
|
||||
data: dict[str, object] = {"mt5_login": "${MT5_MISSING}"}
|
||||
with pytest.raises(ValueError, match="'MT5_MISSING' is not set"):
|
||||
substitute_mapping_values(data, keys={"mt5_login"})
|
||||
|
||||
def test_non_string_values_preserved(self) -> None:
|
||||
"""Test non-string values under selected or non-selected keys are preserved."""
|
||||
data: dict[str, object] = {
|
||||
"mt5_login": 12345,
|
||||
"timeout": 5000,
|
||||
"enabled": True,
|
||||
"ratio": 1.5,
|
||||
"nothing": None,
|
||||
}
|
||||
result = substitute_mapping_values(
|
||||
data, keys={"mt5_login", "timeout", "enabled", "ratio", "nothing"}
|
||||
)
|
||||
assert result == data
|
||||
|
||||
def test_caller_supplied_key_set_substitutes_correctly(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test helper works with any caller-supplied key set."""
|
||||
monkeypatch.setenv("APP_LOGIN", "77777")
|
||||
monkeypatch.setenv("APP_PASSWORD", "p4ss")
|
||||
data: dict[str, object] = {
|
||||
"app_login": "${APP_LOGIN}",
|
||||
"app_password": "${APP_PASSWORD}",
|
||||
"unrelated": "${APP_LOGIN}",
|
||||
}
|
||||
credential_keys = {"app_login", "app_password"}
|
||||
result = substitute_mapping_values(data, keys=credential_keys)
|
||||
assert result == {
|
||||
"app_login": "77777",
|
||||
"app_password": "p4ss",
|
||||
"unrelated": "${APP_LOGIN}",
|
||||
}
|
||||
|
||||
def test_scalar_data_returned_unchanged(self) -> None:
|
||||
"""Test a scalar (non-dict, non-list) value is returned as-is."""
|
||||
assert substitute_mapping_values("hello", keys={"x"}) == "hello"
|
||||
assert substitute_mapping_values(42, keys={"x"}) == 42
|
||||
assert substitute_mapping_values(None, keys={"x"}) is None
|
||||
|
||||
def test_tuple_container_not_traversed(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test tuple containers are returned as-is without traversal."""
|
||||
monkeypatch.setenv("MT5_LOGIN", "42")
|
||||
data: dict[str, object] = {"accounts": ({"mt5_login": "${MT5_LOGIN}"},)}
|
||||
result = substitute_mapping_values(data, keys={"mt5_login"})
|
||||
# tuple is returned as-is; inner dict is NOT visited
|
||||
assert result == {"accounts": ({"mt5_login": "${MT5_LOGIN}"},)}
|
||||
|
||||
+1953
-33
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user