mt5cli 0.5.1__tar.gz → 0.5.3__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 (38) hide show
  1. {mt5cli-0.5.1 → mt5cli-0.5.3}/PKG-INFO +5 -2
  2. {mt5cli-0.5.1 → mt5cli-0.5.3}/README.md +4 -1
  3. {mt5cli-0.5.1 → mt5cli-0.5.3}/docs/api/history.md +14 -2
  4. mt5cli-0.5.3/docs/api/sdk.md +83 -0
  5. {mt5cli-0.5.1 → mt5cli-0.5.3}/mt5cli/__init__.py +12 -0
  6. {mt5cli-0.5.1 → mt5cli-0.5.3}/mt5cli/history.py +89 -9
  7. {mt5cli-0.5.1 → mt5cli-0.5.3}/mt5cli/sdk.py +323 -2
  8. {mt5cli-0.5.1 → mt5cli-0.5.3}/pyproject.toml +1 -1
  9. {mt5cli-0.5.1 → mt5cli-0.5.3}/tests/test_history.py +187 -3
  10. {mt5cli-0.5.1 → mt5cli-0.5.3}/tests/test_sdk.py +312 -0
  11. {mt5cli-0.5.1 → mt5cli-0.5.3}/uv.lock +1 -1
  12. mt5cli-0.5.1/docs/api/sdk.md +0 -3
  13. {mt5cli-0.5.1 → mt5cli-0.5.3}/.agents/skills/local-qa/SKILL.md +0 -0
  14. {mt5cli-0.5.1 → mt5cli-0.5.3}/.agents/skills/local-qa/scripts/qa.sh +0 -0
  15. {mt5cli-0.5.1 → mt5cli-0.5.3}/.agents/skills/mt5cli/SKILL.md +0 -0
  16. {mt5cli-0.5.1 → mt5cli-0.5.3}/.claude/agents/codex.md +0 -0
  17. {mt5cli-0.5.1 → mt5cli-0.5.3}/.claude/settings.json +0 -0
  18. {mt5cli-0.5.1 → mt5cli-0.5.3}/.github/FUNDING.yml +0 -0
  19. {mt5cli-0.5.1 → mt5cli-0.5.3}/.github/dependabot.yml +0 -0
  20. {mt5cli-0.5.1 → mt5cli-0.5.3}/.github/renovate.json +0 -0
  21. {mt5cli-0.5.1 → mt5cli-0.5.3}/.github/workflows/ci.yml +0 -0
  22. {mt5cli-0.5.1 → mt5cli-0.5.3}/.github/workflows/claude.yml +0 -0
  23. {mt5cli-0.5.1 → mt5cli-0.5.3}/.github/workflows/release.yml +0 -0
  24. {mt5cli-0.5.1 → mt5cli-0.5.3}/.gitignore +0 -0
  25. {mt5cli-0.5.1 → mt5cli-0.5.3}/AGENTS.md +0 -0
  26. {mt5cli-0.5.1 → mt5cli-0.5.3}/CLAUDE.md +0 -0
  27. {mt5cli-0.5.1 → mt5cli-0.5.3}/LICENSE +0 -0
  28. {mt5cli-0.5.1 → mt5cli-0.5.3}/docs/api/cli.md +0 -0
  29. {mt5cli-0.5.1 → mt5cli-0.5.3}/docs/api/index.md +0 -0
  30. {mt5cli-0.5.1 → mt5cli-0.5.3}/docs/api/utils.md +0 -0
  31. {mt5cli-0.5.1 → mt5cli-0.5.3}/docs/index.md +0 -0
  32. {mt5cli-0.5.1 → mt5cli-0.5.3}/mkdocs.yml +0 -0
  33. {mt5cli-0.5.1 → mt5cli-0.5.3}/mt5cli/__main__.py +0 -0
  34. {mt5cli-0.5.1 → mt5cli-0.5.3}/mt5cli/cli.py +0 -0
  35. {mt5cli-0.5.1 → mt5cli-0.5.3}/mt5cli/utils.py +0 -0
  36. {mt5cli-0.5.1 → mt5cli-0.5.3}/tests/__init__.py +0 -0
  37. {mt5cli-0.5.1 → mt5cli-0.5.3}/tests/test_cli.py +0 -0
  38. {mt5cli-0.5.1 → mt5cli-0.5.3}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mt5cli
3
- Version: 0.5.1
3
+ Version: 0.5.3
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>
@@ -160,7 +160,10 @@ update_history_with_config(
160
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
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.
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. For long-running pollers, `collect_latest_rates_for_accounts_with_retries()` adds bounded exponential backoff that retries only `pdmt5.Mt5TradingError` / `pdmt5.Mt5RuntimeError` and re-raises once `retry_count` is exhausted.
164
+ - **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.
165
+ - **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` and let the caller decide logging.
166
+ - **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.
164
167
  - **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.
165
168
  - **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns.
166
169
  - **Recent ticks and margins**: `recent_ticks()` and `minimum_margins()` SDK helpers (and matching CLI commands) cover common downstream read-only queries.
@@ -136,7 +136,10 @@ update_history_with_config(
136
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
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.
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. For long-running pollers, `collect_latest_rates_for_accounts_with_retries()` adds bounded exponential backoff that retries only `pdmt5.Mt5TradingError` / `pdmt5.Mt5RuntimeError` and re-raises once `retry_count` is exhausted.
140
+ - **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.
141
+ - **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` and let the caller decide logging.
142
+ - **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.
140
143
  - **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.
141
144
  - **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns.
142
145
  - **Recent ticks and margins**: `recent_ticks()` and `minimum_margins()` SDK helpers (and matching CLI commands) cover common downstream read-only queries.
@@ -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
@@ -215,3 +215,15 @@ frame = series["EURUSD", 1] # keyed by (symbol, integer timeframe)
215
215
  requires existing managed `rate_*` compatibility views and raises
216
216
  `ValueError` when they are missing. Duplicate `(symbol, timeframe)` targets
217
217
  are rejected.
218
+ - `load_rate_series_by_granularity()` is a thin wrapper that builds the targets,
219
+ loads the series, and rekeys the result by granularity name to avoid
220
+ converting integer timeframes downstream:
221
+
222
+ ```python
223
+ from mt5cli import load_rate_series_by_granularity
224
+
225
+ series = load_rate_series_by_granularity(
226
+ "history.db", ["EURUSD"], ["M1", "H1"], count=1000
227
+ )
228
+ frame = series["EURUSD", "M1"] # keyed by (symbol | None, granularity_name)
229
+ ```
@@ -0,0 +1,83 @@
1
+ # SDK Module
2
+
3
+ ::: mt5cli.sdk
4
+
5
+ ## Resilient multi-account orchestration
6
+
7
+ The SDK ships strategy-agnostic helpers for building long-running collectors on
8
+ top of the read-only client. None of them depend on a particular trading
9
+ application.
10
+
11
+ ### Retrying transient rate collection
12
+
13
+ `collect_latest_rates_for_accounts_with_retries()` wraps
14
+ `collect_latest_rates_for_accounts()` with bounded exponential backoff. Only
15
+ `pdmt5.Mt5TradingError` and `pdmt5.Mt5RuntimeError` are retried; the final
16
+ failure is re-raised once `retry_count` is exhausted.
17
+
18
+ ```python
19
+ from mt5cli import AccountSpec, collect_latest_rates_for_accounts_with_retries
20
+
21
+ accounts = [AccountSpec(symbols=["EURUSD"], login=12345)]
22
+ rates = collect_latest_rates_for_accounts_with_retries(
23
+ accounts,
24
+ ["M1", "H1"],
25
+ count=500,
26
+ retry_count=3,
27
+ backoff_base=2, # sleeps 2s, 4s, 8s between attempts
28
+ )
29
+ ```
30
+
31
+ ### Resolving credentials and `${ENV_VAR}` placeholders
32
+
33
+ `resolve_account_spec()` / `resolve_account_specs()` merge explicit override
34
+ values over `AccountSpec` fields and expand `${ENV_VAR}` placeholders, keeping
35
+ secrets out of plan/config files. A missing environment variable raises
36
+ `ValueError`.
37
+
38
+ ```python
39
+ import os
40
+
41
+ from mt5cli import AccountSpec, resolve_account_specs
42
+
43
+ os.environ["MT5_LOGIN"] = "12345"
44
+ os.environ["MT5_PASSWORD"] = "secret"
45
+ accounts = [
46
+ AccountSpec(symbols=["EURUSD"], login="${MT5_LOGIN}", password="${MT5_PASSWORD}")
47
+ ]
48
+
49
+ resolved = resolve_account_specs(accounts, server="Broker-Demo")
50
+ # resolved[0].login == "12345", resolved[0].server == "Broker-Demo"
51
+ ```
52
+
53
+ ### Throttled incremental history updates
54
+
55
+ `ThrottledHistoryUpdater` wraps `update_history()` with a minimum interval
56
+ between successful runs (using a monotonic clock), so an application loop can
57
+ call it every iteration without over-fetching.
58
+
59
+ ```python
60
+ from pdmt5 import Mt5Config, Mt5DataClient
61
+
62
+ from mt5cli import Dataset, ThrottledHistoryUpdater
63
+
64
+ updater = ThrottledHistoryUpdater(
65
+ output="history.db",
66
+ datasets={Dataset.rates},
67
+ timeframes=["M1"],
68
+ interval_seconds=60, # <= 0 updates on every call
69
+ )
70
+
71
+ client = Mt5DataClient(config=Mt5Config(login=12345))
72
+ client.initialize_and_login_mt5()
73
+ try:
74
+ while True:
75
+ updater.update(client, ["EURUSD", "GBPUSD"]) # no-op until 60s elapse
76
+ # ... do other work; break when shutting down ...
77
+ finally:
78
+ client.shutdown()
79
+ ```
80
+
81
+ By default `Mt5TradingError`, `Mt5RuntimeError`, and `sqlite3.Error` propagate so
82
+ the caller controls logging; pass `suppress_errors=True` to swallow them and
83
+ return `False` without advancing the throttle.
@@ -8,6 +8,7 @@ from .history import (
8
8
  build_rate_view_name,
9
9
  load_rate_data,
10
10
  load_rate_data_from_connection,
11
+ load_rate_series_by_granularity,
11
12
  load_rate_series_from_sqlite,
12
13
  resolve_history_datasets,
13
14
  resolve_history_tick_flags,
@@ -19,11 +20,13 @@ from .history import (
19
20
  from .sdk import (
20
21
  AccountSpec,
21
22
  Mt5CliClient,
23
+ ThrottledHistoryUpdater,
22
24
  account_info,
23
25
  build_config,
24
26
  collect_history,
25
27
  collect_latest_rates,
26
28
  collect_latest_rates_for_accounts,
29
+ collect_latest_rates_for_accounts_with_retries,
27
30
  copy_rates_from,
28
31
  copy_rates_from_pos,
29
32
  copy_rates_range,
@@ -42,6 +45,9 @@ from .sdk import (
42
45
  positions,
43
46
  recent_history_deals,
44
47
  recent_ticks,
48
+ resolve_account_spec,
49
+ resolve_account_specs,
50
+ substitute_env_placeholders,
45
51
  symbol_info,
46
52
  symbol_info_tick,
47
53
  symbols,
@@ -75,6 +81,7 @@ __all__ = [
75
81
  "IfExists",
76
82
  "Mt5CliClient",
77
83
  "RateTarget",
84
+ "ThrottledHistoryUpdater",
78
85
  "account_info",
79
86
  "build_config",
80
87
  "build_rate_targets",
@@ -82,6 +89,7 @@ __all__ = [
82
89
  "collect_history",
83
90
  "collect_latest_rates",
84
91
  "collect_latest_rates_for_accounts",
92
+ "collect_latest_rates_for_accounts_with_retries",
85
93
  "copy_rates_from",
86
94
  "copy_rates_from_pos",
87
95
  "copy_rates_range",
@@ -96,6 +104,7 @@ __all__ = [
96
104
  "latest_rates",
97
105
  "load_rate_data",
98
106
  "load_rate_data_from_connection",
107
+ "load_rate_series_by_granularity",
99
108
  "load_rate_series_from_sqlite",
100
109
  "market_book",
101
110
  "minimum_margins",
@@ -110,12 +119,15 @@ __all__ = [
110
119
  "positions",
111
120
  "recent_history_deals",
112
121
  "recent_ticks",
122
+ "resolve_account_spec",
123
+ "resolve_account_specs",
113
124
  "resolve_history_datasets",
114
125
  "resolve_history_tick_flags",
115
126
  "resolve_history_timeframes",
116
127
  "resolve_rate_tables",
117
128
  "resolve_rate_view_name",
118
129
  "resolve_rate_view_names",
130
+ "substitute_env_placeholders",
119
131
  "symbol_info",
120
132
  "symbol_info_tick",
121
133
  "symbols",
@@ -21,7 +21,7 @@ from .utils import (
21
21
  )
22
22
 
23
23
  if TYPE_CHECKING:
24
- from collections.abc import Callable, Sequence
24
+ from collections.abc import Callable, Mapping, Sequence
25
25
 
26
26
  from pdmt5 import Mt5DataClient
27
27
 
@@ -706,6 +706,55 @@ def load_rate_series_from_sqlite(
706
706
  conn.close()
707
707
 
708
708
 
709
+ def load_rate_series_by_granularity(
710
+ conn_or_path: SqliteConnOrPath,
711
+ symbols: Sequence[str],
712
+ granularities: Sequence[int | str],
713
+ count: int,
714
+ *,
715
+ explicit_tables: Sequence[str] | None = None,
716
+ allow_missing_symbol: bool = False,
717
+ ) -> dict[tuple[str | None, str], pd.DataFrame]:
718
+ """Load rate series keyed by symbol and string granularity name.
719
+
720
+ Builds targets with :func:`build_rate_targets` and loads them with
721
+ :func:`load_rate_series_from_sqlite`, then rekeys the result by granularity
722
+ name (for example ``M1``) instead of the integer timeframe to reduce
723
+ downstream boilerplate.
724
+
725
+ Args:
726
+ conn_or_path: SQLite database path or open connection.
727
+ symbols: MT5 symbol names. May be empty when ``allow_missing_symbol``.
728
+ granularities: MT5 timeframes as integers or names (for example ``M1``).
729
+ count: Number of most recent rows to load per series.
730
+ explicit_tables: Optional explicit table or view names matching the
731
+ built targets in row-major order. Required when symbols are omitted.
732
+ allow_missing_symbol: When True and ``symbols`` is empty, build targets
733
+ with ``symbol=None`` for each granularity instead of raising.
734
+
735
+ Returns:
736
+ Mapping keyed by ``(symbol | None, granularity_name)`` to each rate
737
+ DataFrame. Propagates ``ValueError`` (via :func:`build_rate_targets` and
738
+ :func:`load_rate_series_from_sqlite`) when inputs are empty or invalid,
739
+ table resolution fails, or duplicate targets are present.
740
+ """
741
+ targets = build_rate_targets(
742
+ symbols,
743
+ granularities,
744
+ allow_missing_symbol=allow_missing_symbol,
745
+ )
746
+ series = load_rate_series_from_sqlite(
747
+ conn_or_path,
748
+ targets,
749
+ count,
750
+ explicit_tables=explicit_tables,
751
+ )
752
+ return {
753
+ (symbol, resolve_granularity_name(timeframe)): frame
754
+ for (symbol, timeframe), frame in series.items()
755
+ }
756
+
757
+
709
758
  def get_table_columns(conn: sqlite3.Connection, table: str) -> set[str]:
710
759
  """Return existing SQLite columns for a table."""
711
760
  quoted_table = quote_sqlite_identifier(table)
@@ -989,7 +1038,20 @@ def drop_duplicates_in_table(
989
1038
  )
990
1039
 
991
1040
 
992
- DedupScope = tuple[str, tuple[object, ...]]
1041
+ @dataclass(frozen=True)
1042
+ class DedupScope:
1043
+ """Scoped deduplication predicate and the columns it references.
1044
+
1045
+ Attributes:
1046
+ where: SQL predicate appended to the duplicate-removal query.
1047
+ params: Parameters bound to the scope predicate.
1048
+ required_columns: Columns that must be present in the written table for
1049
+ the scope to run.
1050
+ """
1051
+
1052
+ where: str
1053
+ params: tuple[object, ...]
1054
+ required_columns: frozenset[str]
993
1055
 
994
1056
 
995
1057
  def _record_dedup_scope(
@@ -997,17 +1059,25 @@ def _record_dedup_scope(
997
1059
  dataset: Dataset,
998
1060
  scope_where: str,
999
1061
  scope_params: tuple[object, ...],
1062
+ required_columns: frozenset[str],
1000
1063
  ) -> None:
1001
- dedup_scopes.setdefault(dataset, []).append((scope_where, scope_params))
1064
+ dedup_scopes.setdefault(dataset, []).append(
1065
+ DedupScope(scope_where, scope_params, required_columns),
1066
+ )
1002
1067
 
1003
1068
 
1004
1069
  def deduplicate_history_tables(
1005
1070
  conn: sqlite3.Connection,
1006
1071
  written_columns: dict[Dataset, set[str]],
1007
1072
  written_tables: set[Dataset],
1008
- dedup_scopes: dict[Dataset, list[DedupScope]] | None = None,
1073
+ dedup_scopes: Mapping[Dataset, Sequence[DedupScope]] | None = None,
1009
1074
  ) -> None:
1010
- """Deduplicate appended history tables by stable identifiers."""
1075
+ """Deduplicate appended history tables by stable identifiers.
1076
+
1077
+ Scopes whose required columns are not present in the written table are
1078
+ skipped. If all scopes for a dataset are skipped, the table receives one
1079
+ unscoped deduplication pass instead.
1080
+ """
1011
1081
  cursor = conn.cursor()
1012
1082
  for dataset in written_tables:
1013
1083
  columns = written_columns.get(dataset, set())
@@ -1026,16 +1096,19 @@ def deduplicate_history_tables(
1026
1096
  table,
1027
1097
  )
1028
1098
  continue
1029
- scopes = dedup_scopes.get(dataset, []) if dedup_scopes else []
1099
+ raw_scopes: Sequence[DedupScope] = (
1100
+ dedup_scopes.get(dataset, ()) if dedup_scopes else ()
1101
+ )
1102
+ scopes = [scope for scope in raw_scopes if scope.required_columns <= columns]
1030
1103
  if scopes:
1031
- for scope_where, scope_params in scopes:
1104
+ for scope in scopes:
1032
1105
  drop_duplicates_in_table(
1033
1106
  cursor,
1034
1107
  table,
1035
1108
  list(keys),
1036
1109
  keep="last",
1037
- scope_where=scope_where,
1038
- scope_params=scope_params,
1110
+ scope_where=scope.where,
1111
+ scope_params=scope.params,
1039
1112
  )
1040
1113
  continue
1041
1114
  drop_duplicates_in_table(cursor, table, list(keys), keep="last")
@@ -1402,6 +1475,7 @@ def _write_incremental_rates(
1402
1475
  Dataset.rates,
1403
1476
  "symbol = ? AND timeframe = ? AND time >= ?",
1404
1477
  (symbol, timeframe, start_date),
1478
+ frozenset({"symbol", "timeframe", "time"}),
1405
1479
  )
1406
1480
 
1407
1481
 
@@ -1440,6 +1514,7 @@ def _write_incremental_ticks(
1440
1514
  Dataset.ticks,
1441
1515
  "symbol = ? AND time >= ?",
1442
1516
  (symbol, start_date),
1517
+ frozenset({"symbol", "time"}),
1443
1518
  )
1444
1519
 
1445
1520
 
@@ -1478,6 +1553,7 @@ def _write_incremental_history_orders(
1478
1553
  Dataset.history_orders,
1479
1554
  "symbol = ? AND time >= ?",
1480
1555
  (symbol, start_date),
1556
+ frozenset({"symbol", "time"}),
1481
1557
  )
1482
1558
 
1483
1559
 
@@ -1531,6 +1607,7 @@ def _write_incremental_history_deals(
1531
1607
  Dataset.history_deals,
1532
1608
  "symbol = ? AND time >= ?",
1533
1609
  (symbol, start_by_symbol[symbol, None]),
1610
+ frozenset({"symbol", "time"}),
1534
1611
  )
1535
1612
  if "type" in columns:
1536
1613
  _record_dedup_scope(
@@ -1538,6 +1615,7 @@ def _write_incremental_history_deals(
1538
1615
  Dataset.history_deals,
1539
1616
  f"type NOT IN {_TRADE_DEAL_TYPES_SQL} AND time >= ?",
1540
1617
  (account_event_start,),
1618
+ frozenset({"type", "time"}),
1541
1619
  )
1542
1620
  if "type" not in columns and "symbol" in columns:
1543
1621
  _record_dedup_scope(
@@ -1545,6 +1623,7 @@ def _write_incremental_history_deals(
1545
1623
  Dataset.history_deals,
1546
1624
  "(symbol IS NULL OR symbol = '') AND time >= ?",
1547
1625
  (account_event_start,),
1626
+ frozenset({"symbol", "time"}),
1548
1627
  )
1549
1628
  return
1550
1629
  start_by_symbol = load_incremental_start_datetimes(
@@ -1572,6 +1651,7 @@ def _write_incremental_history_deals(
1572
1651
  Dataset.history_deals,
1573
1652
  "symbol = ? AND time >= ?",
1574
1653
  (symbol, start_date),
1654
+ frozenset({"symbol", "time"}),
1575
1655
  )
1576
1656
 
1577
1657