mt5cli 1.2.0__tar.gz → 1.2.1__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 (74) hide show
  1. {mt5cli-1.2.0 → mt5cli-1.2.1}/.gitignore +3 -0
  2. {mt5cli-1.2.0 → mt5cli-1.2.1}/PKG-INFO +1 -2
  3. {mt5cli-1.2.0 → mt5cli-1.2.1}/mt5cli/__init__.py +3 -64
  4. {mt5cli-1.2.0 → mt5cli-1.2.1}/mt5cli/cli.py +6 -38
  5. {mt5cli-1.2.0 → mt5cli-1.2.1}/mt5cli/grafana.py +70 -119
  6. {mt5cli-1.2.0 → mt5cli-1.2.1}/mt5cli/history.py +37 -15
  7. {mt5cli-1.2.0 → mt5cli-1.2.1}/mt5cli/marketdata.py +9 -6
  8. {mt5cli-1.2.0 → mt5cli-1.2.1}/mt5cli/observability.py +2 -10
  9. {mt5cli-1.2.0 → mt5cli-1.2.1}/mt5cli/telemetry.py +136 -101
  10. {mt5cli-1.2.0 → mt5cli-1.2.1}/mt5cli/trading.py +4 -0
  11. {mt5cli-1.2.0 → mt5cli-1.2.1}/pyproject.toml +4 -2
  12. {mt5cli-1.2.0 → mt5cli-1.2.1}/tests/test_cli.py +6 -4
  13. {mt5cli-1.2.0 → mt5cli-1.2.1}/tests/test_telemetry.py +21 -20
  14. {mt5cli-1.2.0 → mt5cli-1.2.1}/tests/test_trading.py +34 -0
  15. {mt5cli-1.2.0 → mt5cli-1.2.1}/uv.lock +1 -3
  16. {mt5cli-1.2.0 → mt5cli-1.2.1}/.agents/skills/local-qa/SKILL.md +0 -0
  17. {mt5cli-1.2.0 → mt5cli-1.2.1}/.agents/skills/local-qa/scripts/qa.sh +0 -0
  18. {mt5cli-1.2.0 → mt5cli-1.2.1}/.agents/skills/mt5cli/SKILL.md +0 -0
  19. {mt5cli-1.2.0 → mt5cli-1.2.1}/.agents/skills/pr-feedback-triage/SKILL.md +0 -0
  20. {mt5cli-1.2.0 → mt5cli-1.2.1}/.claude/agents/codex.md +0 -0
  21. {mt5cli-1.2.0 → mt5cli-1.2.1}/.claude/settings.json +0 -0
  22. {mt5cli-1.2.0 → mt5cli-1.2.1}/.github/FUNDING.yml +0 -0
  23. {mt5cli-1.2.0 → mt5cli-1.2.1}/.github/dependabot.yml +0 -0
  24. {mt5cli-1.2.0 → mt5cli-1.2.1}/.github/renovate.json +0 -0
  25. {mt5cli-1.2.0 → mt5cli-1.2.1}/.github/workflows/ci.yml +0 -0
  26. {mt5cli-1.2.0 → mt5cli-1.2.1}/.github/workflows/claude.yml +0 -0
  27. {mt5cli-1.2.0 → mt5cli-1.2.1}/.github/workflows/opencode.yml +0 -0
  28. {mt5cli-1.2.0 → mt5cli-1.2.1}/.github/workflows/release.yml +0 -0
  29. {mt5cli-1.2.0 → mt5cli-1.2.1}/AGENTS.md +0 -0
  30. {mt5cli-1.2.0 → mt5cli-1.2.1}/CLAUDE.md +0 -0
  31. {mt5cli-1.2.0 → mt5cli-1.2.1}/LICENSE +0 -0
  32. {mt5cli-1.2.0 → mt5cli-1.2.1}/README.md +0 -0
  33. {mt5cli-1.2.0 → mt5cli-1.2.1}/docs/api/cli.md +0 -0
  34. {mt5cli-1.2.0 → mt5cli-1.2.1}/docs/api/client.md +0 -0
  35. {mt5cli-1.2.0 → mt5cli-1.2.1}/docs/api/converters.md +0 -0
  36. {mt5cli-1.2.0 → mt5cli-1.2.1}/docs/api/exceptions.md +0 -0
  37. {mt5cli-1.2.0 → mt5cli-1.2.1}/docs/api/grafana.md +0 -0
  38. {mt5cli-1.2.0 → mt5cli-1.2.1}/docs/api/history.md +0 -0
  39. {mt5cli-1.2.0 → mt5cli-1.2.1}/docs/api/index.md +0 -0
  40. {mt5cli-1.2.0 → mt5cli-1.2.1}/docs/api/marketdata.md +0 -0
  41. {mt5cli-1.2.0 → mt5cli-1.2.1}/docs/api/observability.md +0 -0
  42. {mt5cli-1.2.0 → mt5cli-1.2.1}/docs/api/public-contract.md +0 -0
  43. {mt5cli-1.2.0 → mt5cli-1.2.1}/docs/api/schemas.md +0 -0
  44. {mt5cli-1.2.0 → mt5cli-1.2.1}/docs/api/telemetry.md +0 -0
  45. {mt5cli-1.2.0 → mt5cli-1.2.1}/docs/api/trading.md +0 -0
  46. {mt5cli-1.2.0 → mt5cli-1.2.1}/docs/api/utils.md +0 -0
  47. {mt5cli-1.2.0 → mt5cli-1.2.1}/docs/index.md +0 -0
  48. {mt5cli-1.2.0 → mt5cli-1.2.1}/examples/grafana/README.md +0 -0
  49. {mt5cli-1.2.0 → mt5cli-1.2.1}/examples/grafana/compose.yml +0 -0
  50. {mt5cli-1.2.0 → mt5cli-1.2.1}/examples/grafana/dashboards/mt5cli-market.json +0 -0
  51. {mt5cli-1.2.0 → mt5cli-1.2.1}/examples/grafana/dashboards/mt5cli-overview.json +0 -0
  52. {mt5cli-1.2.0 → mt5cli-1.2.1}/examples/grafana/dashboards/mt5cli-trades.json +0 -0
  53. {mt5cli-1.2.0 → mt5cli-1.2.1}/examples/grafana/provisioning/dashboards/mt5cli.yml +0 -0
  54. {mt5cli-1.2.0 → mt5cli-1.2.1}/examples/grafana/provisioning/datasources/mt5cli-sqlite.yml +0 -0
  55. {mt5cli-1.2.0 → mt5cli-1.2.1}/mkdocs.yml +0 -0
  56. {mt5cli-1.2.0 → mt5cli-1.2.1}/mt5cli/__main__.py +0 -0
  57. {mt5cli-1.2.0 → mt5cli-1.2.1}/mt5cli/client.py +0 -0
  58. {mt5cli-1.2.0 → mt5cli-1.2.1}/mt5cli/contract.py +0 -0
  59. {mt5cli-1.2.0 → mt5cli-1.2.1}/mt5cli/converters.py +0 -0
  60. {mt5cli-1.2.0 → mt5cli-1.2.1}/mt5cli/exceptions.py +0 -0
  61. {mt5cli-1.2.0 → mt5cli-1.2.1}/mt5cli/retry.py +0 -0
  62. {mt5cli-1.2.0 → mt5cli-1.2.1}/mt5cli/schemas.py +0 -0
  63. {mt5cli-1.2.0 → mt5cli-1.2.1}/mt5cli/utils.py +0 -0
  64. {mt5cli-1.2.0 → mt5cli-1.2.1}/tests/__init__.py +0 -0
  65. {mt5cli-1.2.0 → mt5cli-1.2.1}/tests/conftest.py +0 -0
  66. {mt5cli-1.2.0 → mt5cli-1.2.1}/tests/test_client.py +0 -0
  67. {mt5cli-1.2.0 → mt5cli-1.2.1}/tests/test_contracts.py +0 -0
  68. {mt5cli-1.2.0 → mt5cli-1.2.1}/tests/test_examples.py +0 -0
  69. {mt5cli-1.2.0 → mt5cli-1.2.1}/tests/test_grafana.py +0 -0
  70. {mt5cli-1.2.0 → mt5cli-1.2.1}/tests/test_history.py +0 -0
  71. {mt5cli-1.2.0 → mt5cli-1.2.1}/tests/test_marketdata.py +0 -0
  72. {mt5cli-1.2.0 → mt5cli-1.2.1}/tests/test_observability.py +0 -0
  73. {mt5cli-1.2.0 → mt5cli-1.2.1}/tests/test_packaging.py +0 -0
  74. {mt5cli-1.2.0 → mt5cli-1.2.1}/tests/test_utils.py +0 -0
@@ -205,3 +205,6 @@ cython_debug/
205
205
  marimo/_static/
206
206
  marimo/_lsp/
207
207
  __marimo__/
208
+
209
+ # Claude Code user-local settings
210
+ .claude/settings.local.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mt5cli
3
- Version: 1.2.0
3
+ Version: 1.2.1
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>
@@ -19,7 +19,6 @@ Requires-Python: <3.14,>=3.11
19
19
  Requires-Dist: click>=8.1.0
20
20
  Requires-Dist: pandas>=2.2.2
21
21
  Requires-Dist: pdmt5>=1.2.0
22
- Requires-Dist: pydantic>=2.13.4
23
22
  Requires-Dist: typer>=0.15.0
24
23
  Provides-Extra: otel
25
24
  Requires-Dist: opentelemetry-api; extra == 'otel'
@@ -5,6 +5,7 @@ rather than private submodule helpers. See ``docs/api/public-contract.md`` for
5
5
  the stable SDK contract, CLI surface, internal modules, and out-of-scope
6
6
  strategy responsibilities.
7
7
  """
8
+ # pyright: reportUnusedImport=false, reportUnsupportedDunderAll=false
8
9
 
9
10
  from importlib.metadata import version
10
11
 
@@ -80,69 +81,7 @@ from .trading import (
80
81
 
81
82
  __version__ = version(__package__) if __package__ else None
82
83
 
83
- __all__ = [
84
+ __all__ = [ # noqa: PLE0604
84
85
  "STABLE_SDK_EXPORTS",
85
- "AccountSpec",
86
- "ExecutionStatus",
87
- "MT5Client",
88
- "MarginVolume",
89
- "Mt5CliError",
90
- "Mt5ConnectionError",
91
- "Mt5OperationError",
92
- "Mt5SchemaError",
93
- "OrderExecutionResult",
94
- "OrderFillingMode",
95
- "OrderLimits",
96
- "OrderSide",
97
- "OrderTimeMode",
98
- "PositionSide",
99
- "ProjectionMode",
100
- "RateTarget",
101
- "ThrottledHistoryUpdater",
102
- "build_config",
103
- "build_rate_targets",
104
- "calculate_account_projected_margin_ratio",
105
- "calculate_margin_and_volume",
106
- "calculate_new_position_margin_ratio",
107
- "calculate_positions_margin",
108
- "calculate_positions_margin_by_symbol",
109
- "calculate_positions_margin_safe",
110
- "calculate_projected_margin_ratio",
111
- "calculate_spread_ratio",
112
- "calculate_symbol_group_margin_ratio",
113
- "calculate_trailing_stop_updates",
114
- "calculate_volume_by_margin",
115
- "close_open_positions",
116
- "collect_history",
117
- "collect_latest_closed_rates_by_granularity",
118
- "collect_latest_closed_rates_for_accounts",
119
- "collect_latest_rates_for_accounts_with_retries",
120
- "detect_position_side",
121
- "determine_order_limits",
122
- "drop_forming_rate_bar",
123
- "ensure_symbol_selected",
124
- "estimate_order_margin",
125
- "estimate_server_clock_offset_seconds",
126
- "extract_tick_price",
127
- "fetch_latest_closed_rates",
128
- "fetch_latest_closed_rates_indexed",
129
- "get_account_snapshot",
130
- "get_positions_frame",
131
- "get_symbol_snapshot",
132
- "get_tick_snapshot",
133
- "load_rate_series_by_granularity",
134
- "load_rate_series_from_sqlite",
135
- "mt5_session",
136
- "normalize_order_volume",
137
- "place_market_order",
138
- "report_rate_gaps",
139
- "resolve_account_spec",
140
- "resolve_account_specs",
141
- "resolve_broker_filling_mode",
142
- "update_history",
143
- "update_history_with_config",
144
- "update_observability",
145
- "update_observability_with_config",
146
- "update_sltp_for_open_positions",
147
- "update_trailing_stop_loss_for_open_positions",
86
+ *sorted(STABLE_SDK_EXPORTS),
148
87
  ]
@@ -4,8 +4,6 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import logging
7
- import os
8
- import re
9
7
  from contextlib import closing
10
8
  from dataclasses import dataclass
11
9
  from datetime import datetime
@@ -19,9 +17,9 @@ import typer
19
17
  from .client import MT5Client, build_config, mt5_session
20
18
  from .history import collect_history as _collect_history
21
19
  from .history import (
20
+ infer_rate_table_granularity_seconds,
22
21
  open_existing_sqlite_database,
23
22
  report_rate_gaps,
24
- resolve_granularity_name,
25
23
  )
26
24
  from .observability import update_observability_with_config
27
25
  from .trading import OrderExecutionResult, close_open_positions
@@ -206,40 +204,12 @@ _CLI_ENV_DEFAULTS: dict[str, str] = {
206
204
  "password": "MT5_PASSWORD",
207
205
  "server": "MT5_SERVER",
208
206
  }
209
- _RATE_VIEW_NAME_RE = re.compile(
210
- r"^rate_(?P<symbol>.+)__(?:(?P<granularity>[A-Z0-9]+)_)?(?P<timeframe>\d+)$",
211
- )
212
207
 
213
208
 
214
209
  def _get_export_context(ctx: typer.Context) -> _ExportContext:
215
210
  return cast("_ExportContext", ctx.obj)
216
211
 
217
212
 
218
- def _resolve_cli_option(value: str | None, env_name: str) -> str | None:
219
- return value if value is not None else os.environ.get(env_name)
220
-
221
-
222
- def _timeframe_interval_seconds(timeframe: int) -> int | None:
223
- granularity = resolve_granularity_name(timeframe)
224
- units = {
225
- "M": 60,
226
- "H": 3600,
227
- "D": 86400,
228
- "W": 604800,
229
- }
230
- for prefix, seconds in units.items():
231
- suffix = granularity.removeprefix(prefix)
232
- if granularity.startswith(prefix) and suffix.isdigit():
233
- return int(suffix) * seconds
234
- return None
235
-
236
-
237
- def _infer_gap_table_granularity_seconds(table: str) -> int | None:
238
- if (match := _RATE_VIEW_NAME_RE.fullmatch(table)) is None:
239
- return None
240
- return _timeframe_interval_seconds(int(match.group("timeframe")))
241
-
242
-
243
213
  def _default_gap_tables(conn: sqlite3.Connection) -> list[str]:
244
214
  rows = conn.execute(
245
215
  "SELECT name FROM sqlite_master"
@@ -364,10 +334,10 @@ def _callback( # pyright: ignore[reportUnusedFunction]
364
334
  raise typer.BadParameter(str(exc)) from exc
365
335
  try:
366
336
  config = build_config(
367
- path=_resolve_cli_option(path, _CLI_ENV_DEFAULTS["path"]),
368
- login=_resolve_cli_option(login, _CLI_ENV_DEFAULTS["login"]),
369
- password=_resolve_cli_option(password, _CLI_ENV_DEFAULTS["password"]),
370
- server=_resolve_cli_option(server, _CLI_ENV_DEFAULTS["server"]),
337
+ path=path,
338
+ login=login,
339
+ password=password,
340
+ server=server,
371
341
  timeout=timeout,
372
342
  allow_whole_dollar_env=True,
373
343
  )
@@ -988,7 +958,7 @@ def history_gaps(
988
958
  frames: list[pd.DataFrame] = []
989
959
  for table_name in tables:
990
960
  interval_seconds = (
991
- granularity_seconds or _infer_gap_table_granularity_seconds(table_name)
961
+ granularity_seconds or infer_rate_table_granularity_seconds(table_name)
992
962
  )
993
963
  if interval_seconds is None:
994
964
  msg = (
@@ -1141,7 +1111,6 @@ def grafana_schema(
1141
1111
  import sqlite3 as _sqlite3 # noqa: PLC0415
1142
1112
 
1143
1113
  from .grafana import ( # noqa: PLC0415
1144
- create_snapshot_tables,
1145
1114
  ensure_grafana_schema,
1146
1115
  publish_grafana_copy,
1147
1116
  )
@@ -1156,7 +1125,6 @@ def grafana_schema(
1156
1125
  with _sqlite3.connect(export_ctx.output) as conn:
1157
1126
  conn.execute("PRAGMA journal_mode=WAL")
1158
1127
  conn.execute("PRAGMA synchronous=NORMAL")
1159
- create_snapshot_tables(conn)
1160
1128
  ensure_grafana_schema(conn)
1161
1129
  logger.info("Grafana schema applied to %s", export_ctx.output)
1162
1130
  if publish_copy is not None:
@@ -9,7 +9,7 @@ import os
9
9
  import sqlite3
10
10
  import tempfile
11
11
  from pathlib import Path
12
- from typing import cast
12
+ from typing import NamedTuple, cast
13
13
 
14
14
  from .history import get_table_columns
15
15
 
@@ -158,113 +158,78 @@ def start_snapshot_run(conn: sqlite3.Connection, observed_at: int) -> int:
158
158
  # ---------------------------------------------------------------------------
159
159
 
160
160
 
161
- def _build_grafana_rates(conn: sqlite3.Connection) -> None:
162
- cols = get_table_columns(conn, "rates")
163
- required = {"time", "symbol", "timeframe"}
164
- if not required.issubset(cols):
165
- logger.warning(
166
- "Skipping grafana_rates: rates table missing columns %s",
167
- sorted(required - cols),
168
- )
169
- return
170
- time_expr = _time_col_expr("time")
171
- others = _other_cols(cols, {"time"})
172
- other_sql = ", ".join(f'"{c}"' for c in others)
173
- _create_view_safe(
174
- conn,
175
- "grafana_rates",
176
- f'SELECT {time_expr} AS "time", {other_sql} FROM "rates"', # noqa: S608
177
- )
178
-
179
-
180
- def _build_grafana_ticks(conn: sqlite3.Connection) -> None:
181
- cols = get_table_columns(conn, "ticks")
182
- required = {"time", "symbol"}
183
- if not required.issubset(cols):
184
- logger.warning(
185
- "Skipping grafana_ticks: ticks table missing columns %s",
186
- sorted(required - cols),
187
- )
188
- return
189
- time_expr = _time_col_expr("time")
190
- others = _other_cols(cols, {"time"})
191
- other_sql = ", ".join(f'"{c}"' for c in others)
192
- _create_view_safe(
193
- conn,
194
- "grafana_ticks",
195
- f'SELECT {time_expr} AS "time", {other_sql} FROM "ticks"', # noqa: S608
196
- )
197
-
198
-
199
- def _build_grafana_history_deals(conn: sqlite3.Connection) -> None:
200
- cols = get_table_columns(conn, "history_deals")
201
- if "time" not in cols:
202
- logger.warning("Skipping grafana_history_deals: history_deals.time is missing")
203
- return
204
- time_expr = _time_col_expr("time")
205
- others = _other_cols(cols, {"time"})
206
- other_sql = ", ".join(f'"{c}"' for c in others)
207
- _create_view_safe(
208
- conn,
209
- "grafana_history_deals",
210
- f'SELECT {time_expr} AS "time", {other_sql} FROM "history_deals"', # noqa: S608
211
- )
161
+ class _PassthroughViewSpec(NamedTuple):
162
+ """Declarative spec for a view that re-exposes one table with a time column.
212
163
 
164
+ ``exclude_time_col`` controls whether the source time column is dropped
165
+ from the passthrough column list (``grafana_history_orders`` keeps its
166
+ original ``time_setup`` column alongside the derived ``time``).
167
+ """
213
168
 
214
- def _build_grafana_history_orders(conn: sqlite3.Connection) -> None:
215
- cols = get_table_columns(conn, "history_orders")
216
- if "time_setup" not in cols:
217
- logger.warning(
218
- "Skipping grafana_history_orders: history_orders.time_setup is missing"
219
- )
220
- return
221
- time_expr = _time_col_expr("time_setup")
222
- others = _other_cols(cols, set())
223
- other_sql = ", ".join(f'"{c}"' for c in others)
224
- _create_view_safe(
225
- conn,
169
+ view: str
170
+ table: str
171
+ time_col: str
172
+ required: frozenset[str]
173
+ exclude_time_col: bool = True
174
+ where: str | None = None
175
+
176
+
177
+ _PASSTHROUGH_VIEW_SPECS: tuple[_PassthroughViewSpec, ...] = (
178
+ _PassthroughViewSpec(
179
+ "grafana_rates", "rates", "time", frozenset({"time", "symbol", "timeframe"})
180
+ ),
181
+ _PassthroughViewSpec(
182
+ "grafana_ticks", "ticks", "time", frozenset({"time", "symbol"})
183
+ ),
184
+ _PassthroughViewSpec(
185
+ "grafana_history_deals", "history_deals", "time", frozenset({"time"})
186
+ ),
187
+ _PassthroughViewSpec(
226
188
  "grafana_history_orders",
227
- f'SELECT {time_expr} AS "time", {other_sql} FROM "history_orders"', # noqa: S608
228
- )
229
-
230
-
231
- def _build_grafana_trade_deals(conn: sqlite3.Connection) -> None:
232
- cols = get_table_columns(conn, "history_deals")
233
- required = {"time", "type"}
234
- if not required.issubset(cols):
235
- logger.warning(
236
- "Skipping grafana_trade_deals: history_deals missing columns %s",
237
- sorted(required - cols),
238
- )
239
- return
240
- time_expr = _time_col_expr("time")
241
- others = _other_cols(cols, {"time"})
242
- other_sql = ", ".join(f'"{c}"' for c in others)
243
- _create_view_safe(
244
- conn,
189
+ "history_orders",
190
+ "time_setup",
191
+ frozenset({"time_setup"}),
192
+ exclude_time_col=False,
193
+ ),
194
+ _PassthroughViewSpec(
245
195
  "grafana_trade_deals",
246
- f'SELECT {time_expr} AS "time", {other_sql}' # noqa: S608
247
- f' FROM "history_deals" WHERE "type" IN {_TRADE_DEAL_TYPES_SQL}',
248
- )
196
+ "history_deals",
197
+ "time",
198
+ frozenset({"time", "type"}),
199
+ where=f'"type" IN {_TRADE_DEAL_TYPES_SQL}',
200
+ ),
201
+ _PassthroughViewSpec(
202
+ "grafana_cash_events",
203
+ "history_deals",
204
+ "time",
205
+ frozenset({"time", "type"}),
206
+ where=f'"type" NOT IN {_TRADE_DEAL_TYPES_SQL}',
207
+ ),
208
+ )
249
209
 
250
210
 
251
- def _build_grafana_cash_events(conn: sqlite3.Connection) -> None:
252
- cols = get_table_columns(conn, "history_deals")
253
- required = {"time", "type"}
254
- if not required.issubset(cols):
211
+ def _build_passthrough_view(
212
+ conn: sqlite3.Connection,
213
+ spec: _PassthroughViewSpec,
214
+ ) -> None:
215
+ cols = get_table_columns(conn, spec.table)
216
+ if not spec.required.issubset(cols):
255
217
  logger.warning(
256
- "Skipping grafana_cash_events: history_deals missing columns %s",
257
- sorted(required - cols),
218
+ "Skipping %s: %s table missing columns %s",
219
+ spec.view,
220
+ spec.table,
221
+ sorted(spec.required - cols),
258
222
  )
259
223
  return
260
- time_expr = _time_col_expr("time")
261
- others = _other_cols(cols, {"time"})
224
+ time_expr = _time_col_expr(spec.time_col)
225
+ others = _other_cols(cols, {spec.time_col} if spec.exclude_time_col else set())
262
226
  other_sql = ", ".join(f'"{c}"' for c in others)
227
+ where_sql = f" WHERE {spec.where}" if spec.where else ""
263
228
  _create_view_safe(
264
229
  conn,
265
- "grafana_cash_events",
230
+ spec.view,
266
231
  f'SELECT {time_expr} AS "time", {other_sql}' # noqa: S608
267
- f' FROM "history_deals" WHERE "type" NOT IN {_TRADE_DEAL_TYPES_SQL}',
232
+ f' FROM "{spec.table}"{where_sql}',
268
233
  )
269
234
 
270
235
 
@@ -374,20 +339,12 @@ def _build_snapshot_view(
374
339
  logger.warning("Skipping %s: snapshot_runs missing required columns", view_name)
375
340
 
376
341
 
377
- def _build_grafana_account_snapshots(conn: sqlite3.Connection) -> None:
378
- _build_snapshot_view(conn, "grafana_account_snapshots", "account_snapshots")
379
-
380
-
381
- def _build_grafana_position_snapshots(conn: sqlite3.Connection) -> None:
382
- _build_snapshot_view(conn, "grafana_position_snapshots", "position_snapshots")
383
-
384
-
385
- def _build_grafana_order_snapshots(conn: sqlite3.Connection) -> None:
386
- _build_snapshot_view(conn, "grafana_order_snapshots", "order_snapshots")
387
-
388
-
389
- def _build_grafana_terminal_snapshots(conn: sqlite3.Connection) -> None:
390
- _build_snapshot_view(conn, "grafana_terminal_snapshots", "terminal_snapshots")
342
+ _SNAPSHOT_VIEW_TABLES: tuple[tuple[str, str], ...] = (
343
+ ("grafana_account_snapshots", "account_snapshots"),
344
+ ("grafana_position_snapshots", "position_snapshots"),
345
+ ("grafana_order_snapshots", "order_snapshots"),
346
+ ("grafana_terminal_snapshots", "terminal_snapshots"),
347
+ )
391
348
 
392
349
 
393
350
  # ---------------------------------------------------------------------------
@@ -404,19 +361,13 @@ def create_grafana_views(conn: sqlite3.Connection) -> None:
404
361
  """
405
362
  for name in _GRAFANA_VIEW_NAMES:
406
363
  conn.execute(f'DROP VIEW IF EXISTS "{name}"')
407
- _build_grafana_rates(conn)
408
- _build_grafana_ticks(conn)
409
- _build_grafana_history_deals(conn)
410
- _build_grafana_history_orders(conn)
411
- _build_grafana_trade_deals(conn)
412
- _build_grafana_cash_events(conn)
364
+ for spec in _PASSTHROUGH_VIEW_SPECS:
365
+ _build_passthrough_view(conn, spec)
413
366
  _build_grafana_realized_pnl(conn)
414
367
  _build_grafana_symbol_pnl(conn)
415
368
  _build_grafana_trade_stats(conn)
416
- _build_grafana_account_snapshots(conn)
417
- _build_grafana_position_snapshots(conn)
418
- _build_grafana_order_snapshots(conn)
419
- _build_grafana_terminal_snapshots(conn)
369
+ for view_name, table_name in _SNAPSHOT_VIEW_TABLES:
370
+ _build_snapshot_view(conn, view_name, table_name)
420
371
 
421
372
 
422
373
  def create_grafana_indexes(conn: sqlite3.Connection) -> None:
@@ -17,11 +17,7 @@ import pandas as pd
17
17
  from pdmt5 import Mt5Config, Mt5RuntimeError
18
18
  from pdmt5.constants import get_timeframe_name as _get_timeframe_name
19
19
 
20
- from .client import (
21
- MT5Client,
22
- _connected_client, # pyright: ignore[reportPrivateUsage]
23
- build_config,
24
- )
20
+ from .client import mt5_session
25
21
  from .exceptions import Mt5ConnectionError
26
22
  from .schemas import DEDUP_KEYS, REQUIRED_COLUMNS, DataKind, ensure_utc_columns
27
23
  from .telemetry import get_metrics
@@ -158,6 +154,40 @@ def resolve_granularity_name(timeframe: int) -> str:
158
154
  return name.removeprefix("TIMEFRAME_")
159
155
 
160
156
 
157
+ def timeframe_interval_seconds(timeframe: int) -> int | None:
158
+ """Return the bar interval in seconds for a timeframe integer.
159
+
160
+ Returns:
161
+ Interval seconds when the timeframe maps to a known granularity name
162
+ (for example ``M1`` -> ``60``), otherwise ``None``.
163
+ """
164
+ granularity = resolve_granularity_name(timeframe)
165
+ units = {
166
+ "M": 60,
167
+ "H": 3600,
168
+ "D": 86400,
169
+ "W": 604800,
170
+ }
171
+ for prefix, seconds in units.items():
172
+ suffix = granularity.removeprefix(prefix)
173
+ if granularity.startswith(prefix) and suffix.isdigit():
174
+ return int(suffix) * seconds
175
+ return None
176
+
177
+
178
+ def infer_rate_table_granularity_seconds(table: str) -> int | None:
179
+ """Infer the bar interval in seconds from a managed rate table/view name.
180
+
181
+ Returns:
182
+ Interval seconds when ``table`` matches the managed
183
+ ``rate_<symbol>__[<granularity>_]<timeframe>`` naming convention and
184
+ the timeframe is known, otherwise ``None``.
185
+ """
186
+ if (match := _RATE_VIEW_NAME_RE.fullmatch(table)) is None:
187
+ return None
188
+ return timeframe_interval_seconds(int(match.group("timeframe")))
189
+
190
+
161
191
  def drop_forming_rate_bar(df_rate: pd.DataFrame) -> pd.DataFrame:
162
192
  """Return closed bars from chronologically ordered MT5 rate data.
163
193
 
@@ -2597,11 +2627,7 @@ def update_history_with_config( # noqa: PLR0913
2597
2627
  )
2598
2628
  if request is None:
2599
2629
  return
2600
- mt5_config = config or build_config()
2601
- with _connected_client(mt5_config) as raw_client:
2602
- client = MT5Client._from_connected_client( # pyright: ignore[reportPrivateUsage] # noqa: SLF001
2603
- raw_client
2604
- )
2630
+ with mt5_session(config) as client:
2605
2631
  update_history(
2606
2632
  client=client,
2607
2633
  output=output,
@@ -2793,15 +2819,11 @@ def collect_history(
2793
2819
  selected = resolve_history_datasets(datasets)
2794
2820
  tf = parse_timeframe(timeframe)
2795
2821
  tick_flags = parse_tick_flags(flags)
2796
- mt5_config = config or build_config()
2797
2822
  with (
2798
- _connected_client(mt5_config) as raw_client,
2823
+ mt5_session(config) as client,
2799
2824
  closing(sqlite3.connect(output)) as conn,
2800
2825
  conn,
2801
2826
  ):
2802
- client = MT5Client._from_connected_client( # pyright: ignore[reportPrivateUsage] # noqa: SLF001
2803
- raw_client
2804
- )
2805
2827
  conn.execute("PRAGMA journal_mode=WAL")
2806
2828
  conn.execute("PRAGMA synchronous=NORMAL")
2807
2829
  written_tables, written_columns = write_collected_datasets(
@@ -10,8 +10,6 @@ from __future__ import annotations
10
10
  from dataclasses import dataclass, field
11
11
  from typing import TYPE_CHECKING
12
12
 
13
- from pydantic import SecretStr
14
-
15
13
  from .client import (
16
14
  MT5Client,
17
15
  _require_positive, # pyright: ignore[reportPrivateUsage]
@@ -73,7 +71,7 @@ __all__ = [
73
71
 
74
72
 
75
73
  def _make_client(*, config: Mt5Config | None = None) -> MT5Client:
76
- return MT5Client(config=config) if config is not None else MT5Client()
74
+ return MT5Client(config=config)
77
75
 
78
76
 
79
77
  def copy_rates_from(
@@ -348,9 +346,14 @@ def _build_account_config(
348
346
  login = _coerce_login(account.login)
349
347
  if login is None and base_config is not None:
350
348
  login = base_config.login
351
- base_password = base_config.password if base_config else None
352
- if isinstance(base_password, SecretStr):
353
- base_password = base_password.get_secret_value()
349
+ raw_base_password = base_config.password if base_config else None
350
+ # pdmt5 stores the password as ``str | SecretStr | None``; unwrap the
351
+ # secret form without importing pydantic directly.
352
+ base_password = (
353
+ raw_base_password
354
+ if raw_base_password is None or isinstance(raw_base_password, str)
355
+ else raw_base_password.get_secret_value()
356
+ )
354
357
  return build_config(
355
358
  path=account.path or (base_config.path if base_config else None),
356
359
  login=login,
@@ -15,11 +15,7 @@ from datetime import UTC, datetime
15
15
  from pathlib import Path
16
16
  from typing import TYPE_CHECKING, cast
17
17
 
18
- from .client import (
19
- MT5Client,
20
- _connected_client, # pyright: ignore[reportPrivateUsage]
21
- build_config,
22
- )
18
+ from .client import mt5_session
23
19
  from .grafana import (
24
20
  create_snapshot_tables,
25
21
  ensure_grafana_schema,
@@ -241,11 +237,7 @@ def update_observability_with_config(
241
237
  include_terminal: Snapshot terminal info.
242
238
  with_grafana_schema: Ensure Grafana views and indexes exist.
243
239
  """
244
- mt5_config = config or build_config()
245
- with _connected_client(mt5_config) as raw_client:
246
- client = MT5Client._from_connected_client( # pyright: ignore[reportPrivateUsage] # noqa: SLF001
247
- raw_client
248
- )
240
+ with mt5_session(config) as client:
249
241
  update_observability(
250
242
  client=client,
251
243
  output=output,