mt5cli 0.5.1__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.1 → mt5cli-0.5.2}/PKG-INFO +1 -1
  2. {mt5cli-0.5.1 → mt5cli-0.5.2}/docs/api/history.md +2 -2
  3. {mt5cli-0.5.1 → mt5cli-0.5.2}/mt5cli/history.py +40 -9
  4. {mt5cli-0.5.1 → mt5cli-0.5.2}/pyproject.toml +1 -1
  5. {mt5cli-0.5.1 → mt5cli-0.5.2}/tests/test_history.py +136 -3
  6. {mt5cli-0.5.1 → mt5cli-0.5.2}/uv.lock +1 -1
  7. {mt5cli-0.5.1 → mt5cli-0.5.2}/.agents/skills/local-qa/SKILL.md +0 -0
  8. {mt5cli-0.5.1 → mt5cli-0.5.2}/.agents/skills/local-qa/scripts/qa.sh +0 -0
  9. {mt5cli-0.5.1 → mt5cli-0.5.2}/.agents/skills/mt5cli/SKILL.md +0 -0
  10. {mt5cli-0.5.1 → mt5cli-0.5.2}/.claude/agents/codex.md +0 -0
  11. {mt5cli-0.5.1 → mt5cli-0.5.2}/.claude/settings.json +0 -0
  12. {mt5cli-0.5.1 → mt5cli-0.5.2}/.github/FUNDING.yml +0 -0
  13. {mt5cli-0.5.1 → mt5cli-0.5.2}/.github/dependabot.yml +0 -0
  14. {mt5cli-0.5.1 → mt5cli-0.5.2}/.github/renovate.json +0 -0
  15. {mt5cli-0.5.1 → mt5cli-0.5.2}/.github/workflows/ci.yml +0 -0
  16. {mt5cli-0.5.1 → mt5cli-0.5.2}/.github/workflows/claude.yml +0 -0
  17. {mt5cli-0.5.1 → mt5cli-0.5.2}/.github/workflows/release.yml +0 -0
  18. {mt5cli-0.5.1 → mt5cli-0.5.2}/.gitignore +0 -0
  19. {mt5cli-0.5.1 → mt5cli-0.5.2}/AGENTS.md +0 -0
  20. {mt5cli-0.5.1 → mt5cli-0.5.2}/CLAUDE.md +0 -0
  21. {mt5cli-0.5.1 → mt5cli-0.5.2}/LICENSE +0 -0
  22. {mt5cli-0.5.1 → mt5cli-0.5.2}/README.md +0 -0
  23. {mt5cli-0.5.1 → mt5cli-0.5.2}/docs/api/cli.md +0 -0
  24. {mt5cli-0.5.1 → mt5cli-0.5.2}/docs/api/index.md +0 -0
  25. {mt5cli-0.5.1 → mt5cli-0.5.2}/docs/api/sdk.md +0 -0
  26. {mt5cli-0.5.1 → mt5cli-0.5.2}/docs/api/utils.md +0 -0
  27. {mt5cli-0.5.1 → mt5cli-0.5.2}/docs/index.md +0 -0
  28. {mt5cli-0.5.1 → mt5cli-0.5.2}/mkdocs.yml +0 -0
  29. {mt5cli-0.5.1 → mt5cli-0.5.2}/mt5cli/__init__.py +0 -0
  30. {mt5cli-0.5.1 → mt5cli-0.5.2}/mt5cli/__main__.py +0 -0
  31. {mt5cli-0.5.1 → mt5cli-0.5.2}/mt5cli/cli.py +0 -0
  32. {mt5cli-0.5.1 → mt5cli-0.5.2}/mt5cli/sdk.py +0 -0
  33. {mt5cli-0.5.1 → mt5cli-0.5.2}/mt5cli/utils.py +0 -0
  34. {mt5cli-0.5.1 → mt5cli-0.5.2}/tests/__init__.py +0 -0
  35. {mt5cli-0.5.1 → mt5cli-0.5.2}/tests/test_cli.py +0 -0
  36. {mt5cli-0.5.1 → mt5cli-0.5.2}/tests/test_sdk.py +0 -0
  37. {mt5cli-0.5.1 → 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.1
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>
@@ -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
@@ -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
 
@@ -989,7 +989,20 @@ def drop_duplicates_in_table(
989
989
  )
990
990
 
991
991
 
992
- 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]
993
1006
 
994
1007
 
995
1008
  def _record_dedup_scope(
@@ -997,17 +1010,25 @@ def _record_dedup_scope(
997
1010
  dataset: Dataset,
998
1011
  scope_where: str,
999
1012
  scope_params: tuple[object, ...],
1013
+ required_columns: frozenset[str],
1000
1014
  ) -> None:
1001
- dedup_scopes.setdefault(dataset, []).append((scope_where, scope_params))
1015
+ dedup_scopes.setdefault(dataset, []).append(
1016
+ DedupScope(scope_where, scope_params, required_columns),
1017
+ )
1002
1018
 
1003
1019
 
1004
1020
  def deduplicate_history_tables(
1005
1021
  conn: sqlite3.Connection,
1006
1022
  written_columns: dict[Dataset, set[str]],
1007
1023
  written_tables: set[Dataset],
1008
- dedup_scopes: dict[Dataset, list[DedupScope]] | None = None,
1024
+ dedup_scopes: Mapping[Dataset, Sequence[DedupScope]] | None = None,
1009
1025
  ) -> None:
1010
- """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
+ """
1011
1032
  cursor = conn.cursor()
1012
1033
  for dataset in written_tables:
1013
1034
  columns = written_columns.get(dataset, set())
@@ -1026,16 +1047,19 @@ def deduplicate_history_tables(
1026
1047
  table,
1027
1048
  )
1028
1049
  continue
1029
- 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]
1030
1054
  if scopes:
1031
- for scope_where, scope_params in scopes:
1055
+ for scope in scopes:
1032
1056
  drop_duplicates_in_table(
1033
1057
  cursor,
1034
1058
  table,
1035
1059
  list(keys),
1036
1060
  keep="last",
1037
- scope_where=scope_where,
1038
- scope_params=scope_params,
1061
+ scope_where=scope.where,
1062
+ scope_params=scope.params,
1039
1063
  )
1040
1064
  continue
1041
1065
  drop_duplicates_in_table(cursor, table, list(keys), keep="last")
@@ -1402,6 +1426,7 @@ def _write_incremental_rates(
1402
1426
  Dataset.rates,
1403
1427
  "symbol = ? AND timeframe = ? AND time >= ?",
1404
1428
  (symbol, timeframe, start_date),
1429
+ frozenset({"symbol", "timeframe", "time"}),
1405
1430
  )
1406
1431
 
1407
1432
 
@@ -1440,6 +1465,7 @@ def _write_incremental_ticks(
1440
1465
  Dataset.ticks,
1441
1466
  "symbol = ? AND time >= ?",
1442
1467
  (symbol, start_date),
1468
+ frozenset({"symbol", "time"}),
1443
1469
  )
1444
1470
 
1445
1471
 
@@ -1478,6 +1504,7 @@ def _write_incremental_history_orders(
1478
1504
  Dataset.history_orders,
1479
1505
  "symbol = ? AND time >= ?",
1480
1506
  (symbol, start_date),
1507
+ frozenset({"symbol", "time"}),
1481
1508
  )
1482
1509
 
1483
1510
 
@@ -1531,6 +1558,7 @@ def _write_incremental_history_deals(
1531
1558
  Dataset.history_deals,
1532
1559
  "symbol = ? AND time >= ?",
1533
1560
  (symbol, start_by_symbol[symbol, None]),
1561
+ frozenset({"symbol", "time"}),
1534
1562
  )
1535
1563
  if "type" in columns:
1536
1564
  _record_dedup_scope(
@@ -1538,6 +1566,7 @@ def _write_incremental_history_deals(
1538
1566
  Dataset.history_deals,
1539
1567
  f"type NOT IN {_TRADE_DEAL_TYPES_SQL} AND time >= ?",
1540
1568
  (account_event_start,),
1569
+ frozenset({"type", "time"}),
1541
1570
  )
1542
1571
  if "type" not in columns and "symbol" in columns:
1543
1572
  _record_dedup_scope(
@@ -1545,6 +1574,7 @@ def _write_incremental_history_deals(
1545
1574
  Dataset.history_deals,
1546
1575
  "(symbol IS NULL OR symbol = '') AND time >= ?",
1547
1576
  (account_event_start,),
1577
+ frozenset({"symbol", "time"}),
1548
1578
  )
1549
1579
  return
1550
1580
  start_by_symbol = load_incremental_start_datetimes(
@@ -1572,6 +1602,7 @@ def _write_incremental_history_deals(
1572
1602
  Dataset.history_deals,
1573
1603
  "symbol = ? AND time >= ?",
1574
1604
  (symbol, start_date),
1605
+ frozenset({"symbol", "time"}),
1575
1606
  )
1576
1607
 
1577
1608
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mt5cli"
3
- version = "0.5.1"
3
+ version = "0.5.2"
4
4
  description = "Command-line tool for MetaTrader 5"
5
5
  authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
6
6
  maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
@@ -18,6 +18,7 @@ if TYPE_CHECKING:
18
18
  from mt5cli import history
19
19
  from mt5cli.history import (
20
20
  DEFAULT_HISTORY_TIMEFRAMES,
21
+ DedupScope,
21
22
  RateTarget,
22
23
  append_dataframe,
23
24
  augment_written_columns_from_sqlite,
@@ -618,7 +619,7 @@ class TestIncrementalStart:
618
619
  ) -> None:
619
620
  """Test rates tables without timeframe fail fast during incremental resume."""
620
621
  fallback = datetime(2024, 1, 1, tzinfo=UTC)
621
- with sqlite3.connect(tmp_path / "legacy-rates.db") as conn:
622
+ with sqlite3.connect(tmp_path / "rates-without-timeframe.db") as conn:
622
623
  conn.execute("CREATE TABLE rates(symbol TEXT, time TEXT, open REAL)")
623
624
  conn.execute(
624
625
  "INSERT INTO rates(symbol, time, open) VALUES (?, ?, ?)",
@@ -887,9 +888,10 @@ class TestDeduplication:
887
888
  {Dataset.rates},
888
889
  {
889
890
  Dataset.rates: [
890
- (
891
+ DedupScope(
891
892
  "symbol = ? AND timeframe = ? AND time >= ?",
892
893
  ("EURUSD", 1, boundary),
894
+ frozenset({"symbol", "timeframe", "time"}),
893
895
  ),
894
896
  ],
895
897
  },
@@ -902,6 +904,89 @@ class TestDeduplication:
902
904
  ("2024-01-02T00:00:00+00:00", 9.9),
903
905
  ]
904
906
 
907
+ def test_unusable_scope_falls_back_to_table_dedup(self, tmp_path: Path) -> None:
908
+ """Test scopes with missing columns do not break stable-key dedup."""
909
+ boundary = datetime(2024, 1, 1, tzinfo=UTC)
910
+ with sqlite3.connect(tmp_path / "orders-without-time.db") as conn:
911
+ conn.execute(
912
+ "CREATE TABLE history_orders("
913
+ " ticket INTEGER, symbol TEXT, time_setup TEXT, type INTEGER)",
914
+ )
915
+ conn.executemany(
916
+ "INSERT INTO history_orders(ticket, symbol, time_setup, type)"
917
+ " VALUES (?, ?, ?, ?)",
918
+ [
919
+ (1, "EURUSD", "2024-01-01T00:00:00+00:00", 0),
920
+ (1, "EURUSD", "2024-01-01T00:00:01+00:00", 1),
921
+ ],
922
+ )
923
+ deduplicate_history_tables(
924
+ conn,
925
+ {Dataset.history_orders: {"ticket", "symbol", "time_setup", "type"}},
926
+ {Dataset.history_orders},
927
+ {
928
+ Dataset.history_orders: [
929
+ DedupScope(
930
+ "symbol = ? AND time >= ?",
931
+ ("EURUSD", boundary),
932
+ frozenset({"symbol", "time"}),
933
+ ),
934
+ ],
935
+ },
936
+ )
937
+ rows = conn.execute(
938
+ "SELECT ticket, time_setup, type FROM history_orders",
939
+ ).fetchall()
940
+ assert rows == [(1, "2024-01-01T00:00:01+00:00", 1)]
941
+
942
+ def test_partially_unusable_scopes_only_run_usable_scopes(
943
+ self,
944
+ tmp_path: Path,
945
+ ) -> None:
946
+ """Test mixed scope filtering skips only scopes with missing columns."""
947
+ boundary = datetime(2024, 1, 2, tzinfo=UTC)
948
+ with sqlite3.connect(tmp_path / "partial-scope-filter.db") as conn:
949
+ conn.execute(
950
+ "CREATE TABLE rates("
951
+ " symbol TEXT, timeframe INTEGER, time TEXT, open REAL)",
952
+ )
953
+ conn.executemany(
954
+ "INSERT INTO rates(symbol, timeframe, time, open) VALUES (?, ?, ?, ?)",
955
+ [
956
+ ("EURUSD", 1, "2024-01-02T00:00:00+00:00", 2.0),
957
+ ("EURUSD", 1, "2024-01-02T00:00:00+00:00", 9.9),
958
+ ("USDJPY", 1, "2024-01-02T00:00:00+00:00", 100.0),
959
+ ("USDJPY", 1, "2024-01-02T00:00:00+00:00", 101.0),
960
+ ],
961
+ )
962
+ deduplicate_history_tables(
963
+ conn,
964
+ {Dataset.rates: {"symbol", "timeframe", "time", "open"}},
965
+ {Dataset.rates},
966
+ {
967
+ Dataset.rates: [
968
+ DedupScope(
969
+ "symbol = ? AND timeframe = ? AND time >= ?",
970
+ ("EURUSD", 1, boundary),
971
+ frozenset({"symbol", "timeframe", "time"}),
972
+ ),
973
+ DedupScope(
974
+ "symbol = ? AND timeframe = ? AND broker = ?",
975
+ ("USDJPY", 1, "demo"),
976
+ frozenset({"symbol", "timeframe", "broker"}),
977
+ ),
978
+ ],
979
+ },
980
+ )
981
+ rows = conn.execute(
982
+ "SELECT symbol, open FROM rates ORDER BY symbol, open",
983
+ ).fetchall()
984
+ assert rows == [
985
+ ("EURUSD", 9.9),
986
+ ("USDJPY", 100.0),
987
+ ("USDJPY", 101.0),
988
+ ]
989
+
905
990
 
906
991
  class TestRateCompatibilityViews:
907
992
  """Tests for rate compatibility view creation."""
@@ -1362,6 +1447,54 @@ class TestIncrementalIntegration:
1362
1447
  "rate_EURUSD_M1__1",
1363
1448
  }
1364
1449
 
1450
+ def test_incremental_orders_without_time_deduplicate_by_ticket(
1451
+ self,
1452
+ tmp_path: Path,
1453
+ caplog: pytest.LogCaptureFixture,
1454
+ ) -> None:
1455
+ """Test incremental history_orders without time deduplicate safely."""
1456
+
1457
+ def history_orders_get_as_df(**kwargs: object) -> pd.DataFrame:
1458
+ if kwargs["symbol"] == "GBPUSD":
1459
+ return pd.DataFrame()
1460
+ return pd.DataFrame({
1461
+ "ticket": [1, 1],
1462
+ "symbol": ["EURUSD", "EURUSD"],
1463
+ "time_setup": [
1464
+ "2024-01-01T00:00:00+00:00",
1465
+ "2024-01-01T00:00:01+00:00",
1466
+ ],
1467
+ "type": [0, 1],
1468
+ })
1469
+
1470
+ client = MagicMock()
1471
+ client.history_orders_get_as_df.side_effect = history_orders_get_as_df
1472
+ start = datetime(2024, 1, 1, tzinfo=UTC)
1473
+ end = datetime(2024, 1, 2, tzinfo=UTC)
1474
+ with (
1475
+ sqlite3.connect(tmp_path / "incremental-orders-without-time.db") as conn,
1476
+ caplog.at_level(logging.WARNING, logger="mt5cli.history"),
1477
+ ):
1478
+ write_incremental_datasets(
1479
+ conn,
1480
+ client,
1481
+ ["EURUSD", "GBPUSD"],
1482
+ {Dataset.history_orders},
1483
+ [],
1484
+ 0,
1485
+ start,
1486
+ end,
1487
+ deduplicate=True,
1488
+ create_rate_views=False,
1489
+ with_views=False,
1490
+ include_account_events=False,
1491
+ )
1492
+ rows = conn.execute(
1493
+ "SELECT ticket, time_setup, type FROM history_orders",
1494
+ ).fetchall()
1495
+ assert rows == [(1, "2024-01-01T00:00:01+00:00", 1)]
1496
+ assert "Skipping history_orders: dataset returned no columns" in caplog.text
1497
+
1365
1498
  def test_write_collected_datasets_and_edge_branches(
1366
1499
  self,
1367
1500
  tmp_path: Path,
@@ -1737,7 +1870,7 @@ class TestIncrementalHistoryDeals:
1737
1870
  })
1738
1871
  start = datetime(2024, 1, 1, tzinfo=UTC)
1739
1872
  end = datetime(2024, 1, 3, tzinfo=UTC)
1740
- with sqlite3.connect(tmp_path / "legacy-deals.db") as conn:
1873
+ with sqlite3.connect(tmp_path / "deals-without-type.db") as conn:
1741
1874
  conn.execute(
1742
1875
  "CREATE TABLE history_deals( ticket INTEGER, symbol TEXT, time TEXT)",
1743
1876
  )
@@ -487,7 +487,7 @@ wheels = [
487
487
 
488
488
  [[package]]
489
489
  name = "mt5cli"
490
- version = "0.5.1"
490
+ version = "0.5.2"
491
491
  source = { editable = "." }
492
492
  dependencies = [
493
493
  { name = "click" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes