mt5cli 0.5.0__tar.gz → 0.5.2__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 (37) hide show
  1. {mt5cli-0.5.0 → mt5cli-0.5.2}/PKG-INFO +5 -2
  2. {mt5cli-0.5.0 → mt5cli-0.5.2}/README.md +4 -1
  3. {mt5cli-0.5.0 → mt5cli-0.5.2}/docs/api/history.md +34 -2
  4. {mt5cli-0.5.0 → mt5cli-0.5.2}/mt5cli/__init__.py +40 -1
  5. {mt5cli-0.5.0 → mt5cli-0.5.2}/mt5cli/history.py +271 -16
  6. {mt5cli-0.5.0 → mt5cli-0.5.2}/mt5cli/sdk.py +138 -1
  7. {mt5cli-0.5.0 → mt5cli-0.5.2}/pyproject.toml +1 -1
  8. {mt5cli-0.5.0 → mt5cli-0.5.2}/tests/test_history.py +459 -3
  9. {mt5cli-0.5.0 → mt5cli-0.5.2}/tests/test_sdk.py +178 -1
  10. {mt5cli-0.5.0 → mt5cli-0.5.2}/uv.lock +1 -1
  11. {mt5cli-0.5.0 → mt5cli-0.5.2}/.agents/skills/local-qa/SKILL.md +0 -0
  12. {mt5cli-0.5.0 → mt5cli-0.5.2}/.agents/skills/local-qa/scripts/qa.sh +0 -0
  13. {mt5cli-0.5.0 → mt5cli-0.5.2}/.agents/skills/mt5cli/SKILL.md +0 -0
  14. {mt5cli-0.5.0 → mt5cli-0.5.2}/.claude/agents/codex.md +0 -0
  15. {mt5cli-0.5.0 → mt5cli-0.5.2}/.claude/settings.json +0 -0
  16. {mt5cli-0.5.0 → mt5cli-0.5.2}/.github/FUNDING.yml +0 -0
  17. {mt5cli-0.5.0 → mt5cli-0.5.2}/.github/dependabot.yml +0 -0
  18. {mt5cli-0.5.0 → mt5cli-0.5.2}/.github/renovate.json +0 -0
  19. {mt5cli-0.5.0 → mt5cli-0.5.2}/.github/workflows/ci.yml +0 -0
  20. {mt5cli-0.5.0 → mt5cli-0.5.2}/.github/workflows/claude.yml +0 -0
  21. {mt5cli-0.5.0 → mt5cli-0.5.2}/.github/workflows/release.yml +0 -0
  22. {mt5cli-0.5.0 → mt5cli-0.5.2}/.gitignore +0 -0
  23. {mt5cli-0.5.0 → mt5cli-0.5.2}/AGENTS.md +0 -0
  24. {mt5cli-0.5.0 → mt5cli-0.5.2}/CLAUDE.md +0 -0
  25. {mt5cli-0.5.0 → mt5cli-0.5.2}/LICENSE +0 -0
  26. {mt5cli-0.5.0 → mt5cli-0.5.2}/docs/api/cli.md +0 -0
  27. {mt5cli-0.5.0 → mt5cli-0.5.2}/docs/api/index.md +0 -0
  28. {mt5cli-0.5.0 → mt5cli-0.5.2}/docs/api/sdk.md +0 -0
  29. {mt5cli-0.5.0 → mt5cli-0.5.2}/docs/api/utils.md +0 -0
  30. {mt5cli-0.5.0 → mt5cli-0.5.2}/docs/index.md +0 -0
  31. {mt5cli-0.5.0 → mt5cli-0.5.2}/mkdocs.yml +0 -0
  32. {mt5cli-0.5.0 → mt5cli-0.5.2}/mt5cli/__main__.py +0 -0
  33. {mt5cli-0.5.0 → mt5cli-0.5.2}/mt5cli/cli.py +0 -0
  34. {mt5cli-0.5.0 → mt5cli-0.5.2}/mt5cli/utils.py +0 -0
  35. {mt5cli-0.5.0 → mt5cli-0.5.2}/tests/__init__.py +0 -0
  36. {mt5cli-0.5.0 → mt5cli-0.5.2}/tests/test_cli.py +0 -0
  37. {mt5cli-0.5.0 → mt5cli-0.5.2}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mt5cli
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Command-line tool for MetaTrader 5
5
5
  Project-URL: Repository, https://github.com/dceoy/mt5cli.git
6
6
  Author-email: dceoy <dceoy@users.noreply.github.com>
@@ -157,8 +157,11 @@ update_history_with_config(
157
157
  - **`update_history`**: incremental append based on existing SQLite `MAX(time)` per symbol (and timeframe for rates); account-level deals use a separate cursor when `include_account_events=True`.
158
158
  - **`rates` table**: normalized storage with `symbol` and `timeframe` columns.
159
159
  - **Rate compatibility views**: mt5cli manages all `rate_*` views. Naming is `rate_<symbol>__<timeframe>` when a symbol has one timeframe, otherwise `rate_<symbol>__<granularity>_<timeframe>` (for example `rate_EURUSD__M1_1`). Stale `rate_*` views are dropped and recreated when rates change for offline tools such as mteor optimize.
160
- - **Rate view resolution**: use `mt5cli.history.resolve_rate_view_name()` / `resolve_rate_view_names()` to map symbols and granularities to existing SQLite compatibility views without creating databases.
160
+ - **Rate view resolution**: use `resolve_rate_view_name()` / `resolve_rate_view_names()` to map symbols and granularities to existing SQLite compatibility views without creating databases. Both accept `None` (or a missing path) and return deterministic default names unless `require_existing=True`.
161
161
  - **Rate view loading**: use `load_rate_data()` / `load_rate_data_from_connection()` to load a SQLite rate table or view into a `DatetimeIndex` DataFrame.
162
+ - **Multi-series rate loading**: use `build_rate_targets()` to build neutral `RateTarget(symbol, timeframe)` pairs, `resolve_rate_tables()` to map them to table/view names (pass `require_existing=True` for strict resolution), and `load_rate_series_from_sqlite()` to load them into a mapping keyed by `(symbol, integer timeframe)`. The loader requires existing managed views unless `explicit_tables` is supplied, and rejects duplicate `(symbol, timeframe)` targets.
163
+ - **Multi-account latest rates**: use `collect_latest_rates_for_accounts()` with `AccountSpec` to read the latest bars for several account groups, merged into a `(symbol, integer timeframe)` mapping.
164
+ - **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 `Mt5CliClient` that shuts down on exit.
162
165
  - **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns.
163
166
  - **Recent ticks and margins**: `recent_ticks()` and `minimum_margins()` SDK helpers (and matching CLI commands) cover common downstream read-only queries.
164
167
 
@@ -133,8 +133,11 @@ update_history_with_config(
133
133
  - **`update_history`**: incremental append based on existing SQLite `MAX(time)` per symbol (and timeframe for rates); account-level deals use a separate cursor when `include_account_events=True`.
134
134
  - **`rates` table**: normalized storage with `symbol` and `timeframe` columns.
135
135
  - **Rate compatibility views**: mt5cli manages all `rate_*` views. Naming is `rate_<symbol>__<timeframe>` when a symbol has one timeframe, otherwise `rate_<symbol>__<granularity>_<timeframe>` (for example `rate_EURUSD__M1_1`). Stale `rate_*` views are dropped and recreated when rates change for offline tools such as mteor optimize.
136
- - **Rate view resolution**: use `mt5cli.history.resolve_rate_view_name()` / `resolve_rate_view_names()` to map symbols and granularities to existing SQLite compatibility views without creating databases.
136
+ - **Rate view resolution**: use `resolve_rate_view_name()` / `resolve_rate_view_names()` to map symbols and granularities to existing SQLite compatibility views without creating databases. Both accept `None` (or a missing path) and return deterministic default names unless `require_existing=True`.
137
137
  - **Rate view loading**: use `load_rate_data()` / `load_rate_data_from_connection()` to load a SQLite rate table or view into a `DatetimeIndex` DataFrame.
138
+ - **Multi-series rate loading**: use `build_rate_targets()` to build neutral `RateTarget(symbol, timeframe)` pairs, `resolve_rate_tables()` to map them to table/view names (pass `require_existing=True` for strict resolution), and `load_rate_series_from_sqlite()` to load them into a mapping keyed by `(symbol, integer timeframe)`. The loader requires existing managed views unless `explicit_tables` is supplied, and rejects duplicate `(symbol, timeframe)` targets.
139
+ - **Multi-account latest rates**: use `collect_latest_rates_for_accounts()` with `AccountSpec` to read the latest bars for several account groups, merged into a `(symbol, integer timeframe)` mapping.
140
+ - **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 `Mt5CliClient` that shuts down on exit.
138
141
  - **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns.
139
142
  - **Recent ticks and margins**: `recent_ticks()` and `minimum_margins()` SDK helpers (and matching CLI commands) cover common downstream read-only queries.
140
143
 
@@ -133,8 +133,8 @@ The `update_history` SDK path uses the same base tables and optional
133
133
  ### Rate view resolution
134
134
 
135
135
  Downstream tools can resolve mt5cli-managed compatibility view names from an
136
- existing SQLite history database without creating files or guessing legacy
137
- naming schemes:
136
+ existing SQLite history database without creating files or guessing naming
137
+ schemes:
138
138
 
139
139
  ```python
140
140
  from pathlib import Path
@@ -183,3 +183,35 @@ rates = load_rate_data(Path("history.db"), view, count=1000)
183
183
  The loader accepts close-based OHLC rate data or tick-like bid/ask data. It
184
184
  validates that `time` exists, parses timestamps with pandas, and returns a
185
185
  DataFrame indexed by ascending `DatetimeIndex` named `time`.
186
+
187
+ ### Multi-series rate loading
188
+
189
+ For loading many rate series at once, build neutral `RateTarget` pairs and load
190
+ them from SQLite in one call. View names are resolved via the same
191
+ compatibility-view rules, or you can pass `explicit_tables` to bypass resolution:
192
+
193
+ ```python
194
+ from pathlib import Path
195
+
196
+ from mt5cli import build_rate_targets, load_rate_series_from_sqlite
197
+
198
+ targets = build_rate_targets(["EURUSD", "GBPUSD"], ["M1", "H1"])
199
+ series = load_rate_series_from_sqlite(Path("history.db"), targets, count=1000)
200
+ frame = series["EURUSD", 1] # keyed by (symbol, integer timeframe)
201
+ ```
202
+
203
+ - `build_rate_targets()` returns `RateTarget(symbol, timeframe)` pairs in
204
+ row-major order, normalizing timeframe names such as `"M1"` to their integer
205
+ values; set `allow_missing_symbol=True` to address series solely by
206
+ `explicit_tables` (targets carry `symbol=None`).
207
+ - `resolve_rate_tables()` maps targets to table or view names and validates that
208
+ any `explicit_tables` count matches the target count. Pass
209
+ `require_existing=True` to raise `ValueError` instead of returning a
210
+ best-guess name when the database or managed view is missing. When
211
+ `explicit_tables` is provided, names are returned as-is and
212
+ `require_existing` is ignored.
213
+ - `load_rate_series_from_sqlite()` returns a mapping keyed by
214
+ `(symbol, integer timeframe)`. Unless `explicit_tables` is supplied, it
215
+ requires existing managed `rate_*` compatibility views and raises
216
+ `ValueError` when they are missing. Duplicate `(symbol, timeframe)` targets
217
+ are rejected.
@@ -2,13 +2,28 @@
2
2
 
3
3
  from importlib.metadata import version
4
4
 
5
- from .history import load_rate_data, load_rate_data_from_connection
5
+ from .history import (
6
+ RateTarget,
7
+ build_rate_targets,
8
+ build_rate_view_name,
9
+ load_rate_data,
10
+ load_rate_data_from_connection,
11
+ load_rate_series_from_sqlite,
12
+ resolve_history_datasets,
13
+ resolve_history_tick_flags,
14
+ resolve_history_timeframes,
15
+ resolve_rate_tables,
16
+ resolve_rate_view_name,
17
+ resolve_rate_view_names,
18
+ )
6
19
  from .sdk import (
20
+ AccountSpec,
7
21
  Mt5CliClient,
8
22
  account_info,
9
23
  build_config,
10
24
  collect_history,
11
25
  collect_latest_rates,
26
+ collect_latest_rates_for_accounts,
12
27
  copy_rates_from,
13
28
  copy_rates_from_pos,
14
29
  copy_rates_range,
@@ -20,6 +35,7 @@ from .sdk import (
20
35
  latest_rates,
21
36
  market_book,
22
37
  minimum_margins,
38
+ mt5_session,
23
39
  mt5_summary,
24
40
  mt5_summary_as_df,
25
41
  orders,
@@ -37,23 +53,35 @@ from .sdk import (
37
53
  version as mt5_version,
38
54
  )
39
55
  from .utils import (
56
+ TICK_FLAG_MAP,
57
+ TIMEFRAME_MAP,
40
58
  Dataset,
41
59
  IfExists,
42
60
  detect_format,
43
61
  export_dataframe,
44
62
  export_dataframe_to_sqlite,
63
+ parse_datetime,
64
+ parse_tick_flags,
65
+ parse_timeframe,
45
66
  )
46
67
 
47
68
  __version__ = version(__package__) if __package__ else None
48
69
 
49
70
  __all__ = [
71
+ "TICK_FLAG_MAP",
72
+ "TIMEFRAME_MAP",
73
+ "AccountSpec",
50
74
  "Dataset",
51
75
  "IfExists",
52
76
  "Mt5CliClient",
77
+ "RateTarget",
53
78
  "account_info",
54
79
  "build_config",
80
+ "build_rate_targets",
81
+ "build_rate_view_name",
55
82
  "collect_history",
56
83
  "collect_latest_rates",
84
+ "collect_latest_rates_for_accounts",
57
85
  "copy_rates_from",
58
86
  "copy_rates_from_pos",
59
87
  "copy_rates_range",
@@ -68,15 +96,26 @@ __all__ = [
68
96
  "latest_rates",
69
97
  "load_rate_data",
70
98
  "load_rate_data_from_connection",
99
+ "load_rate_series_from_sqlite",
71
100
  "market_book",
72
101
  "minimum_margins",
102
+ "mt5_session",
73
103
  "mt5_summary",
74
104
  "mt5_summary_as_df",
75
105
  "mt5_version",
76
106
  "orders",
107
+ "parse_datetime",
108
+ "parse_tick_flags",
109
+ "parse_timeframe",
77
110
  "positions",
78
111
  "recent_history_deals",
79
112
  "recent_ticks",
113
+ "resolve_history_datasets",
114
+ "resolve_history_tick_flags",
115
+ "resolve_history_timeframes",
116
+ "resolve_rate_tables",
117
+ "resolve_rate_view_name",
118
+ "resolve_rate_view_names",
80
119
  "symbol_info",
81
120
  "symbol_info_tick",
82
121
  "symbols",
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import logging
6
6
  import sqlite3
7
+ from dataclasses import dataclass
7
8
  from datetime import UTC, datetime
8
9
  from pathlib import Path
9
10
  from typing import TYPE_CHECKING, Literal, cast
@@ -20,7 +21,7 @@ from .utils import (
20
21
  )
21
22
 
22
23
  if TYPE_CHECKING:
23
- from collections.abc import Callable, Sequence
24
+ from collections.abc import Callable, Mapping, Sequence
24
25
 
25
26
  from pdmt5 import Mt5DataClient
26
27
 
@@ -135,14 +136,17 @@ def _require_non_empty_identifier(identifier: str, kind: str) -> str:
135
136
 
136
137
 
137
138
  def _open_history_connection(
138
- conn_or_path: SqliteConnOrPath,
139
+ conn_or_path: SqliteConnOrPath | None,
139
140
  ) -> tuple[sqlite3.Connection | None, bool]:
140
141
  """Open a read-only SQLite connection when given a path.
141
142
 
142
143
  Returns:
143
- A connection and whether the caller should close it. When the path does
144
- not exist, returns ``(None, False)`` without creating a database file.
144
+ A connection and whether the caller should close it. When ``conn_or_path``
145
+ is None or the path does not exist, returns ``(None, False)`` without
146
+ creating a database file.
145
147
  """
148
+ if conn_or_path is None:
149
+ return None, False
146
150
  if isinstance(conn_or_path, sqlite3.Connection):
147
151
  return conn_or_path, False
148
152
  path = Path(conn_or_path)
@@ -376,7 +380,7 @@ def _resolve_rate_view_name_from_context(
376
380
 
377
381
 
378
382
  def resolve_rate_view_name(
379
- conn_or_path: SqliteConnOrPath,
383
+ conn_or_path: SqliteConnOrPath | None,
380
384
  symbol: str,
381
385
  granularity: str,
382
386
  *,
@@ -385,7 +389,9 @@ def resolve_rate_view_name(
385
389
  """Resolve the mt5cli-managed rate compatibility view name.
386
390
 
387
391
  Args:
388
- conn_or_path: SQLite database path or open connection.
392
+ conn_or_path: SQLite database path or open connection. When None or a
393
+ non-existing path and ``require_existing`` is False, the deterministic
394
+ default view name is returned without creating a database file.
389
395
  symbol: Symbol stored in the normalized ``rates`` table.
390
396
  granularity: Timeframe name (for example ``M1``) or integer string.
391
397
  require_existing: When True, require the database and a managed view to exist.
@@ -429,7 +435,7 @@ def resolve_rate_view_name(
429
435
 
430
436
 
431
437
  def resolve_rate_view_names(
432
- conn_or_path: SqliteConnOrPath,
438
+ conn_or_path: SqliteConnOrPath | None,
433
439
  symbols: Sequence[str],
434
440
  granularities: Sequence[str],
435
441
  *,
@@ -438,7 +444,9 @@ def resolve_rate_view_names(
438
444
  """Resolve rate compatibility view names for symbol and granularity pairs.
439
445
 
440
446
  Args:
441
- conn_or_path: SQLite database path or open connection.
447
+ conn_or_path: SQLite database path or open connection. When None or a
448
+ non-existing path and ``require_existing`` is False, deterministic
449
+ default view names are returned without creating a database file.
442
450
  symbols: Symbols stored in the normalized ``rates`` table.
443
451
  granularities: Timeframe names (for example ``M1``) or integer strings.
444
452
  require_existing: When True, require the database and managed views to exist.
@@ -482,6 +490,222 @@ def resolve_rate_view_names(
482
490
  conn.close()
483
491
 
484
492
 
493
+ @dataclass(frozen=True)
494
+ class RateTarget:
495
+ """A single rate series identified by symbol and timeframe.
496
+
497
+ Attributes:
498
+ symbol: MT5 symbol name, or None when the rate series is addressed only
499
+ by an explicit table (for example a custom SQLite view).
500
+ timeframe: MT5 timeframe as an integer or name (for example ``M1``).
501
+ """
502
+
503
+ symbol: str | None
504
+ timeframe: int | str
505
+
506
+ def __post_init__(self) -> None:
507
+ """Normalize accepted timeframe aliases to the stored integer value."""
508
+ if not isinstance(self.timeframe, int):
509
+ object.__setattr__(self, "timeframe", parse_timeframe(self.timeframe))
510
+
511
+ @property
512
+ def timeframe_int(self) -> int:
513
+ """Return the timeframe as its integer MT5 value."""
514
+ return cast("int", self.timeframe)
515
+
516
+
517
+ def build_rate_targets(
518
+ symbols: Sequence[str],
519
+ timeframes: Sequence[int | str],
520
+ *,
521
+ allow_missing_symbol: bool = False,
522
+ ) -> list[RateTarget]:
523
+ """Build rate targets for every symbol and timeframe combination.
524
+
525
+ Args:
526
+ symbols: MT5 symbol names. May be empty when ``allow_missing_symbol``.
527
+ timeframes: MT5 timeframes as integers or names (for example ``M1``).
528
+ allow_missing_symbol: When True and ``symbols`` is empty, build targets
529
+ with ``symbol=None`` for each timeframe instead of raising.
530
+
531
+ Returns:
532
+ Targets in row-major order: every timeframe for the first symbol, then
533
+ every timeframe for the next symbol, and so on.
534
+
535
+ Raises:
536
+ ValueError: If ``timeframes`` is empty, or ``symbols`` is empty and
537
+ ``allow_missing_symbol`` is False.
538
+ """
539
+ if not timeframes:
540
+ msg = "At least one timeframe is required."
541
+ raise ValueError(msg)
542
+ if not symbols:
543
+ if not allow_missing_symbol:
544
+ msg = "At least one symbol is required."
545
+ raise ValueError(msg)
546
+ return [RateTarget(symbol=None, timeframe=tf) for tf in timeframes]
547
+ return [
548
+ RateTarget(symbol=symbol, timeframe=tf)
549
+ for symbol in symbols
550
+ for tf in timeframes
551
+ ]
552
+
553
+
554
+ def resolve_rate_tables(
555
+ conn_or_path: SqliteConnOrPath | None,
556
+ targets: Sequence[RateTarget],
557
+ explicit_tables: Sequence[str] | None = None,
558
+ *,
559
+ require_existing: bool = False,
560
+ ) -> list[str]:
561
+ """Resolve SQLite table or view names for rate targets.
562
+
563
+ Args:
564
+ conn_or_path: SQLite database path or open connection. May be None when
565
+ ``explicit_tables`` is provided, or when ``require_existing`` is
566
+ False and deterministic default view names are sufficient.
567
+ targets: Rate targets to resolve.
568
+ explicit_tables: Optional explicit table or view names. When provided,
569
+ they are used as-is and must match the number of targets.
570
+ require_existing: When True, require the database and managed views to
571
+ exist for each symbol target. Ignored when ``explicit_tables`` is
572
+ provided.
573
+
574
+ Returns:
575
+ Table or view names aligned with ``targets``.
576
+
577
+ Raises:
578
+ ValueError: If ``targets`` is empty, ``explicit_tables`` length does not
579
+ match the target count, a target without a symbol is resolved
580
+ without an explicit table, or ``require_existing`` is True and the
581
+ database or a managed view is missing.
582
+ """
583
+ target_list = list(targets)
584
+ if not target_list:
585
+ msg = "At least one rate target is required."
586
+ raise ValueError(msg)
587
+ if explicit_tables is not None:
588
+ tables = list(explicit_tables)
589
+ if len(tables) != len(target_list):
590
+ msg = (
591
+ f"Expected {len(target_list)} explicit table(s) "
592
+ f"to match the targets, got {len(tables)}."
593
+ )
594
+ raise ValueError(msg)
595
+ return tables
596
+ if any(target.symbol is None for target in target_list):
597
+ msg = (
598
+ "Cannot resolve a rate table for a target without a symbol; "
599
+ "provide explicit_tables."
600
+ )
601
+ raise ValueError(msg)
602
+ conn, should_close = _open_history_connection(conn_or_path)
603
+ try:
604
+ if conn is None:
605
+ if require_existing:
606
+ path = (
607
+ conn_or_path
608
+ if isinstance(conn_or_path, (Path, str))
609
+ else "database"
610
+ )
611
+ msg = f"SQLite database not found: {path}"
612
+ raise ValueError(msg)
613
+ timeframe_counts = None
614
+ existing_views: set[str] = set()
615
+ else:
616
+ timeframe_counts = _load_rates_timeframe_counts(conn)
617
+ existing_views = _load_existing_rate_views(conn)
618
+ resolved: list[str] = []
619
+ for target in target_list:
620
+ symbol = cast("str", target.symbol)
621
+ timeframe = target.timeframe_int
622
+ resolved.append(
623
+ _resolve_rate_view_name_from_context(
624
+ symbol=symbol,
625
+ timeframe=timeframe,
626
+ granularity_name=resolve_granularity_name(timeframe),
627
+ timeframe_counts=timeframe_counts,
628
+ existing_views=existing_views,
629
+ require_existing=require_existing,
630
+ ),
631
+ )
632
+ return resolved
633
+ finally:
634
+ if should_close and conn is not None:
635
+ conn.close()
636
+
637
+
638
+ def load_rate_series_from_sqlite(
639
+ conn_or_path: SqliteConnOrPath,
640
+ targets: Sequence[RateTarget],
641
+ count: int,
642
+ explicit_tables: Sequence[str] | None = None,
643
+ ) -> dict[tuple[str | None, int], pd.DataFrame]:
644
+ """Load multiple rate series from a SQLite database.
645
+
646
+ Args:
647
+ conn_or_path: SQLite database path or open connection.
648
+ targets: Rate targets to load. Each ``(symbol, timeframe_int)`` pair
649
+ must be unique.
650
+ count: Number of most recent rows to load per series.
651
+ explicit_tables: Optional explicit table or view names matching targets.
652
+ When omitted, managed ``rate_*`` compatibility views must already
653
+ exist in the database.
654
+
655
+ Returns:
656
+ Mapping keyed by ``(symbol, timeframe_int)`` to each rate DataFrame.
657
+
658
+ Raises:
659
+ ValueError: If ``count`` is not positive, targets are empty, duplicate
660
+ ``(symbol, timeframe_int)`` pairs are present, or table resolution
661
+ fails.
662
+ """
663
+ if count <= 0:
664
+ msg = "count must be positive."
665
+ raise ValueError(msg)
666
+ target_list = list(targets)
667
+ if not target_list:
668
+ msg = "At least one rate target is required."
669
+ raise ValueError(msg)
670
+ if explicit_tables is None and any(target.symbol is None for target in target_list):
671
+ msg = (
672
+ "Cannot resolve a rate table for a target without a symbol; "
673
+ "provide explicit_tables."
674
+ )
675
+ raise ValueError(msg)
676
+ seen_keys: set[tuple[str | None, int]] = set()
677
+ for target in target_list:
678
+ key = (target.symbol, target.timeframe_int)
679
+ if key in seen_keys:
680
+ symbol_repr = repr(target.symbol)
681
+ msg = f"Duplicate rate target: ({symbol_repr}, {target.timeframe_int})"
682
+ raise ValueError(msg)
683
+ seen_keys.add(key)
684
+ tables = (
685
+ resolve_rate_tables(None, target_list, explicit_tables)
686
+ if explicit_tables is not None
687
+ else None
688
+ )
689
+ conn, should_close = _open_existing_sqlite_database(conn_or_path)
690
+ try:
691
+ resolved_tables = tables or resolve_rate_tables(
692
+ conn,
693
+ target_list,
694
+ require_existing=True,
695
+ )
696
+ return {
697
+ (target.symbol, target.timeframe_int): load_rate_data_from_connection(
698
+ conn,
699
+ table,
700
+ count=count,
701
+ )
702
+ for target, table in zip(target_list, resolved_tables, strict=True)
703
+ }
704
+ finally:
705
+ if should_close:
706
+ conn.close()
707
+
708
+
485
709
  def get_table_columns(conn: sqlite3.Connection, table: str) -> set[str]:
486
710
  """Return existing SQLite columns for a table."""
487
711
  quoted_table = quote_sqlite_identifier(table)
@@ -765,7 +989,20 @@ def drop_duplicates_in_table(
765
989
  )
766
990
 
767
991
 
768
- DedupScope = tuple[str, tuple[object, ...]]
992
+ @dataclass(frozen=True)
993
+ class DedupScope:
994
+ """Scoped deduplication predicate and the columns it references.
995
+
996
+ Attributes:
997
+ where: SQL predicate appended to the duplicate-removal query.
998
+ params: Parameters bound to the scope predicate.
999
+ required_columns: Columns that must be present in the written table for
1000
+ the scope to run.
1001
+ """
1002
+
1003
+ where: str
1004
+ params: tuple[object, ...]
1005
+ required_columns: frozenset[str]
769
1006
 
770
1007
 
771
1008
  def _record_dedup_scope(
@@ -773,17 +1010,25 @@ def _record_dedup_scope(
773
1010
  dataset: Dataset,
774
1011
  scope_where: str,
775
1012
  scope_params: tuple[object, ...],
1013
+ required_columns: frozenset[str],
776
1014
  ) -> None:
777
- dedup_scopes.setdefault(dataset, []).append((scope_where, scope_params))
1015
+ dedup_scopes.setdefault(dataset, []).append(
1016
+ DedupScope(scope_where, scope_params, required_columns),
1017
+ )
778
1018
 
779
1019
 
780
1020
  def deduplicate_history_tables(
781
1021
  conn: sqlite3.Connection,
782
1022
  written_columns: dict[Dataset, set[str]],
783
1023
  written_tables: set[Dataset],
784
- dedup_scopes: dict[Dataset, list[DedupScope]] | None = None,
1024
+ dedup_scopes: Mapping[Dataset, Sequence[DedupScope]] | None = None,
785
1025
  ) -> None:
786
- """Deduplicate appended history tables by stable identifiers."""
1026
+ """Deduplicate appended history tables by stable identifiers.
1027
+
1028
+ Scopes whose required columns are not present in the written table are
1029
+ skipped. If all scopes for a dataset are skipped, the table receives one
1030
+ unscoped deduplication pass instead.
1031
+ """
787
1032
  cursor = conn.cursor()
788
1033
  for dataset in written_tables:
789
1034
  columns = written_columns.get(dataset, set())
@@ -802,16 +1047,19 @@ def deduplicate_history_tables(
802
1047
  table,
803
1048
  )
804
1049
  continue
805
- scopes = dedup_scopes.get(dataset, []) if dedup_scopes else []
1050
+ raw_scopes: Sequence[DedupScope] = (
1051
+ dedup_scopes.get(dataset, ()) if dedup_scopes else ()
1052
+ )
1053
+ scopes = [scope for scope in raw_scopes if scope.required_columns <= columns]
806
1054
  if scopes:
807
- for scope_where, scope_params in scopes:
1055
+ for scope in scopes:
808
1056
  drop_duplicates_in_table(
809
1057
  cursor,
810
1058
  table,
811
1059
  list(keys),
812
1060
  keep="last",
813
- scope_where=scope_where,
814
- scope_params=scope_params,
1061
+ scope_where=scope.where,
1062
+ scope_params=scope.params,
815
1063
  )
816
1064
  continue
817
1065
  drop_duplicates_in_table(cursor, table, list(keys), keep="last")
@@ -1178,6 +1426,7 @@ def _write_incremental_rates(
1178
1426
  Dataset.rates,
1179
1427
  "symbol = ? AND timeframe = ? AND time >= ?",
1180
1428
  (symbol, timeframe, start_date),
1429
+ frozenset({"symbol", "timeframe", "time"}),
1181
1430
  )
1182
1431
 
1183
1432
 
@@ -1216,6 +1465,7 @@ def _write_incremental_ticks(
1216
1465
  Dataset.ticks,
1217
1466
  "symbol = ? AND time >= ?",
1218
1467
  (symbol, start_date),
1468
+ frozenset({"symbol", "time"}),
1219
1469
  )
1220
1470
 
1221
1471
 
@@ -1254,6 +1504,7 @@ def _write_incremental_history_orders(
1254
1504
  Dataset.history_orders,
1255
1505
  "symbol = ? AND time >= ?",
1256
1506
  (symbol, start_date),
1507
+ frozenset({"symbol", "time"}),
1257
1508
  )
1258
1509
 
1259
1510
 
@@ -1307,6 +1558,7 @@ def _write_incremental_history_deals(
1307
1558
  Dataset.history_deals,
1308
1559
  "symbol = ? AND time >= ?",
1309
1560
  (symbol, start_by_symbol[symbol, None]),
1561
+ frozenset({"symbol", "time"}),
1310
1562
  )
1311
1563
  if "type" in columns:
1312
1564
  _record_dedup_scope(
@@ -1314,6 +1566,7 @@ def _write_incremental_history_deals(
1314
1566
  Dataset.history_deals,
1315
1567
  f"type NOT IN {_TRADE_DEAL_TYPES_SQL} AND time >= ?",
1316
1568
  (account_event_start,),
1569
+ frozenset({"type", "time"}),
1317
1570
  )
1318
1571
  if "type" not in columns and "symbol" in columns:
1319
1572
  _record_dedup_scope(
@@ -1321,6 +1574,7 @@ def _write_incremental_history_deals(
1321
1574
  Dataset.history_deals,
1322
1575
  "(symbol IS NULL OR symbol = '') AND time >= ?",
1323
1576
  (account_event_start,),
1577
+ frozenset({"symbol", "time"}),
1324
1578
  )
1325
1579
  return
1326
1580
  start_by_symbol = load_incremental_start_datetimes(
@@ -1348,6 +1602,7 @@ def _write_incremental_history_deals(
1348
1602
  Dataset.history_deals,
1349
1603
  "symbol = ? AND time >= ?",
1350
1604
  (symbol, start_date),
1605
+ frozenset({"symbol", "time"}),
1351
1606
  )
1352
1607
 
1353
1608