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.
- {mt5cli-0.5.1 → mt5cli-0.5.2}/PKG-INFO +1 -1
- {mt5cli-0.5.1 → mt5cli-0.5.2}/docs/api/history.md +2 -2
- {mt5cli-0.5.1 → mt5cli-0.5.2}/mt5cli/history.py +40 -9
- {mt5cli-0.5.1 → mt5cli-0.5.2}/pyproject.toml +1 -1
- {mt5cli-0.5.1 → mt5cli-0.5.2}/tests/test_history.py +136 -3
- {mt5cli-0.5.1 → mt5cli-0.5.2}/uv.lock +1 -1
- {mt5cli-0.5.1 → mt5cli-0.5.2}/.agents/skills/local-qa/SKILL.md +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/.agents/skills/local-qa/scripts/qa.sh +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/.agents/skills/mt5cli/SKILL.md +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/.claude/agents/codex.md +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/.claude/settings.json +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/.github/FUNDING.yml +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/.github/dependabot.yml +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/.github/renovate.json +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/.github/workflows/ci.yml +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/.github/workflows/claude.yml +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/.github/workflows/release.yml +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/.gitignore +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/AGENTS.md +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/CLAUDE.md +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/LICENSE +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/README.md +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/docs/api/cli.md +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/docs/api/index.md +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/docs/api/sdk.md +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/docs/api/utils.md +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/docs/index.md +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/mkdocs.yml +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/mt5cli/__init__.py +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/mt5cli/__main__.py +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/mt5cli/cli.py +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/mt5cli/sdk.py +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/mt5cli/utils.py +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/tests/__init__.py +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/tests/test_cli.py +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/tests/test_sdk.py +0 -0
- {mt5cli-0.5.1 → mt5cli-0.5.2}/tests/test_utils.py +0 -0
|
@@ -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
|
|
137
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
|
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=
|
|
1038
|
-
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.
|
|
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 / "
|
|
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 / "
|
|
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
|
)
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|