Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
|
||||
@@ -249,7 +249,7 @@ 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).
|
||||
- **Throttled history updates**: use `ThrottledHistoryUpdater` to wrap `update_history()` with a minimum `interval_seconds` between successful runs (monotonic clock). Call `should_update()` / `update(client, symbols)` from an application loop; errors propagate by default, or pass `suppress_errors=True` to swallow recoverable `Mt5*Error`, `sqlite3.Error`, `ValueError`, `OSError`, and MT5 client capability errors for history API methods without advancing the throttle (other `AttributeError` / `TypeError` values always propagate). Pass `update_backend` to inject a custom history update callable (same keyword arguments as `update_history`) instead of monkey-patching `mt5cli.sdk.update_history`.
|
||||
- **Trading session helpers**: use `mt5_trading_session()` for a trading-capable `pdmt5.Mt5TradingClient` that initializes/logs in via `Mt5Config.path` and always shuts down safely. Pair with `detect_position_side()`, `calculate_margin_and_volume()`, and `determine_order_limits()` for generic position and sizing utilities. The read-only `mt5_session()` / `Mt5CliClient` SDK is unchanged.
|
||||
- **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.
|
||||
|
||||
+12
-10
@@ -60,6 +60,7 @@ timestamp normalization in downstream apps.
|
||||
| ------------------------------------------------ | ------------------------------------------------------------ |
|
||||
| `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 |
|
||||
| `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` |
|
||||
@@ -67,16 +68,16 @@ timestamp normalization in downstream apps.
|
||||
|
||||
### 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
|
||||
@@ -96,6 +97,7 @@ strategy entries, exits, Kelly sizing, or signal logic.
|
||||
| `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 |
|
||||
| `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) |
|
||||
|
||||
+26
-3
@@ -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 `Mt5CliClient`; 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.
|
||||
|
||||
@@ -113,6 +114,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
|
||||
|
||||
@@ -40,15 +40,19 @@ 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,
|
||||
get_account_snapshot,
|
||||
get_positions_frame,
|
||||
get_symbol_snapshot,
|
||||
get_tick_snapshot,
|
||||
normalize_order_volume,
|
||||
place_market_order,
|
||||
)
|
||||
|
||||
@@ -58,6 +62,22 @@ 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,
|
||||
)
|
||||
sizing = calculate_margin_and_volume(
|
||||
client,
|
||||
"EURUSD",
|
||||
@@ -87,6 +107,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
|
||||
@@ -153,6 +179,9 @@ through the stable package root without embedding entry/exit policy.
|
||||
| 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()` |
|
||||
| Local SL/TP price derivation | `determine_order_limits()` |
|
||||
| Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` |
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ from .trading import (
|
||||
PositionSide,
|
||||
calculate_margin_and_volume,
|
||||
calculate_new_position_margin_ratio,
|
||||
calculate_positions_margin,
|
||||
calculate_spread_ratio,
|
||||
calculate_volume_by_margin,
|
||||
close_open_positions,
|
||||
@@ -125,11 +126,14 @@ from .trading import (
|
||||
detect_position_side,
|
||||
determine_order_limits,
|
||||
ensure_symbol_selected,
|
||||
estimate_order_margin,
|
||||
fetch_latest_closed_rates_for_trading_client,
|
||||
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,
|
||||
)
|
||||
@@ -182,6 +186,7 @@ __all__ = [
|
||||
"build_rate_view_name",
|
||||
"calculate_margin_and_volume",
|
||||
"calculate_new_position_margin_ratio",
|
||||
"calculate_positions_margin",
|
||||
"calculate_spread_ratio",
|
||||
"calculate_volume_by_margin",
|
||||
"call_with_normalized_errors",
|
||||
@@ -204,9 +209,11 @@ __all__ = [
|
||||
"drop_forming_rate_bar",
|
||||
"ensure_symbol_selected",
|
||||
"ensure_utc",
|
||||
"estimate_order_margin",
|
||||
"export_dataframe",
|
||||
"export_dataframe_to_sqlite",
|
||||
"fetch_latest_closed_rates",
|
||||
"fetch_latest_closed_rates_for_trading_client",
|
||||
"get_account_snapshot",
|
||||
"get_positions_frame",
|
||||
"get_symbol_snapshot",
|
||||
@@ -230,6 +237,7 @@ __all__ = [
|
||||
"mt5_version",
|
||||
"normalize_dataframe",
|
||||
"normalize_mt5_exception",
|
||||
"normalize_order_volume",
|
||||
"normalize_symbol",
|
||||
"normalize_symbols",
|
||||
"normalize_time_columns",
|
||||
|
||||
@@ -30,6 +30,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
||||
"build_rate_view_name",
|
||||
"calculate_margin_and_volume",
|
||||
"calculate_new_position_margin_ratio",
|
||||
"calculate_positions_margin",
|
||||
"calculate_spread_ratio",
|
||||
"calculate_volume_by_margin",
|
||||
"call_with_normalized_errors",
|
||||
@@ -50,9 +51,11 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
||||
"determine_order_limits",
|
||||
"drop_forming_rate_bar",
|
||||
"ensure_symbol_selected",
|
||||
"estimate_order_margin",
|
||||
"export_dataframe",
|
||||
"export_dataframe_to_sqlite",
|
||||
"fetch_latest_closed_rates",
|
||||
"fetch_latest_closed_rates_for_trading_client",
|
||||
"get_account_snapshot",
|
||||
"get_positions_frame",
|
||||
"get_symbol_snapshot",
|
||||
@@ -74,6 +77,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
||||
"mt5_trading_session",
|
||||
"mt5_version",
|
||||
"normalize_mt5_exception",
|
||||
"normalize_order_volume",
|
||||
"orders",
|
||||
"place_market_order",
|
||||
"positions",
|
||||
|
||||
+23
-5
@@ -42,6 +42,8 @@ from .utils import coerce_login as _coerce_login
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
|
||||
UpdateHistoryBackend = Callable[..., None]
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1044,10 +1046,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 +1070,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 +1094,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 +1110,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 +1163,7 @@ class ThrottledHistoryUpdater:
|
||||
lookback_hours=self.lookback_hours,
|
||||
date_to=None,
|
||||
)
|
||||
update_history(
|
||||
self.update_backend(
|
||||
client=client,
|
||||
output=self.output,
|
||||
symbols=symbols,
|
||||
|
||||
+206
-1
@@ -10,11 +10,13 @@ from typing import TYPE_CHECKING, Literal, TypedDict, cast
|
||||
import pandas as pd
|
||||
from pdmt5 import Mt5Config, Mt5TradingClient, Mt5TradingError
|
||||
|
||||
from .history import drop_forming_rate_bar
|
||||
from .sdk import build_config
|
||||
from .utils import coerce_login as _coerce_login
|
||||
from .utils import parse_timeframe
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Iterator, Sequence
|
||||
|
||||
PositionSide = Literal["long", "short"]
|
||||
OrderSide = Literal["BUY", "SELL"]
|
||||
@@ -125,6 +127,7 @@ __all__ = [
|
||||
"PositionSide",
|
||||
"calculate_margin_and_volume",
|
||||
"calculate_new_position_margin_ratio",
|
||||
"calculate_positions_margin",
|
||||
"calculate_spread_ratio",
|
||||
"calculate_volume_by_margin",
|
||||
"close_open_positions",
|
||||
@@ -132,11 +135,14 @@ __all__ = [
|
||||
"detect_position_side",
|
||||
"determine_order_limits",
|
||||
"ensure_symbol_selected",
|
||||
"estimate_order_margin",
|
||||
"fetch_latest_closed_rates_for_trading_client",
|
||||
"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",
|
||||
]
|
||||
@@ -276,6 +282,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"}:
|
||||
@@ -531,6 +579,108 @@ 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 = tick["ask"] if side == "BUY" else tick["bid"]
|
||||
if not isinstance(price, int | float) or price <= 0 or not isfinite(price):
|
||||
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, float(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_spread_ratio(client: Mt5TradingClient, symbol: str) -> float:
|
||||
"""Return ``(ask - bid) / ((ask + bid) / 2)`` for the latest tick.
|
||||
|
||||
@@ -997,6 +1147,61 @@ 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 callable(fetch_method):
|
||||
fetched = fetch_method(symbol, granularity, count + 1)
|
||||
else:
|
||||
copy_method = getattr(client, "copy_rates_from_pos_as_df", None)
|
||||
if not callable(copy_method):
|
||||
msg = "MT5 trading client cannot fetch rate data."
|
||||
raise Mt5TradingError(msg)
|
||||
fetched = copy_method(
|
||||
symbol=symbol,
|
||||
timeframe=parse_timeframe(granularity),
|
||||
start_pos=0,
|
||||
count=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)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def mt5_trading_session(
|
||||
config: Mt5Config | None = None,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mt5cli"
|
||||
version = "0.8.1"
|
||||
version = "0.8.3"
|
||||
description = "Generic MT5 data and execution infrastructure for Python applications"
|
||||
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||
|
||||
@@ -34,6 +34,7 @@ from mt5cli import (
|
||||
build_config,
|
||||
build_rate_targets,
|
||||
calculate_margin_and_volume,
|
||||
calculate_positions_margin,
|
||||
call_with_normalized_errors,
|
||||
detect_format,
|
||||
drop_forming_rate_bar,
|
||||
@@ -42,6 +43,7 @@ from mt5cli import (
|
||||
export_dataframe,
|
||||
export_dataframe_to_sqlite,
|
||||
fetch_latest_closed_rates,
|
||||
fetch_latest_closed_rates_for_trading_client,
|
||||
granularity_name,
|
||||
is_recoverable_mt5_error,
|
||||
load_rate_data,
|
||||
@@ -50,6 +52,7 @@ from mt5cli import (
|
||||
mt5_trading_session,
|
||||
normalize_dataframe,
|
||||
normalize_mt5_exception,
|
||||
normalize_order_volume,
|
||||
normalize_symbol,
|
||||
normalize_symbols,
|
||||
parse_date_range,
|
||||
@@ -572,6 +575,45 @@ 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_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"
|
||||
|
||||
@@ -2100,3 +2100,154 @@ 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
|
||||
|
||||
@@ -19,6 +19,7 @@ from mt5cli.trading import (
|
||||
OrderLimits,
|
||||
calculate_margin_and_volume,
|
||||
calculate_new_position_margin_ratio,
|
||||
calculate_positions_margin,
|
||||
calculate_spread_ratio,
|
||||
calculate_volume_by_margin,
|
||||
close_open_positions,
|
||||
@@ -26,11 +27,14 @@ from mt5cli.trading import (
|
||||
detect_position_side,
|
||||
determine_order_limits,
|
||||
ensure_symbol_selected,
|
||||
estimate_order_margin,
|
||||
fetch_latest_closed_rates_for_trading_client,
|
||||
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,
|
||||
)
|
||||
@@ -773,6 +777,474 @@ class TestSnapshotsAndState:
|
||||
calculate_spread_ratio(client, "EURUSD")
|
||||
|
||||
|
||||
class TestNormalizeOrderVolume:
|
||||
"""Tests for normalize_order_volume."""
|
||||
|
||||
def test_returns_exact_minimum_volume(self) -> None:
|
||||
"""Test exact volume_min is returned unchanged."""
|
||||
_assert_close(
|
||||
normalize_order_volume(
|
||||
0.1,
|
||||
volume_min=0.1,
|
||||
volume_max=1.0,
|
||||
volume_step=0.1,
|
||||
),
|
||||
0.1,
|
||||
)
|
||||
|
||||
def test_floors_to_step_between_boundaries(self) -> None:
|
||||
"""Test volume between steps floors down to the nearest valid step."""
|
||||
_assert_close(
|
||||
normalize_order_volume(
|
||||
0.25,
|
||||
volume_min=0.1,
|
||||
volume_max=1.0,
|
||||
volume_step=0.1,
|
||||
),
|
||||
0.2,
|
||||
)
|
||||
|
||||
def test_clamps_to_volume_max(self) -> None:
|
||||
"""Test positive volume_max caps the normalized result."""
|
||||
_assert_close(
|
||||
normalize_order_volume(
|
||||
0.9,
|
||||
volume_min=0.1,
|
||||
volume_max=0.5,
|
||||
volume_step=0.1,
|
||||
),
|
||||
0.5,
|
||||
)
|
||||
|
||||
def test_returns_zero_below_volume_min(self) -> None:
|
||||
"""Test sub-minimum requests return zero volume."""
|
||||
_assert_close(
|
||||
normalize_order_volume(
|
||||
0.05,
|
||||
volume_min=0.1,
|
||||
volume_max=1.0,
|
||||
volume_step=0.1,
|
||||
),
|
||||
0.0,
|
||||
)
|
||||
|
||||
def test_returns_zero_for_invalid_volume_min(self) -> None:
|
||||
"""Test non-positive volume_min returns zero volume."""
|
||||
_assert_close(
|
||||
normalize_order_volume(
|
||||
1.0,
|
||||
volume_min=0.0,
|
||||
volume_max=1.0,
|
||||
volume_step=0.1,
|
||||
),
|
||||
0.0,
|
||||
)
|
||||
|
||||
def test_returns_zero_for_invalid_volume_step(self) -> None:
|
||||
"""Test non-positive volume_step returns zero volume."""
|
||||
_assert_close(
|
||||
normalize_order_volume(
|
||||
1.0,
|
||||
volume_min=0.1,
|
||||
volume_max=1.0,
|
||||
volume_step=0.0,
|
||||
),
|
||||
0.0,
|
||||
)
|
||||
|
||||
def test_treats_non_positive_volume_max_as_no_cap(self) -> None:
|
||||
"""Test volume_max <= 0 disables the maximum cap."""
|
||||
_assert_close(
|
||||
normalize_order_volume(
|
||||
2.5,
|
||||
volume_min=0.1,
|
||||
volume_max=0.0,
|
||||
volume_step=0.1,
|
||||
),
|
||||
2.5,
|
||||
)
|
||||
|
||||
def test_reapplies_volume_max_after_step_normalization(self) -> None:
|
||||
"""Test post-step normalization cannot exceed volume_max."""
|
||||
_assert_close(
|
||||
normalize_order_volume(
|
||||
0.5,
|
||||
volume_min=0.1,
|
||||
volume_max=0.34,
|
||||
volume_step=0.12,
|
||||
),
|
||||
0.34,
|
||||
)
|
||||
|
||||
def test_returns_zero_for_non_finite_volume(self) -> None:
|
||||
"""Test NaN or infinite requested volume returns zero."""
|
||||
_assert_close(
|
||||
normalize_order_volume(
|
||||
float("nan"),
|
||||
volume_min=0.1,
|
||||
volume_max=1.0,
|
||||
volume_step=0.1,
|
||||
),
|
||||
0.0,
|
||||
)
|
||||
_assert_close(
|
||||
normalize_order_volume(
|
||||
float("inf"),
|
||||
volume_min=0.1,
|
||||
volume_max=1.0,
|
||||
volume_step=0.1,
|
||||
),
|
||||
0.0,
|
||||
)
|
||||
|
||||
def test_returns_zero_for_non_finite_constraints(self) -> None:
|
||||
"""Test NaN or infinite volume_min/volume_step returns zero."""
|
||||
_assert_close(
|
||||
normalize_order_volume(
|
||||
1.0,
|
||||
volume_min=float("nan"),
|
||||
volume_max=1.0,
|
||||
volume_step=0.1,
|
||||
),
|
||||
0.0,
|
||||
)
|
||||
_assert_close(
|
||||
normalize_order_volume(
|
||||
1.0,
|
||||
volume_min=0.1,
|
||||
volume_max=1.0,
|
||||
volume_step=float("inf"),
|
||||
),
|
||||
0.0,
|
||||
)
|
||||
|
||||
def test_treats_non_finite_volume_max_as_no_cap(self) -> None:
|
||||
"""Test non-finite volume_max disables the maximum cap."""
|
||||
_assert_close(
|
||||
normalize_order_volume(
|
||||
2.5,
|
||||
volume_min=0.1,
|
||||
volume_max=float("nan"),
|
||||
volume_step=0.1,
|
||||
),
|
||||
2.5,
|
||||
)
|
||||
|
||||
|
||||
class TestEstimateOrderMargin:
|
||||
"""Tests for estimate_order_margin."""
|
||||
|
||||
def test_estimates_buy_margin_at_ask(self) -> None:
|
||||
"""Test buy margin uses ask price and buy order type."""
|
||||
client = _mock_trade_client()
|
||||
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
|
||||
client.order_calc_margin.return_value = 12.5
|
||||
|
||||
margin = estimate_order_margin(client, "EURUSD", "BUY", 0.1)
|
||||
|
||||
_assert_close(margin, 12.5)
|
||||
client.order_calc_margin.assert_called_once_with(10, "EURUSD", 0.1, 1.1010)
|
||||
|
||||
def test_estimates_sell_margin_at_bid(self) -> None:
|
||||
"""Test sell margin uses bid price and sell order type."""
|
||||
client = _mock_trade_client()
|
||||
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
|
||||
client.order_calc_margin.return_value = 12.4
|
||||
|
||||
margin = estimate_order_margin(client, "EURUSD", "SELL", 0.1)
|
||||
|
||||
_assert_close(margin, 12.4)
|
||||
client.order_calc_margin.assert_called_once_with(11, "EURUSD", 0.1, 1.1000)
|
||||
|
||||
def test_accepts_long_and_short_aliases(self) -> None:
|
||||
"""Test long/short aliases normalize to buy/sell pricing."""
|
||||
client = _mock_trade_client()
|
||||
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
|
||||
client.order_calc_margin.side_effect = [12.5, 12.4]
|
||||
|
||||
estimate_order_margin(client, "EURUSD", "long", 0.1)
|
||||
estimate_order_margin(client, "EURUSD", "short", 0.1)
|
||||
|
||||
client.order_calc_margin.assert_any_call(10, "EURUSD", 0.1, 1.1010)
|
||||
client.order_calc_margin.assert_any_call(11, "EURUSD", 0.1, 1.1000)
|
||||
|
||||
def test_rejects_invalid_side(self) -> None:
|
||||
"""Test unsupported order side raises ValueError."""
|
||||
client = _mock_trade_client()
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported order side"):
|
||||
estimate_order_margin(client, "EURUSD", "HOLD", 0.1)
|
||||
|
||||
def test_rejects_non_positive_volume(self) -> None:
|
||||
"""Test non-positive volume raises Mt5TradingError."""
|
||||
client = _mock_trade_client()
|
||||
|
||||
with pytest.raises(Mt5TradingError, match="positive finite number"):
|
||||
estimate_order_margin(client, "EURUSD", "BUY", 0.0)
|
||||
|
||||
def test_rejects_nan_volume(self) -> None:
|
||||
"""Test NaN volume raises Mt5TradingError without broker calls."""
|
||||
client = _mock_trade_client()
|
||||
|
||||
with pytest.raises(Mt5TradingError, match="positive finite number"):
|
||||
estimate_order_margin(client, "EURUSD", "BUY", float("nan"))
|
||||
|
||||
client.symbol_info_tick_as_dict.assert_not_called()
|
||||
client.order_calc_margin.assert_not_called()
|
||||
|
||||
def test_rejects_infinite_volume(self) -> None:
|
||||
"""Test infinite volume raises Mt5TradingError without broker calls."""
|
||||
client = _mock_trade_client()
|
||||
|
||||
with pytest.raises(Mt5TradingError, match="positive finite number"):
|
||||
estimate_order_margin(client, "EURUSD", "BUY", float("inf"))
|
||||
|
||||
client.symbol_info_tick_as_dict.assert_not_called()
|
||||
client.order_calc_margin.assert_not_called()
|
||||
|
||||
def test_rejects_missing_tick_prices(self) -> None:
|
||||
"""Test missing tick prices raise Mt5TradingError."""
|
||||
client = _mock_trade_client()
|
||||
client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.1000}
|
||||
|
||||
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"):
|
||||
estimate_order_margin(client, "EURUSD", "BUY", 0.1)
|
||||
|
||||
def test_rejects_non_positive_tick_price(self) -> None:
|
||||
"""Test non-positive tick prices raise Mt5TradingError."""
|
||||
client = _mock_trade_client()
|
||||
client.symbol_info_tick_as_dict.return_value = {"ask": 0.0, "bid": 1.1000}
|
||||
|
||||
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"):
|
||||
estimate_order_margin(client, "EURUSD", "BUY", 0.1)
|
||||
|
||||
def test_rejects_non_finite_tick_price(self) -> None:
|
||||
"""Test non-finite tick prices raise Mt5TradingError."""
|
||||
client = _mock_trade_client()
|
||||
client.symbol_info_tick_as_dict.return_value = {
|
||||
"ask": float("inf"),
|
||||
"bid": 1.1000,
|
||||
}
|
||||
|
||||
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"):
|
||||
estimate_order_margin(client, "EURUSD", "BUY", 0.1)
|
||||
|
||||
def test_rejects_invalid_margin_result(self) -> None:
|
||||
"""Test non-positive margin estimates raise Mt5TradingError."""
|
||||
client = _mock_trade_client()
|
||||
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
|
||||
client.order_calc_margin.return_value = 0.0
|
||||
|
||||
with pytest.raises(Mt5TradingError, match="Margin estimate is invalid"):
|
||||
estimate_order_margin(client, "EURUSD", "BUY", 0.1)
|
||||
|
||||
def test_rejects_non_finite_margin_result(self) -> None:
|
||||
"""Test non-finite margin estimates raise Mt5TradingError."""
|
||||
client = _mock_trade_client()
|
||||
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
|
||||
client.order_calc_margin.return_value = float("inf")
|
||||
|
||||
with pytest.raises(Mt5TradingError, match="Margin estimate is invalid"):
|
||||
estimate_order_margin(client, "EURUSD", "BUY", 0.1)
|
||||
|
||||
def test_rejects_none_margin_result(self) -> None:
|
||||
"""Test None margin results raise Mt5TradingError."""
|
||||
client = _mock_trade_client()
|
||||
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
|
||||
client.order_calc_margin.return_value = None
|
||||
|
||||
with pytest.raises(Mt5TradingError, match="Margin estimate is invalid"):
|
||||
estimate_order_margin(client, "EURUSD", "BUY", 0.1)
|
||||
|
||||
def test_rejects_non_numeric_margin_result(self) -> None:
|
||||
"""Test non-numeric margin results raise Mt5TradingError."""
|
||||
client = _mock_trade_client()
|
||||
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
|
||||
client.order_calc_margin.return_value = "invalid"
|
||||
|
||||
with pytest.raises(Mt5TradingError, match="Margin estimate is invalid"):
|
||||
estimate_order_margin(client, "EURUSD", "BUY", 0.1)
|
||||
|
||||
|
||||
class TestCalculatePositionsMargin:
|
||||
"""Tests for calculate_positions_margin."""
|
||||
|
||||
def test_returns_zero_for_empty_positions(self) -> None:
|
||||
"""Test empty positions return zero total margin."""
|
||||
client = _mock_trade_client()
|
||||
client.positions_get_as_df.return_value = pd.DataFrame()
|
||||
|
||||
_assert_close(calculate_positions_margin(client), 0.0)
|
||||
|
||||
def test_filters_by_symbols(self) -> None:
|
||||
"""Test optional symbol filter limits summed positions."""
|
||||
client = _mock_trade_client()
|
||||
client.positions_get_as_df.return_value = pd.DataFrame(
|
||||
[
|
||||
{"symbol": "EURUSD", "type": 0, "volume": 0.1},
|
||||
{"symbol": "USDJPY", "type": 1, "volume": 0.2},
|
||||
],
|
||||
)
|
||||
client.symbol_info_tick_as_dict.side_effect = [
|
||||
{"ask": 1.1010, "bid": 1.1000},
|
||||
{"ask": 110.0, "bid": 109.0},
|
||||
]
|
||||
client.order_calc_margin.side_effect = [12.5, 20.0]
|
||||
|
||||
margin = calculate_positions_margin(client, symbols=["EURUSD"])
|
||||
|
||||
_assert_close(margin, 12.5)
|
||||
assert client.order_calc_margin.call_count == 1
|
||||
|
||||
def test_sums_mixed_buy_and_sell_positions(self) -> None:
|
||||
"""Test mixed buy/sell exposure sums each side independently."""
|
||||
client = _mock_trade_client()
|
||||
client.positions_get_as_df.return_value = pd.DataFrame(
|
||||
[
|
||||
{"symbol": "EURUSD", "type": 0, "volume": 0.1},
|
||||
{"symbol": "EURUSD", "type": 1, "volume": 0.2},
|
||||
],
|
||||
)
|
||||
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
|
||||
client.order_calc_margin.side_effect = [12.5, 24.8]
|
||||
|
||||
margin = calculate_positions_margin(client)
|
||||
|
||||
_assert_close(margin, 37.3)
|
||||
|
||||
def test_groups_positions_by_symbol_and_side(self) -> None:
|
||||
"""Test repeated symbol/side pairs use one margin call with summed volume."""
|
||||
client = _mock_trade_client()
|
||||
client.positions_get_as_df.return_value = pd.DataFrame(
|
||||
[
|
||||
{"symbol": "EURUSD", "type": 0, "volume": 0.1},
|
||||
{"symbol": "EURUSD", "type": 0, "volume": 0.2},
|
||||
],
|
||||
)
|
||||
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
|
||||
client.order_calc_margin.return_value = 37.5
|
||||
|
||||
margin = calculate_positions_margin(client)
|
||||
|
||||
_assert_close(margin, 37.5)
|
||||
client.order_calc_margin.assert_called_once()
|
||||
args = client.order_calc_margin.call_args[0]
|
||||
assert args[0] == 10
|
||||
assert args[1] == "EURUSD"
|
||||
_assert_close(args[2], 0.3)
|
||||
_assert_close(args[3], 1.1010)
|
||||
|
||||
def test_sums_multiple_symbols(self) -> None:
|
||||
"""Test positions across symbols are all included."""
|
||||
client = _mock_trade_client()
|
||||
client.positions_get_as_df.return_value = pd.DataFrame(
|
||||
[
|
||||
{"symbol": "EURUSD", "type": 0, "volume": 0.1},
|
||||
{"symbol": "GBPUSD", "type": 1, "volume": 0.3},
|
||||
],
|
||||
)
|
||||
client.symbol_info_tick_as_dict.side_effect = [
|
||||
{"ask": 1.1010, "bid": 1.1000},
|
||||
{"ask": 1.3010, "bid": 1.3000},
|
||||
]
|
||||
client.order_calc_margin.side_effect = [12.5, 30.0]
|
||||
|
||||
margin = calculate_positions_margin(client)
|
||||
|
||||
_assert_close(margin, 42.5)
|
||||
|
||||
def test_propagates_invalid_tick_or_margin_errors(self) -> None:
|
||||
"""Test invalid tick or margin data raises Mt5TradingError."""
|
||||
client = _mock_trade_client()
|
||||
client.positions_get_as_df.return_value = pd.DataFrame(
|
||||
[{"symbol": "EURUSD", "type": 0, "volume": 0.1}],
|
||||
)
|
||||
client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.1000}
|
||||
|
||||
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"):
|
||||
calculate_positions_margin(client)
|
||||
|
||||
def test_skips_rows_with_invalid_symbol_volume_or_type(self) -> None:
|
||||
"""Test malformed position rows are ignored when summing margin."""
|
||||
client = _mock_trade_client()
|
||||
client.positions_get_as_df.return_value = pd.DataFrame(
|
||||
[
|
||||
{"symbol": "", "type": 0, "volume": 0.1},
|
||||
{"symbol": "EURUSD", "type": 0, "volume": 0.0},
|
||||
{"symbol": "EURUSD", "type": 2, "volume": 0.1},
|
||||
{"symbol": "EURUSD", "type": 0, "volume": 0.1},
|
||||
],
|
||||
)
|
||||
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
|
||||
client.order_calc_margin.return_value = 12.5
|
||||
|
||||
margin = calculate_positions_margin(client)
|
||||
|
||||
_assert_close(margin, 12.5)
|
||||
client.order_calc_margin.assert_called_once()
|
||||
|
||||
def test_skips_rows_with_non_finite_volume(self) -> None:
|
||||
"""Test NaN and infinite position volumes are ignored."""
|
||||
client = _mock_trade_client()
|
||||
client.positions_get_as_df.return_value = pd.DataFrame(
|
||||
[
|
||||
{"symbol": "EURUSD", "type": 0, "volume": float("nan")},
|
||||
{"symbol": "EURUSD", "type": 0, "volume": float("inf")},
|
||||
{"symbol": "EURUSD", "type": 0, "volume": 0.1},
|
||||
],
|
||||
)
|
||||
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
|
||||
client.order_calc_margin.return_value = 12.5
|
||||
|
||||
margin = calculate_positions_margin(client)
|
||||
|
||||
_assert_close(margin, 12.5)
|
||||
client.order_calc_margin.assert_called_once()
|
||||
|
||||
def test_returns_zero_when_all_volumes_are_non_finite(self) -> None:
|
||||
"""Test all-invalid non-finite volumes return zero without broker calls."""
|
||||
client = _mock_trade_client()
|
||||
client.positions_get_as_df.return_value = pd.DataFrame(
|
||||
[
|
||||
{"symbol": "EURUSD", "type": 0, "volume": float("nan")},
|
||||
{"symbol": "EURUSD", "type": 1, "volume": float("inf")},
|
||||
],
|
||||
)
|
||||
|
||||
_assert_close(calculate_positions_margin(client), 0.0)
|
||||
client.order_calc_margin.assert_not_called()
|
||||
client.symbol_info_tick_as_dict.assert_not_called()
|
||||
|
||||
def test_returns_zero_when_symbol_filter_matches_nothing(self) -> None:
|
||||
"""Test filtered symbol lists with no matches return zero."""
|
||||
client = _mock_trade_client()
|
||||
client.positions_get_as_df.return_value = pd.DataFrame(
|
||||
[{"symbol": "EURUSD", "type": 0, "volume": 0.1}],
|
||||
)
|
||||
|
||||
_assert_close(calculate_positions_margin(client, symbols=["GBPUSD"]), 0.0)
|
||||
client.order_calc_margin.assert_not_called()
|
||||
|
||||
def test_returns_zero_for_empty_positions_with_symbol_filter(self) -> None:
|
||||
"""Test empty positions with a symbol filter return zero."""
|
||||
client = _mock_trade_client()
|
||||
client.positions_get_as_df.return_value = pd.DataFrame()
|
||||
|
||||
_assert_close(calculate_positions_margin(client, symbols=["EURUSD"]), 0.0)
|
||||
|
||||
def test_returns_zero_for_positions_without_symbol_column_with_symbol_filter(
|
||||
self,
|
||||
) -> None:
|
||||
"""Test positions missing a symbol column return zero when filtered."""
|
||||
client = _mock_trade_client()
|
||||
client.positions_get_as_df.return_value = pd.DataFrame(
|
||||
[{"type": 0, "volume": 0.1}],
|
||||
)
|
||||
|
||||
_assert_close(calculate_positions_margin(client, symbols=["EURUSD"]), 0.0)
|
||||
client.order_calc_margin.assert_not_called()
|
||||
|
||||
|
||||
class TestVolumeAndExecution:
|
||||
"""Tests for order planning and execution helpers."""
|
||||
|
||||
@@ -1928,3 +2400,230 @@ class TestVolumeAndExecution:
|
||||
raise RuntimeError(body_error)
|
||||
|
||||
mock_client.shutdown.assert_called_once()
|
||||
|
||||
|
||||
class TestFetchLatestClosedRatesForTradingClient:
|
||||
"""Tests for fetch_latest_closed_rates_for_trading_client."""
|
||||
|
||||
def test_fetches_extra_bar_and_drops_forming_row(self) -> None:
|
||||
"""Test trading-client helper hides the forming bar."""
|
||||
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,
|
||||
)
|
||||
|
||||
client.fetch_latest_rates_as_df.assert_called_once_with("EURUSD", "M1", 3)
|
||||
assert list(result["close"]) == [1.0, 1.1]
|
||||
assert list(result["time"]) == [1, 2]
|
||||
|
||||
def test_falls_back_to_copy_rates_from_pos_as_df(self) -> None:
|
||||
"""Test legacy trading clients without fetch helper still work."""
|
||||
client = MagicMock(spec=["copy_rates_from_pos_as_df", "mt5"])
|
||||
del client.fetch_latest_rates_as_df
|
||||
client.copy_rates_from_pos_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,
|
||||
)
|
||||
|
||||
client.copy_rates_from_pos_as_df.assert_called_once_with(
|
||||
symbol="EURUSD",
|
||||
timeframe=1,
|
||||
start_pos=0,
|
||||
count=3,
|
||||
)
|
||||
assert list(result["close"]) == [1.0, 1.1]
|
||||
|
||||
def test_accepts_numeric_epoch_timestamps(self) -> None:
|
||||
"""Test numeric epoch timestamps are preserved in output."""
|
||||
client = MagicMock()
|
||||
client.fetch_latest_rates_as_df.return_value = pd.DataFrame(
|
||||
{
|
||||
"time": [1700000000, 1700000060, 1700000120],
|
||||
"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["time"]) == [1700000000, 1700000060]
|
||||
|
||||
def test_accepts_timezone_aware_timestamps_from_index(self) -> None:
|
||||
"""Test timezone-aware timestamps in the index are exposed as a column."""
|
||||
client = MagicMock()
|
||||
frame = pd.DataFrame(
|
||||
{
|
||||
"close": [1.0, 1.1, 1.2],
|
||||
},
|
||||
index=pd.to_datetime(
|
||||
[
|
||||
"2024-01-01T00:00:00Z",
|
||||
"2024-01-01T00:01:00Z",
|
||||
"2024-01-01T00:02:00Z",
|
||||
],
|
||||
utc=True,
|
||||
),
|
||||
)
|
||||
frame.index.name = "time"
|
||||
client.fetch_latest_rates_as_df.return_value = frame
|
||||
|
||||
result = fetch_latest_closed_rates_for_trading_client(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=2,
|
||||
)
|
||||
|
||||
assert "time" in result.columns
|
||||
assert len(result) == 2
|
||||
assert result["close"].tolist() == [1.0, 1.1]
|
||||
|
||||
def test_accepts_unnamed_datetime_index(self) -> None:
|
||||
"""Test unnamed DatetimeIndex values are exposed as a time column."""
|
||||
client = MagicMock()
|
||||
frame = pd.DataFrame(
|
||||
{"close": [1.0, 1.1, 1.2]},
|
||||
index=pd.to_datetime(
|
||||
[
|
||||
"2024-01-01T00:00:00Z",
|
||||
"2024-01-01T00:01:00Z",
|
||||
"2024-01-01T00:02:00Z",
|
||||
],
|
||||
utc=True,
|
||||
),
|
||||
)
|
||||
client.fetch_latest_rates_as_df.return_value = frame
|
||||
|
||||
result = fetch_latest_closed_rates_for_trading_client(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=2,
|
||||
)
|
||||
|
||||
assert "time" in result.columns
|
||||
assert len(result) == 2
|
||||
|
||||
def test_accepts_named_non_time_index(self) -> None:
|
||||
"""Test non-time named indexes are left unchanged before validation."""
|
||||
client = MagicMock()
|
||||
frame = pd.DataFrame(
|
||||
{
|
||||
"close": [1.0, 1.1, 1.2],
|
||||
"bar_id": [1, 2, 3],
|
||||
},
|
||||
).set_index("bar_id")
|
||||
client.fetch_latest_rates_as_df.return_value = frame
|
||||
|
||||
with pytest.raises(ValueError, match="missing a time column"):
|
||||
fetch_latest_closed_rates_for_trading_client(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=2,
|
||||
)
|
||||
|
||||
def test_raises_when_trading_client_cannot_fetch_rates(self) -> None:
|
||||
"""Test missing rate-fetch methods raise Mt5TradingError."""
|
||||
client = MagicMock(spec=[])
|
||||
|
||||
with pytest.raises(Mt5TradingError, match="cannot fetch rate data"):
|
||||
fetch_latest_closed_rates_for_trading_client(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=1,
|
||||
)
|
||||
|
||||
def test_raises_when_time_column_is_missing(self) -> None:
|
||||
"""Test malformed rate data without time raises ValueError."""
|
||||
client = MagicMock()
|
||||
client.fetch_latest_rates_as_df.return_value = pd.DataFrame(
|
||||
{"close": [1.0, 1.1, 1.2]},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="missing a time column"):
|
||||
fetch_latest_closed_rates_for_trading_client(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=2,
|
||||
)
|
||||
|
||||
def test_raises_when_no_closed_bars_are_available(self) -> None:
|
||||
"""Test empty closed-bar results raise an actionable ValueError."""
|
||||
client = MagicMock()
|
||||
client.fetch_latest_rates_as_df.return_value = pd.DataFrame(
|
||||
{"time": [1], "close": [1.0]},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Rate data is empty"):
|
||||
fetch_latest_closed_rates_for_trading_client(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=1,
|
||||
)
|
||||
|
||||
def test_raises_when_fetch_returns_none(self) -> None:
|
||||
"""Test None fetch results raise a malformed rate data error."""
|
||||
client = MagicMock()
|
||||
client.fetch_latest_rates_as_df.return_value = None
|
||||
|
||||
with pytest.raises(ValueError, match="Malformed rate data"):
|
||||
fetch_latest_closed_rates_for_trading_client(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=2,
|
||||
)
|
||||
|
||||
def test_raises_when_fetch_returns_non_dataframe(self) -> None:
|
||||
"""Test non-DataFrame fetch results raise a malformed rate data error."""
|
||||
client = MagicMock()
|
||||
client.fetch_latest_rates_as_df.return_value = [{"time": 1, "close": 1.0}]
|
||||
|
||||
with pytest.raises(ValueError, match="Malformed rate data"):
|
||||
fetch_latest_closed_rates_for_trading_client(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=2,
|
||||
)
|
||||
|
||||
def test_rejects_non_positive_count_before_fetching(self) -> None:
|
||||
"""Test invalid count values fail before calling MT5."""
|
||||
client = MagicMock()
|
||||
|
||||
with pytest.raises(ValueError, match="count must be positive"):
|
||||
fetch_latest_closed_rates_for_trading_client(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=0,
|
||||
)
|
||||
|
||||
client.fetch_latest_rates_as_df.assert_not_called()
|
||||
|
||||
Reference in New Issue
Block a user