mt5cli 1.1.4__tar.gz → 1.1.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. {mt5cli-1.1.4 → mt5cli-1.1.5}/AGENTS.md +1 -1
  2. {mt5cli-1.1.4 → mt5cli-1.1.5}/PKG-INFO +1 -1
  3. {mt5cli-1.1.4 → mt5cli-1.1.5}/docs/api/history.md +32 -1
  4. {mt5cli-1.1.4 → mt5cli-1.1.5}/docs/api/public-contract.md +1 -0
  5. {mt5cli-1.1.4 → mt5cli-1.1.5}/docs/api/sdk.md +2 -2
  6. {mt5cli-1.1.4 → mt5cli-1.1.5}/docs/api/trading.md +40 -0
  7. {mt5cli-1.1.4 → mt5cli-1.1.5}/docs/index.md +14 -14
  8. {mt5cli-1.1.4 → mt5cli-1.1.5}/mt5cli/__init__.py +2 -0
  9. {mt5cli-1.1.4 → mt5cli-1.1.5}/mt5cli/cli.py +20 -6
  10. {mt5cli-1.1.4 → mt5cli-1.1.5}/mt5cli/contract.py +1 -0
  11. {mt5cli-1.1.4 → mt5cli-1.1.5}/mt5cli/history.py +116 -10
  12. {mt5cli-1.1.4 → mt5cli-1.1.5}/mt5cli/schemas.py +15 -0
  13. {mt5cli-1.1.4 → mt5cli-1.1.5}/mt5cli/sdk.py +27 -7
  14. {mt5cli-1.1.4 → mt5cli-1.1.5}/mt5cli/trading.py +96 -1
  15. {mt5cli-1.1.4 → mt5cli-1.1.5}/mt5cli/utils.py +1 -0
  16. {mt5cli-1.1.4 → mt5cli-1.1.5}/pyproject.toml +1 -1
  17. {mt5cli-1.1.4 → mt5cli-1.1.5}/tests/test_cli.py +86 -0
  18. {mt5cli-1.1.4 → mt5cli-1.1.5}/tests/test_contracts.py +69 -57
  19. {mt5cli-1.1.4 → mt5cli-1.1.5}/tests/test_history.py +265 -4
  20. {mt5cli-1.1.4 → mt5cli-1.1.5}/tests/test_sdk.py +101 -0
  21. {mt5cli-1.1.4 → mt5cli-1.1.5}/tests/test_trading.py +192 -0
  22. {mt5cli-1.1.4 → mt5cli-1.1.5}/tests/test_utils.py +1 -0
  23. {mt5cli-1.1.4 → mt5cli-1.1.5}/uv.lock +1 -1
  24. {mt5cli-1.1.4 → mt5cli-1.1.5}/.agents/skills/local-qa/SKILL.md +0 -0
  25. {mt5cli-1.1.4 → mt5cli-1.1.5}/.agents/skills/local-qa/scripts/qa.sh +0 -0
  26. {mt5cli-1.1.4 → mt5cli-1.1.5}/.agents/skills/mt5cli/SKILL.md +0 -0
  27. {mt5cli-1.1.4 → mt5cli-1.1.5}/.agents/skills/pr-feedback-triage/SKILL.md +0 -0
  28. {mt5cli-1.1.4 → mt5cli-1.1.5}/.claude/agents/codex.md +0 -0
  29. {mt5cli-1.1.4 → mt5cli-1.1.5}/.claude/settings.json +0 -0
  30. {mt5cli-1.1.4 → mt5cli-1.1.5}/.github/FUNDING.yml +0 -0
  31. {mt5cli-1.1.4 → mt5cli-1.1.5}/.github/dependabot.yml +0 -0
  32. {mt5cli-1.1.4 → mt5cli-1.1.5}/.github/renovate.json +0 -0
  33. {mt5cli-1.1.4 → mt5cli-1.1.5}/.github/workflows/ci.yml +0 -0
  34. {mt5cli-1.1.4 → mt5cli-1.1.5}/.github/workflows/claude.yml +0 -0
  35. {mt5cli-1.1.4 → mt5cli-1.1.5}/.github/workflows/opencode.yml +0 -0
  36. {mt5cli-1.1.4 → mt5cli-1.1.5}/.github/workflows/release.yml +0 -0
  37. {mt5cli-1.1.4 → mt5cli-1.1.5}/.gitignore +0 -0
  38. {mt5cli-1.1.4 → mt5cli-1.1.5}/CLAUDE.md +0 -0
  39. {mt5cli-1.1.4 → mt5cli-1.1.5}/LICENSE +0 -0
  40. {mt5cli-1.1.4 → mt5cli-1.1.5}/README.md +0 -0
  41. {mt5cli-1.1.4 → mt5cli-1.1.5}/docs/api/cli.md +0 -0
  42. {mt5cli-1.1.4 → mt5cli-1.1.5}/docs/api/client.md +0 -0
  43. {mt5cli-1.1.4 → mt5cli-1.1.5}/docs/api/converters.md +0 -0
  44. {mt5cli-1.1.4 → mt5cli-1.1.5}/docs/api/exceptions.md +0 -0
  45. {mt5cli-1.1.4 → mt5cli-1.1.5}/docs/api/grafana.md +0 -0
  46. {mt5cli-1.1.4 → mt5cli-1.1.5}/docs/api/index.md +0 -0
  47. {mt5cli-1.1.4 → mt5cli-1.1.5}/docs/api/schemas.md +0 -0
  48. {mt5cli-1.1.4 → mt5cli-1.1.5}/docs/api/telemetry.md +0 -0
  49. {mt5cli-1.1.4 → mt5cli-1.1.5}/docs/api/utils.md +0 -0
  50. {mt5cli-1.1.4 → mt5cli-1.1.5}/examples/grafana/README.md +0 -0
  51. {mt5cli-1.1.4 → mt5cli-1.1.5}/examples/grafana/compose.yml +0 -0
  52. {mt5cli-1.1.4 → mt5cli-1.1.5}/examples/grafana/dashboards/mt5cli-market.json +0 -0
  53. {mt5cli-1.1.4 → mt5cli-1.1.5}/examples/grafana/dashboards/mt5cli-overview.json +0 -0
  54. {mt5cli-1.1.4 → mt5cli-1.1.5}/examples/grafana/dashboards/mt5cli-trades.json +0 -0
  55. {mt5cli-1.1.4 → mt5cli-1.1.5}/examples/grafana/provisioning/dashboards/mt5cli.yml +0 -0
  56. {mt5cli-1.1.4 → mt5cli-1.1.5}/examples/grafana/provisioning/datasources/mt5cli-sqlite.yml +0 -0
  57. {mt5cli-1.1.4 → mt5cli-1.1.5}/mkdocs.yml +0 -0
  58. {mt5cli-1.1.4 → mt5cli-1.1.5}/mt5cli/__main__.py +0 -0
  59. {mt5cli-1.1.4 → mt5cli-1.1.5}/mt5cli/client.py +0 -0
  60. {mt5cli-1.1.4 → mt5cli-1.1.5}/mt5cli/converters.py +0 -0
  61. {mt5cli-1.1.4 → mt5cli-1.1.5}/mt5cli/exceptions.py +0 -0
  62. {mt5cli-1.1.4 → mt5cli-1.1.5}/mt5cli/grafana.py +0 -0
  63. {mt5cli-1.1.4 → mt5cli-1.1.5}/mt5cli/retry.py +0 -0
  64. {mt5cli-1.1.4 → mt5cli-1.1.5}/mt5cli/telemetry.py +0 -0
  65. {mt5cli-1.1.4 → mt5cli-1.1.5}/tests/__init__.py +0 -0
  66. {mt5cli-1.1.4 → mt5cli-1.1.5}/tests/conftest.py +0 -0
  67. {mt5cli-1.1.4 → mt5cli-1.1.5}/tests/test_examples.py +0 -0
  68. {mt5cli-1.1.4 → mt5cli-1.1.5}/tests/test_grafana.py +0 -0
  69. {mt5cli-1.1.4 → mt5cli-1.1.5}/tests/test_telemetry.py +0 -0
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Project Structure & Module Organization
4
4
 
5
- `mt5cli/` contains the package source. Important modules include `cli.py` for the Typer command-line app, `client.py` and `sdk.py` for public MT5 client/session APIs, `history.py` for SQLite history collection, `storage.py` and `converters.py` for export behavior, and `schemas.py` for normalized dataset contracts. `tests/` holds pytest coverage for CLI behavior, SDK contracts, trading helpers, history, and utilities. `docs/` and `mkdocs.yml` define the MkDocs site and API reference. `skills/mt5cli/SKILL.md` documents the mt5cli agent skill.
5
+ `mt5cli/` contains the package source. Important modules include `cli.py` for the Typer command-line app, `client.py` and `sdk.py` for public MT5 client/session APIs, `history.py` for SQLite history collection, `trading.py` for trading helpers, `grafana.py` for Grafana schema support, `telemetry.py` for observability, `utils.py` and `converters.py` for export behavior, and `schemas.py` for normalized dataset contracts. `tests/` holds pytest coverage for CLI behavior, SDK contracts, trading helpers, history, and utilities. `docs/` and `mkdocs.yml` define the MkDocs site and API reference. `skills/mt5cli/SKILL.md` documents the mt5cli agent skill.
6
6
 
7
7
  ## Build, Test, and Development Commands
8
8
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mt5cli
3
- Version: 1.1.4
3
+ Version: 1.1.5
4
4
  Summary: Generic MT5 data and execution infrastructure for Python applications
5
5
  Project-URL: Repository, https://github.com/dceoy/mt5cli.git
6
6
  Author-email: dceoy <dceoy@users.noreply.github.com>
@@ -20,6 +20,21 @@ append using dataset-specific keys (for example `ticket` on history tables, or
20
20
  Optional views are created when `--with-views` is set and the `history-deals` dataset
21
21
  was written.
22
22
 
23
+ `ticks` and `symbols` are opt-in datasets (pass `--dataset ticks` /
24
+ `--dataset symbols`); they are excluded from the default `rates`,
25
+ `history-orders`, `history-deals` selection. Unlike the other datasets,
26
+ `symbols` is not a time-windowed history table: each collection or update
27
+ writes one row per requested symbol, timestamped with the collection's
28
+ `date_to` (one-shot `collect-history`) or the update's end time (incremental
29
+ `update_history`). It snapshots broker-reported symbol metadata (`point`,
30
+ `digits`, `trade_contract_size`, `volume_min`, `volume_max`, `volume_step`,
31
+ `trade_tick_size`, `trade_tick_value`, `currency_profit`) so downstream
32
+ consumers can convert `rates.spread` (points) into a relative spread without a
33
+ live terminal connection. Metadata is only valid for the account/broker that
34
+ produced the snapshot. When a symbol's `point` is missing or zero, the row is
35
+ still written with NULL metadata and a warning is logged — a bad symbol never
36
+ aborts the rest of the sync.
37
+
23
38
  ### Entity-relationship diagram
24
39
 
25
40
  Sample layout for a full collection with `--with-views`:
@@ -79,6 +94,20 @@ erDiagram
79
94
  REAL fee
80
95
  }
81
96
 
97
+ symbols {
98
+ TEXT symbol "dedup key"
99
+ TEXT time "dedup key, snapshot moment"
100
+ REAL point
101
+ INTEGER digits
102
+ REAL trade_contract_size
103
+ REAL volume_min
104
+ REAL volume_max
105
+ REAL volume_step
106
+ REAL trade_tick_size
107
+ REAL trade_tick_value
108
+ TEXT currency_profit
109
+ }
110
+
82
111
  cash_events {
83
112
  INTEGER ticket
84
113
  TEXT symbol
@@ -106,6 +135,7 @@ erDiagram
106
135
  rates ||--o{ history_deals : "symbol (logical)"
107
136
  ticks ||--o{ history_deals : "symbol (logical)"
108
137
  history_orders ||--o{ history_deals : "order ~ ticket (logical)"
138
+ symbols ||--o{ rates : "symbol (logical)"
109
139
  history_deals ||--|| cash_events : "VIEW: type NOT IN (0,1)"
110
140
  history_deals ||--o{ positions_reconstructed : "VIEW: GROUP BY position_id"
111
141
  ```
@@ -118,6 +148,7 @@ erDiagram
118
148
  | `ticks` | table | `copy_ticks_range` | Indexed on `(symbol, time)` when columns exist. |
119
149
  | `history_orders` | table | `history_orders_get` | Fetched per `--symbol`, then concatenated. |
120
150
  | `history_deals` | table | `history_deals_get` | Fetched per `--symbol`, then concatenated. Indexed on `(position_id, symbol)` when present. |
151
+ | `symbols` | table | `symbol_info` | Opt-in. One row per symbol per collection/update, snapshotted at `date_to` / update end. |
121
152
  | `cash_events` | view | `history_deals` | Non-trade deal types (deposits, balance ops, etc.). Requires `type` column. |
122
153
  | `positions_reconstructed` | view | `history_deals` | One row per closed `position_id`; volume-weighted prices and reversal stats. |
123
154
 
@@ -240,7 +271,7 @@ frame = series["EURUSD", 1] # keyed by (symbol, integer timeframe)
240
271
  `require_existing` is ignored.
241
272
  - `load_rate_series_from_sqlite()` returns a mapping keyed by
242
273
  `(symbol, integer timeframe)`. Unless `explicit_tables` is supplied, it
243
- requires existing managed `rate_*` compatibility views and raises
274
+ requires existing managed `rate_*__*` compatibility views and raises
244
275
  `ValueError` when they are missing. Duplicate `(symbol, timeframe)` targets
245
276
  are rejected.
246
277
  - `load_rate_series_by_granularity()` is a thin wrapper that builds the targets,
@@ -88,6 +88,7 @@ strategy entries, exits, Kelly sizing, or signal logic.
88
88
  | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
89
89
  | `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views |
90
90
  | `extract_tick_price` | Positive finite bid/ask extraction from tick mappings |
91
+ | `estimate_server_clock_offset_seconds` | Broker server clock offset from true UTC, via the latest tick |
91
92
  | `detect_position_side` | Net long / short / flat from open positions |
92
93
  | `calculate_spread_ratio` | Relative bid-ask spread |
93
94
  | `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing |
@@ -160,8 +160,8 @@ updater = ThrottledHistoryUpdater(
160
160
  )
161
161
  ```
162
162
 
163
- By default recoverable errors (`Mt5TradingError`, `Mt5RuntimeError`,
164
- `sqlite3.Error`, `ValueError`, `OSError`, and MT5 client capability
163
+ By default recoverable errors (`Mt5RuntimeError`, `sqlite3.Error`,
164
+ `ValueError`, `OSError`, and MT5 client capability
165
165
  `AttributeError` / `TypeError` for history API methods) propagate so the caller
166
166
  controls logging; pass `suppress_errors=True` to swallow them and return
167
167
  `False` without advancing the throttle. Other `AttributeError` / `TypeError`
@@ -236,6 +236,45 @@ or `None` result from the underlying client is normalized to an empty DataFrame.
236
236
  Downstream packages own all strategy-specific transformations. mt5cli does not
237
237
  provide entry-deal classification, Kelly sizing, or any betting-specific helpers.
238
238
 
239
+ ## Broker server clock offset
240
+
241
+ MT5 tick, bar, and deal timestamps are epoch values labeled in the broker
242
+ server's wall clock, which is commonly UTC+2 or UTC+3 rather than true UTC.
243
+ Any code that mixes those timestamps with true UTC (freshness checks,
244
+ trailing history windows) silently inherits the broker's offset as a bias
245
+ unless it is measured and applied explicitly.
246
+
247
+ `estimate_server_clock_offset_seconds()` reads the latest tick for a symbol
248
+ and returns the broker's clock offset from true UTC, rounded to the nearest
249
+ half hour, or `None` when no valid tick time is available. Pass the result to
250
+ `fetch_recent_history_deals_for_trading_client()` via
251
+ `server_clock_offset_seconds` to shift the trailing window so it covers the
252
+ true most-recent deals:
253
+
254
+ ```python
255
+ from mt5cli import (
256
+ create_trading_client,
257
+ estimate_server_clock_offset_seconds,
258
+ fetch_recent_history_deals_for_trading_client,
259
+ )
260
+
261
+ client = create_trading_client(login=12345, server="Broker-Demo")
262
+ try:
263
+ offset = estimate_server_clock_offset_seconds(client, "JP225")
264
+ deals_df = fetch_recent_history_deals_for_trading_client(
265
+ client,
266
+ symbol="JP225",
267
+ hours=24,
268
+ server_clock_offset_seconds=offset,
269
+ )
270
+ finally:
271
+ client.shutdown()
272
+ ```
273
+
274
+ No offset is applied automatically anywhere; omitting
275
+ `server_clock_offset_seconds` keeps the trailing window anchored to true UTC
276
+ (current behavior, byte-identical to prior releases).
277
+
239
278
  ## Migration from application-local helpers
240
279
 
241
280
  | Application-local concern | mt5cli replacement |
@@ -247,6 +286,7 @@ provide entry-deal classification, Kelly sizing, or any betting-specific helpers
247
286
  | Local order or position margin estimation | `estimate_order_margin()`, `calculate_positions_margin()` |
248
287
  | Local closed-bar fetch from a trading session | `fetch_latest_closed_rates_for_trading_client()`, `fetch_latest_closed_rates_indexed()` |
249
288
  | Local recent deal history fetch from a trading session | `fetch_recent_history_deals_for_trading_client()` |
289
+ | Local broker server clock offset measurement | `estimate_server_clock_offset_seconds()` |
250
290
  | Local SL/TP price derivation | `determine_order_limits()` |
251
291
  | Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` |
252
292
 
@@ -178,10 +178,10 @@ applications should prefer dedicated closing helpers or their own risk controls.
178
178
 
179
179
  ### Bulk Collection
180
180
 
181
- | Command | Description |
182
- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
183
- | `collect-history` | Collect rates, history-orders, and history-deals (ticks opt-in via `--dataset ticks`) for one or more symbols into a single SQLite database (optional cash-event/position views) |
184
- | `history-gaps` | Export a SQLite-only one-row-per-gap report from managed rate compatibility views without connecting to MT5 |
181
+ | Command | Description |
182
+ | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
183
+ | `collect-history` | Collect rates, history-orders, and history-deals (ticks and symbol metadata opt-in via `--dataset ticks` / `--dataset symbols`) for one or more symbols into a single SQLite database (optional cash-event/position views) |
184
+ | `history-gaps` | Export a SQLite-only one-row-per-gap report from managed rate compatibility views without connecting to MT5 |
185
185
 
186
186
  ```bash
187
187
  mt5cli -o history.db collect-history \
@@ -193,16 +193,16 @@ mt5cli -o history.db collect-history \
193
193
 
194
194
  `collect-history` options:
195
195
 
196
- | Option | Default | Description |
197
- | -------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
198
- | `--symbol/-s` | _required_ | Symbol to collect (repeat for multiple). |
199
- | `--date-from` | _required_ | Start date in ISO 8601. |
200
- | `--date-to` | _required_ | End date in ISO 8601. |
201
- | `--dataset` | rates, history-orders, history-deals | Repeatable: `rates`, `ticks`, `history-orders`, `history-deals`. Ticks are opt-in: pass `--dataset ticks` to include them. |
202
- | `--timeframe` | `M1` | Rates timeframe; recorded in a `timeframe` column on the `rates` table. |
203
- | `--flags` | `ALL` | Tick copy flags forwarded to `copy_ticks_range`. |
204
- | `--if-exists` | `fail` | `append`, `replace`, or `fail` when a target table already exists. |
205
- | `--with-views` | off | Add `cash_events` and `positions_reconstructed` views (requires the `history-deals` dataset). |
196
+ | Option | Default | Description |
197
+ | -------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
198
+ | `--symbol/-s` | _required_ | Symbol to collect (repeat for multiple). |
199
+ | `--date-from` | _required_ | Start date in ISO 8601. |
200
+ | `--date-to` | _required_ | End date in ISO 8601. |
201
+ | `--dataset` | rates, history-orders, history-deals | Repeatable: `rates`, `ticks`, `history-orders`, `history-deals`, `symbols`. Ticks and symbol metadata are opt-in: pass `--dataset ticks` / `--dataset symbols` to include them. |
202
+ | `--timeframe` | `M1` | Rates timeframe; recorded in a `timeframe` column on the `rates` table. |
203
+ | `--flags` | `ALL` | Tick copy flags forwarded to `copy_ticks_range`. |
204
+ | `--if-exists` | `fail` | `append`, `replace`, or `fail` when a target table already exists. |
205
+ | `--with-views` | off | Add `cash_events` and `positions_reconstructed` views (requires the `history-deals` dataset). |
206
206
 
207
207
  History orders and deals are fetched per symbol and concatenated, so the symbol filter is applied consistently across all datasets. The `cash_events` view is derived from symbol-filtered `history_deals`, so account-level cash events with empty or non-matching symbols may be excluded. The `positions_reconstructed` view excludes positions with no closing deal, uses volume-weighted open/close prices, and reports reversal deals (`DEAL_ENTRY_INOUT`) via `volume_reversal` / `reversal_count`.
208
208
 
@@ -66,6 +66,7 @@ from .trading import (
66
66
  determine_order_limits,
67
67
  ensure_symbol_selected,
68
68
  estimate_order_margin,
69
+ estimate_server_clock_offset_seconds,
69
70
  extract_tick_price,
70
71
  fetch_latest_closed_rates_for_trading_client,
71
72
  fetch_latest_closed_rates_indexed,
@@ -127,6 +128,7 @@ __all__ = [
127
128
  "drop_forming_rate_bar",
128
129
  "ensure_symbol_selected",
129
130
  "estimate_order_margin",
131
+ "estimate_server_clock_offset_seconds",
130
132
  "extract_tick_price",
131
133
  "fetch_latest_closed_rates",
132
134
  "fetch_latest_closed_rates_for_trading_client",
@@ -6,7 +6,7 @@ import json
6
6
  import logging
7
7
  import os
8
8
  import re
9
- import sqlite3
9
+ from contextlib import closing
10
10
  from dataclasses import dataclass
11
11
  from datetime import datetime # noqa: TC003
12
12
  from pathlib import Path # noqa: TC003
@@ -17,7 +17,11 @@ import typer
17
17
 
18
18
  from . import sdk
19
19
  from .client import MT5Client
20
- from .history import report_rate_gaps, resolve_granularity_name
20
+ from .history import (
21
+ open_existing_sqlite_database,
22
+ report_rate_gaps,
23
+ resolve_granularity_name,
24
+ )
21
25
  from .trading import OrderExecutionResult, close_open_positions, create_trading_client
22
26
  from .utils import (
23
27
  DATETIME_TYPE,
@@ -33,6 +37,7 @@ from .utils import (
33
37
  )
34
38
 
35
39
  if TYPE_CHECKING:
40
+ import sqlite3
36
41
  from collections.abc import Callable
37
42
 
38
43
  from pdmt5 import Mt5Config
@@ -838,10 +843,15 @@ def history_gaps(
838
843
  """Export SQLite rate gaps without connecting to MT5.
839
844
 
840
845
  Raises:
841
- typer.BadParameter: If no compatible rate view is available and no
842
- explicit table is provided, or if granularity inference fails.
846
+ typer.BadParameter: If the source database does not exist, if no
847
+ compatible rate view is available and no explicit table is
848
+ provided, or if granularity inference fails.
843
849
  """
844
- with sqlite3.connect(sqlite3_path) as conn:
850
+ try:
851
+ conn, _ = open_existing_sqlite_database(sqlite3_path)
852
+ except ValueError as exc:
853
+ raise typer.BadParameter(str(exc), param_hint="--sqlite3") from exc
854
+ with closing(conn):
845
855
  tables = list(table) if table else _default_gap_tables(conn)
846
856
  if not tables:
847
857
  msg = (
@@ -902,7 +912,8 @@ def collect_history(
902
912
  help=(
903
913
  "Dataset to include (repeat for multiple)."
904
914
  " Defaults to rates, history-orders, history-deals."
905
- " Ticks are opt-in: pass --dataset ticks to include them."
915
+ " Ticks and symbols metadata are opt-in:"
916
+ " pass --dataset ticks or --dataset symbols to include them."
906
917
  ),
907
918
  ),
908
919
  ] = None,
@@ -943,6 +954,9 @@ def collect_history(
943
954
  Tables written depend on ``--dataset``: ``rates``, ``history_orders``,
944
955
  ``history_deals`` by default. ``ticks`` are opt-in: pass
945
956
  ``--dataset ticks`` to include them (tick data grows the database quickly).
957
+ ``symbols`` is also opt-in: pass ``--dataset symbols`` to snapshot
958
+ per-symbol metadata (point, digits, contract size, volume limits) at
959
+ ``date_to``, one row per symbol per collection.
946
960
  History datasets are fetched per symbol and concatenated. Rates rows carry
947
961
  the requested ``timeframe`` so appended runs at different timeframes remain
948
962
  distinguishable.
@@ -44,6 +44,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
44
44
  "drop_forming_rate_bar",
45
45
  "ensure_symbol_selected",
46
46
  "estimate_order_margin",
47
+ "estimate_server_clock_offset_seconds",
47
48
  "extract_tick_price",
48
49
  "fetch_latest_closed_rates",
49
50
  "fetch_latest_closed_rates_for_trading_client",
@@ -5,15 +5,17 @@ from __future__ import annotations
5
5
  import logging
6
6
  import re
7
7
  import sqlite3
8
+ from collections.abc import Mapping
8
9
  from dataclasses import dataclass
9
10
  from datetime import UTC, datetime, timedelta
10
11
  from pathlib import Path
11
12
  from typing import TYPE_CHECKING, Literal, SupportsInt, cast, overload
12
13
 
13
14
  import pandas as pd
15
+ from pdmt5 import Mt5RuntimeError
14
16
  from pdmt5 import get_timeframe_name as _get_timeframe_name
15
17
 
16
- from .schemas import DEDUP_KEYS, DataKind, ensure_utc_columns
18
+ from .schemas import DEDUP_KEYS, REQUIRED_COLUMNS, DataKind, ensure_utc_columns
17
19
  from .utils import (
18
20
  TIMEFRAME_NAMES,
19
21
  Dataset,
@@ -24,7 +26,7 @@ from .utils import (
24
26
  )
25
27
 
26
28
  if TYPE_CHECKING:
27
- from collections.abc import Callable, Mapping, Sequence
29
+ from collections.abc import Callable, Sequence
28
30
 
29
31
  from pdmt5 import Mt5DataClient
30
32
 
@@ -49,8 +51,18 @@ _HISTORY_DEDUP_KEYS: dict[Dataset, tuple[tuple[str, ...], ...]] = {
49
51
  Dataset.ticks: DEDUP_KEYS[DataKind.ticks],
50
52
  Dataset.history_orders: DEDUP_KEYS[DataKind.history_orders],
51
53
  Dataset.history_deals: DEDUP_KEYS[DataKind.history_deals],
54
+ Dataset.symbols: DEDUP_KEYS[DataKind.symbols],
52
55
  }
53
56
 
57
+ _SYMBOLS_SNAPSHOT_FIELDS: tuple[str, ...] = tuple(
58
+ sorted(REQUIRED_COLUMNS[DataKind.symbols] - {"symbol", "time"}),
59
+ )
60
+ _SYMBOLS_FLOAT_FIELDS: tuple[str, ...] = tuple(
61
+ field
62
+ for field in _SYMBOLS_SNAPSHOT_FIELDS
63
+ if field not in {"currency_profit", "digits"}
64
+ )
65
+
54
66
  _TRADE_DEAL_TYPES: tuple[int, int] = (0, 1)
55
67
  _TRADE_DEAL_TYPES_SQL = f"({', '.join(str(value) for value in _TRADE_DEAL_TYPES)})"
56
68
 
@@ -91,7 +103,8 @@ def resolve_history_datasets(datasets: set[Dataset] | None) -> set[Dataset]:
91
103
  Returns:
92
104
  ``DEFAULT_HISTORY_DATASETS`` (rates, history-orders, history-deals)
93
105
  when ``datasets`` is None, otherwise the configured selection (which
94
- may be empty or explicitly include ``Dataset.ticks``).
106
+ may be empty or explicitly include ``Dataset.ticks`` /
107
+ ``Dataset.symbols``).
95
108
  """
96
109
  if datasets is None:
97
110
  return set(DEFAULT_HISTORY_DATASETS)
@@ -222,7 +235,7 @@ def _open_history_connection(
222
235
  return conn, True
223
236
 
224
237
 
225
- def _open_existing_sqlite_database(
238
+ def open_existing_sqlite_database(
226
239
  conn_or_path: SqliteConnOrPath,
227
240
  ) -> tuple[sqlite3.Connection, bool]:
228
241
  """Open a read-only SQLite database or reuse an existing connection.
@@ -490,7 +503,7 @@ def load_rate_data(
490
503
  DataFrame indexed by ascending ``time``.
491
504
 
492
505
  """
493
- conn, should_close = _open_existing_sqlite_database(conn_or_path)
506
+ conn, should_close = open_existing_sqlite_database(conn_or_path)
494
507
  try:
495
508
  return load_rate_data_from_connection(conn, table, count=count)
496
509
  finally:
@@ -899,8 +912,8 @@ def load_rate_series_from_sqlite(
899
912
  be unique. Omit when loading a single explicit ``table``.
900
913
  count: Optional number of most recent rows to load per series.
901
914
  explicit_tables: Optional explicit table or view names matching targets.
902
- When omitted, managed ``rate_*`` compatibility views must already
903
- exist in the database.
915
+ When omitted, managed ``rate_*__*`` compatibility views must
916
+ already exist in the database.
904
917
  table: Optional single table or view name to load directly.
905
918
 
906
919
  Returns:
@@ -943,7 +956,7 @@ def load_rate_series_from_sqlite(
943
956
  if explicit_tables is not None
944
957
  else None
945
958
  )
946
- conn, should_close = _open_existing_sqlite_database(conn_or_path)
959
+ conn, should_close = open_existing_sqlite_database(conn_or_path)
947
960
  try:
948
961
  resolved_tables = tables or resolve_rate_tables(
949
962
  conn,
@@ -1639,9 +1652,9 @@ def create_positions_reconstructed_view(
1639
1652
 
1640
1653
 
1641
1654
  def drop_rate_compatibility_views(conn: sqlite3.Connection) -> None:
1642
- """Drop all mt5cli-managed ``rate_*`` compatibility views."""
1655
+ """Drop all mt5cli-managed ``rate_*__*`` compatibility views."""
1643
1656
  rows = conn.execute(
1644
- "SELECT name FROM sqlite_master WHERE type = 'view' AND name GLOB 'rate_*'",
1657
+ "SELECT name FROM sqlite_master WHERE type = 'view' AND name GLOB 'rate_*__*'",
1645
1658
  ).fetchall()
1646
1659
  for (view_name,) in rows:
1647
1660
  quoted_view_name = quote_sqlite_identifier(str(view_name))
@@ -1801,6 +1814,62 @@ def write_ticks_dataset(
1801
1814
  )
1802
1815
 
1803
1816
 
1817
+ def write_symbols_dataset(
1818
+ conn: sqlite3.Connection,
1819
+ client: Mt5DataClient,
1820
+ symbols: Sequence[str],
1821
+ snapshot_time: datetime,
1822
+ if_exists: IfExists,
1823
+ written_columns: dict[Dataset, set[str]],
1824
+ ) -> bool:
1825
+ """Snapshot per-symbol metadata (point, digits, contract size, volume limits).
1826
+
1827
+ Broker-reported symbol metadata is only valid for the account/broker that
1828
+ produced the snapshot. When a symbol's metadata cannot be retrieved, or
1829
+ when its ``point`` is missing or zero (the terminal has no valid metadata
1830
+ for it), a row is still persisted with NULL metadata and a warning is
1831
+ logged; the sync is never aborted for a single bad symbol.
1832
+
1833
+ Returns:
1834
+ True if the symbols table was written.
1835
+ """
1836
+
1837
+ def _fetch_symbol_frame(sym: str) -> pd.DataFrame:
1838
+ try:
1839
+ info = client.symbol_info_as_dict(symbol=sym)
1840
+ except Mt5RuntimeError:
1841
+ info = None
1842
+ if not isinstance(info, Mapping):
1843
+ logger.warning(
1844
+ "Symbol %r metadata could not be retrieved; persisting NULL metadata.",
1845
+ sym,
1846
+ )
1847
+ info = {}
1848
+ if info.get("point"):
1849
+ metadata = {field: info.get(field) for field in _SYMBOLS_SNAPSHOT_FIELDS}
1850
+ else:
1851
+ if info:
1852
+ logger.warning(
1853
+ "Symbol %r has missing or zero point; persisting NULL metadata.",
1854
+ sym,
1855
+ )
1856
+ metadata = dict.fromkeys(_SYMBOLS_SNAPSHOT_FIELDS)
1857
+ frame = pd.DataFrame([{"symbol": sym, "time": snapshot_time, **metadata}])
1858
+ for field in _SYMBOLS_FLOAT_FIELDS:
1859
+ frame[field] = frame[field].astype("float64")
1860
+ frame["digits"] = frame["digits"].astype("Int64")
1861
+ return frame
1862
+
1863
+ return _stream_symbol_frames(
1864
+ conn,
1865
+ symbols,
1866
+ Dataset.symbols,
1867
+ if_exists,
1868
+ written_columns,
1869
+ _fetch_symbol_frame,
1870
+ )
1871
+
1872
+
1804
1873
  def write_history_dataset(
1805
1874
  conn: sqlite3.Connection,
1806
1875
  fetch: Callable[..., pd.DataFrame],
@@ -1935,6 +2004,25 @@ def _write_incremental_ticks(
1935
2004
  )
1936
2005
 
1937
2006
 
2007
+ def _write_incremental_symbols(
2008
+ conn: sqlite3.Connection,
2009
+ client: Mt5DataClient,
2010
+ symbols: Sequence[str],
2011
+ end_date: datetime,
2012
+ written_columns: dict[Dataset, set[str]],
2013
+ written_tables: set[Dataset],
2014
+ ) -> None:
2015
+ if write_symbols_dataset(
2016
+ conn,
2017
+ client,
2018
+ symbols,
2019
+ end_date,
2020
+ IfExists.APPEND,
2021
+ written_columns,
2022
+ ):
2023
+ written_tables.add(Dataset.symbols)
2024
+
2025
+
1938
2026
  def _write_incremental_history_orders(
1939
2027
  conn: sqlite3.Connection,
1940
2028
  client: Mt5DataClient,
@@ -2182,6 +2270,15 @@ def write_incremental_datasets( # noqa: PLR0913
2182
2270
  dedup_scopes,
2183
2271
  include_account_events=include_account_events,
2184
2272
  )
2273
+ if Dataset.symbols in selected_datasets:
2274
+ _write_incremental_symbols(
2275
+ conn,
2276
+ client,
2277
+ symbols,
2278
+ end_date,
2279
+ written_columns,
2280
+ written_tables,
2281
+ )
2185
2282
  _finalize_incremental_writes(
2186
2283
  conn,
2187
2284
  selected_datasets,
@@ -2259,4 +2356,13 @@ def write_collected_datasets(
2259
2356
  include_account_events=False,
2260
2357
  ):
2261
2358
  written_tables.add(Dataset.history_deals)
2359
+ if Dataset.symbols in datasets and write_symbols_dataset(
2360
+ conn,
2361
+ client,
2362
+ symbols,
2363
+ date_to,
2364
+ if_exists,
2365
+ written_columns,
2366
+ ):
2367
+ written_tables.add(Dataset.symbols)
2262
2368
  return written_tables, written_columns
@@ -46,6 +46,7 @@ class DataKind(StrEnum):
46
46
  positions = "positions"
47
47
  history_orders = "history_orders"
48
48
  history_deals = "history_deals"
49
+ symbols = "symbols"
49
50
 
50
51
 
51
52
  REQUIRED_COLUMNS: dict[DataKind, frozenset[str]] = {
@@ -108,6 +109,19 @@ REQUIRED_COLUMNS: dict[DataKind, frozenset[str]] = {
108
109
  "price",
109
110
  "profit",
110
111
  }),
112
+ DataKind.symbols: frozenset({
113
+ "symbol",
114
+ "time",
115
+ "point",
116
+ "digits",
117
+ "trade_contract_size",
118
+ "volume_min",
119
+ "volume_max",
120
+ "volume_step",
121
+ "trade_tick_size",
122
+ "trade_tick_value",
123
+ "currency_profit",
124
+ }),
111
125
  }
112
126
 
113
127
  _OPTIONAL_TIME_COLUMNS_BY_KIND: dict[DataKind, frozenset[str]] = {
@@ -135,6 +149,7 @@ DEDUP_KEYS: dict[DataKind, tuple[tuple[str, ...], ...]] = {
135
149
  DataKind.ticks: (("symbol", "time_msc"), ("symbol", "time")),
136
150
  DataKind.history_orders: (("ticket",), ("symbol", "time", "type")),
137
151
  DataKind.history_deals: (("ticket",), ("symbol", "time", "type", "entry")),
152
+ DataKind.symbols: (("symbol", "time"),),
138
153
  }
139
154
 
140
155
 
@@ -369,16 +369,26 @@ def build_config(
369
369
 
370
370
 
371
371
  @contextmanager
372
- def connected_client(config: Mt5Config) -> Iterator[Mt5DataClient]:
372
+ def connected_client(
373
+ config: Mt5Config,
374
+ *,
375
+ retry_count: int | None = None,
376
+ ) -> Iterator[Mt5DataClient]:
373
377
  """Initialize MT5, yield a connected client, and always shut down.
374
378
 
375
379
  Args:
376
380
  config: MT5 connection configuration.
381
+ retry_count: Number of MT5 initialization retries. Defaults to the
382
+ pdmt5 client default when omitted.
377
383
 
378
384
  Yields:
379
385
  Connected ``Mt5DataClient`` instance.
380
386
  """
381
- client = Mt5DataClient(config=config)
387
+ client = (
388
+ Mt5DataClient(config=config)
389
+ if retry_count is None
390
+ else Mt5DataClient(config=config, retry_count=retry_count)
391
+ )
382
392
  try:
383
393
  client.initialize_and_login_mt5()
384
394
  yield client
@@ -389,17 +399,21 @@ def connected_client(config: Mt5Config) -> Iterator[Mt5DataClient]:
389
399
  def _run_with_client(
390
400
  config: Mt5Config,
391
401
  fetch_fn: Callable[[Mt5DataClient], T],
402
+ *,
403
+ retry_count: int | None = None,
392
404
  ) -> T:
393
405
  """Connect, run ``fetch_fn``, and shut down safely.
394
406
 
395
407
  Args:
396
408
  config: MT5 connection configuration.
397
409
  fetch_fn: Callable receiving a connected client.
410
+ retry_count: Number of MT5 initialization retries. Defaults to the
411
+ pdmt5 client default when omitted.
398
412
 
399
413
  Returns:
400
414
  Value returned by ``fetch_fn``.
401
415
  """
402
- with connected_client(config) as client:
416
+ with connected_client(config, retry_count=retry_count) as client:
403
417
  return fetch_fn(client)
404
418
 
405
419
 
@@ -512,7 +526,7 @@ class Mt5CliClient:
512
526
  def _fetch_value(self, fetch_fn: Callable[[Mt5DataClient], T]) -> T:
513
527
  if self._client is not None:
514
528
  return fetch_fn(self._client)
515
- return _run_with_client(self._config, fetch_fn)
529
+ return _run_with_client(self._config, fetch_fn, retry_count=self._retry_count)
516
530
 
517
531
  def _fetch(self, fetch_fn: Callable[[Mt5DataClient], pd.DataFrame]) -> pd.DataFrame:
518
532
  return self._fetch_value(fetch_fn)
@@ -992,7 +1006,9 @@ def update_history( # noqa: PLR0913
992
1006
  output: SQLite database path.
993
1007
  symbols: Symbols to update.
994
1008
  datasets: Datasets to include (defaults to rates, history-orders,
995
- history-deals; pass ``{Dataset.ticks}`` to opt in to ticks).
1009
+ history-deals; pass ``{Dataset.ticks}`` to opt in to ticks,
1010
+ or ``{Dataset.symbols}`` to opt in to symbol metadata
1011
+ snapshots).
996
1012
  timeframes: Rate timeframes to update (defaults to all fixed MT5
997
1013
  timeframes when None).
998
1014
  flags: Tick copy flags as integer or name (e.g. ``ALL``).
@@ -1126,7 +1142,9 @@ class ThrottledHistoryUpdater:
1126
1142
  Args:
1127
1143
  output: SQLite database path.
1128
1144
  datasets: Datasets to include (defaults to rates, history-orders,
1129
- history-deals; pass ``{Dataset.ticks}`` to opt in to ticks).
1145
+ history-deals; pass ``{Dataset.ticks}`` to opt in to ticks,
1146
+ or ``{Dataset.symbols}`` to opt in to symbol metadata
1147
+ snapshots).
1130
1148
  timeframes: Rate timeframes to update (defaults to all fixed MT5
1131
1149
  timeframes).
1132
1150
  flags: Tick copy flags as integer or name (e.g. ``ALL``).
@@ -1259,7 +1277,9 @@ def collect_history(
1259
1277
  date_from: Start date.
1260
1278
  date_to: End date.
1261
1279
  datasets: Datasets to include (defaults to rates, history-orders,
1262
- history-deals; pass ``{Dataset.ticks}`` to opt in to ticks).
1280
+ history-deals; pass ``{Dataset.ticks}`` to opt in to ticks,
1281
+ or ``{Dataset.symbols}`` to opt in to symbol metadata
1282
+ snapshots).
1263
1283
  timeframe: Rates timeframe as integer or name (e.g. ``M1``).
1264
1284
  flags: Tick copy flags as integer or name (e.g. ``ALL``).
1265
1285
  if_exists: Behavior when a target table already exists.