Add rate view resolution and downstream SDK helpers (#18)
* Add public helpers to resolve rate compatibility view names. Expose resolve_rate_view_name and resolve_rate_view_names in mt5cli.history so consumers can derive mt5cli-managed SQLite view names from stored rates metadata without reimplementing the naming rules. Co-authored-by: Cursor <cursoragent@cursor.com> * Add reusable export, tick-window, and margin helpers for downstream tools. Expose SQLite append/dedup export, recent tick retrieval, and minimum margin summary through the SDK and CLI so projects like mteor can depend on mt5cli instead of duplicating MT5 data plumbing. Co-authored-by: Cursor <cursoragent@cursor.com> * Bump version to 0.4.3. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback for rate view resolution and SDK helpers. Harden SQLite read-only connections, tighten view discovery, improve recent_ticks fetch efficiency, default SQLite export to append, and expand tests and docs. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix read-only SQLite URI construction on Windows. Use Path.as_uri() so encoded file URIs work cross-platform with mode=ro. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+60
-1
@@ -6,7 +6,7 @@ import json
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -316,6 +316,65 @@ class TestCommands:
|
||||
flags=2,
|
||||
)
|
||||
|
||||
def test_ticks_recent(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test ticks-recent command."""
|
||||
output = tmp_path / "out.csv"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"-o",
|
||||
str(output),
|
||||
"ticks-recent",
|
||||
"--symbol",
|
||||
"EURUSD",
|
||||
"--seconds",
|
||||
"120",
|
||||
"--date-to",
|
||||
"2024-01-02",
|
||||
"--count",
|
||||
"500",
|
||||
"--flags",
|
||||
"ALL",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
mock_client.copy_ticks_from_as_df.assert_called_once_with(
|
||||
symbol="EURUSD",
|
||||
date_from=datetime(2024, 1, 2, tzinfo=UTC) - timedelta(seconds=120),
|
||||
count=500,
|
||||
flags=1,
|
||||
)
|
||||
mock_client.copy_ticks_range_as_df.assert_not_called()
|
||||
|
||||
def test_minimum_margins(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test minimum-margins command."""
|
||||
sym = MagicMock(volume_min=0.01)
|
||||
account = MagicMock(currency="USD")
|
||||
tick = MagicMock(ask=1.1010, bid=1.1000)
|
||||
mock_client.symbol_info.return_value = sym
|
||||
mock_client.account_info.return_value = account
|
||||
mock_client.symbol_info_tick.return_value = tick
|
||||
mock_client.order_calc_margin.side_effect = [12.5, 12.4]
|
||||
mock_client.mt5.ORDER_TYPE_BUY = 0
|
||||
mock_client.mt5.ORDER_TYPE_SELL = 1
|
||||
output = tmp_path / "out.csv"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["-o", str(output), "minimum-margins", "--symbol", "EURUSD"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
mock_client.symbol_info.assert_called_once_with("EURUSD")
|
||||
mock_client.order_calc_margin.assert_any_call(0, "EURUSD", 0.01, 1.1010)
|
||||
mock_client.order_calc_margin.assert_any_call(1, "EURUSD", 0.01, 1.1000)
|
||||
|
||||
def test_orders(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
|
||||
@@ -38,6 +38,8 @@ from mt5cli.history import (
|
||||
resolve_history_datasets,
|
||||
resolve_history_tick_flags,
|
||||
resolve_history_timeframes,
|
||||
resolve_rate_view_name,
|
||||
resolve_rate_view_names,
|
||||
write_collected_datasets,
|
||||
write_history_dataset,
|
||||
write_incremental_datasets,
|
||||
@@ -47,6 +49,281 @@ from mt5cli.history import (
|
||||
from mt5cli.utils import TIMEFRAME_MAP, Dataset, IfExists
|
||||
|
||||
|
||||
class TestResolveRateViewName:
|
||||
"""Tests for resolve_rate_view_name and resolve_rate_view_names."""
|
||||
|
||||
def test_missing_database_path_does_not_create_file(self, tmp_path: Path) -> None:
|
||||
"""Test resolving against a missing path does not create a database."""
|
||||
db_path = tmp_path / "missing.db"
|
||||
assert resolve_rate_view_name(db_path, "EURUSD", "M1") == "rate_EURUSD__1"
|
||||
assert not db_path.exists()
|
||||
|
||||
def test_no_rates_table_falls_back_to_single_timeframe_name(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test databases without a rates table use single-timeframe naming."""
|
||||
db_path = tmp_path / "no-rates.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("CREATE TABLE ticks(symbol TEXT, time TEXT)")
|
||||
assert resolve_rate_view_name(db_path, "EURUSD", "M1") == "rate_EURUSD__1"
|
||||
|
||||
def test_single_timeframe_for_one_symbol(self, tmp_path: Path) -> None:
|
||||
"""Test one stored timeframe resolves to the short view name."""
|
||||
db_path = tmp_path / "single-timeframe.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE rates("
|
||||
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
|
||||
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
|
||||
)
|
||||
create_rate_compatibility_views(conn)
|
||||
assert resolve_rate_view_name(db_path, "EURUSD", "M1") == "rate_EURUSD__1"
|
||||
|
||||
def test_multiple_timeframes_for_one_symbol(self, tmp_path: Path) -> None:
|
||||
"""Test multiple stored timeframes resolve to disambiguated view names."""
|
||||
db_path = tmp_path / "multi-timeframe.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE rates("
|
||||
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
|
||||
[
|
||||
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
|
||||
("EURUSD", TIMEFRAME_MAP["H1"], "2024-01-01T01:00:00+00:00", 1.1),
|
||||
],
|
||||
)
|
||||
create_rate_compatibility_views(conn)
|
||||
assert resolve_rate_view_name(db_path, "EURUSD", "M1") == "rate_EURUSD__M1_1"
|
||||
assert (
|
||||
resolve_rate_view_name(db_path, "EURUSD", "H1") == "rate_EURUSD__H1_16385"
|
||||
)
|
||||
|
||||
def test_prefers_multi_name_when_both_candidate_views_exist(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test multi-timeframe metadata wins over stale single-timeframe views."""
|
||||
db_path = tmp_path / "stale-and-current-views.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE rates("
|
||||
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
|
||||
[
|
||||
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
|
||||
("EURUSD", TIMEFRAME_MAP["H1"], "2024-01-01T01:00:00+00:00", 1.1),
|
||||
],
|
||||
)
|
||||
conn.execute(
|
||||
'CREATE VIEW "rate_EURUSD__1" AS'
|
||||
" SELECT time, close FROM rates"
|
||||
" WHERE symbol = 'EURUSD' AND timeframe = 1",
|
||||
)
|
||||
conn.execute(
|
||||
'CREATE VIEW "rate_EURUSD__M1_1" AS'
|
||||
" SELECT time, close FROM rates"
|
||||
" WHERE symbol = 'EURUSD' AND timeframe = 1",
|
||||
)
|
||||
assert resolve_rate_view_name(db_path, "EURUSD", "M1") == "rate_EURUSD__M1_1"
|
||||
|
||||
def test_prefers_existing_view_when_metadata_unavailable(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test an existing managed view is preferred without rates metadata."""
|
||||
db_path = tmp_path / "view-only.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("CREATE TABLE ticks(symbol TEXT, time TEXT)")
|
||||
conn.execute('CREATE VIEW "rate_EURUSD__M1_1" AS SELECT 1 AS close')
|
||||
assert resolve_rate_view_name(db_path, "EURUSD", "M1") == "rate_EURUSD__M1_1"
|
||||
|
||||
def test_symbol_absent_from_rates_metadata_uses_candidate_pair(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test symbols missing from rates metadata still resolve known views."""
|
||||
db_path = tmp_path / "other-symbol-only.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE rates("
|
||||
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
|
||||
("GBPUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
|
||||
)
|
||||
conn.execute(
|
||||
'CREATE VIEW "rate_EURUSD__1" AS'
|
||||
" SELECT time, close FROM rates"
|
||||
" WHERE symbol = 'EURUSD' AND timeframe = 1",
|
||||
)
|
||||
assert resolve_rate_view_name(db_path, "EURUSD", "M1") == "rate_EURUSD__1"
|
||||
|
||||
def test_ignores_non_compatibility_rate_views(self, tmp_path: Path) -> None:
|
||||
"""Test unrelated rate_* views without the __ separator are ignored."""
|
||||
db_path = tmp_path / "summary-view.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("CREATE TABLE ticks(symbol TEXT, time TEXT)")
|
||||
conn.execute('CREATE VIEW "rate_summary" AS SELECT 1 AS close')
|
||||
assert resolve_rate_view_name(db_path, "EURUSD", "M1") == "rate_EURUSD__1"
|
||||
|
||||
def test_invalid_granularity_propagates_value_error(self, tmp_path: Path) -> None:
|
||||
"""Test invalid granularities raise ValueError from parse_timeframe."""
|
||||
with pytest.raises(ValueError, match="Invalid timeframe"):
|
||||
resolve_rate_view_name(tmp_path / "unused.db", "EURUSD", "BAD")
|
||||
with pytest.raises(ValueError, match="Invalid timeframe"):
|
||||
resolve_rate_view_names(tmp_path / "unused.db", ["EURUSD"], ["BAD"])
|
||||
|
||||
def test_resolve_rate_view_names_for_multiple_pairs(self, tmp_path: Path) -> None:
|
||||
"""Test batch resolution returns row-major symbol/granularity pairs."""
|
||||
db_path = tmp_path / "batch-resolve.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE rates("
|
||||
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
|
||||
[
|
||||
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
|
||||
("EURUSD", TIMEFRAME_MAP["H1"], "2024-01-01T01:00:00+00:00", 1.1),
|
||||
("GBPUSD", 1, "2024-01-01T00:00:00+00:00", 1.2),
|
||||
],
|
||||
)
|
||||
create_rate_compatibility_views(conn)
|
||||
assert resolve_rate_view_names(
|
||||
db_path,
|
||||
["EURUSD", "GBPUSD"],
|
||||
["M1", "H1"],
|
||||
) == [
|
||||
"rate_EURUSD__M1_1",
|
||||
"rate_EURUSD__H1_16385",
|
||||
"rate_GBPUSD__1",
|
||||
"rate_GBPUSD__16385",
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"symbol",
|
||||
["EUR/USD", "US500.cash", "#US500"],
|
||||
)
|
||||
def test_supports_broker_specific_symbols(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
symbol: str,
|
||||
) -> None:
|
||||
"""Test broker-specific symbols resolve to safely created view names."""
|
||||
db_path = tmp_path / "broker-symbol-resolve.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE rates("
|
||||
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
|
||||
(symbol, 1, "2024-01-01T00:00:00+00:00", 1.0),
|
||||
)
|
||||
create_rate_compatibility_views(conn)
|
||||
assert resolve_rate_view_name(db_path, symbol, "M1") == build_rate_view_name(
|
||||
symbol=symbol,
|
||||
granularity="M1",
|
||||
granularity_count=1,
|
||||
timeframe=1,
|
||||
)
|
||||
|
||||
def test_accepts_open_sqlite_connection(self, tmp_path: Path) -> None:
|
||||
"""Test resolver accepts an already-open SQLite connection."""
|
||||
db_path = tmp_path / "open-connection.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE rates("
|
||||
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
|
||||
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
|
||||
)
|
||||
create_rate_compatibility_views(conn)
|
||||
assert resolve_rate_view_name(conn, "EURUSD", "M1") == "rate_EURUSD__1"
|
||||
|
||||
def test_require_existing_raises_when_database_missing(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test strict mode rejects missing database paths."""
|
||||
db_path = tmp_path / "missing.db"
|
||||
with pytest.raises(ValueError, match="SQLite database not found"):
|
||||
resolve_rate_view_name(
|
||||
db_path,
|
||||
"EURUSD",
|
||||
"M1",
|
||||
require_existing=True,
|
||||
)
|
||||
with pytest.raises(ValueError, match="SQLite database not found"):
|
||||
resolve_rate_view_names(
|
||||
db_path,
|
||||
["EURUSD"],
|
||||
["M1"],
|
||||
require_existing=True,
|
||||
)
|
||||
|
||||
def test_require_existing_raises_when_view_missing(self, tmp_path: Path) -> None:
|
||||
"""Test strict mode rejects databases without matching rate views."""
|
||||
db_path = tmp_path / "no-view.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("CREATE TABLE ticks(symbol TEXT, time TEXT)")
|
||||
with pytest.raises(ValueError, match="No rate compatibility view exists"):
|
||||
resolve_rate_view_name(
|
||||
db_path,
|
||||
"EURUSD",
|
||||
"M1",
|
||||
require_existing=True,
|
||||
)
|
||||
with pytest.raises(ValueError, match="No rate compatibility view exists"):
|
||||
resolve_rate_view_names(
|
||||
db_path,
|
||||
["EURUSD"],
|
||||
["M1"],
|
||||
require_existing=True,
|
||||
)
|
||||
|
||||
def test_require_existing_returns_existing_view(self, tmp_path: Path) -> None:
|
||||
"""Test strict mode returns a view when one exists."""
|
||||
db_path = tmp_path / "existing-view.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE rates("
|
||||
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
|
||||
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
|
||||
)
|
||||
create_rate_compatibility_views(conn)
|
||||
assert (
|
||||
resolve_rate_view_name(
|
||||
db_path,
|
||||
"EURUSD",
|
||||
"M1",
|
||||
require_existing=True,
|
||||
)
|
||||
== "rate_EURUSD__1"
|
||||
)
|
||||
assert resolve_rate_view_names(
|
||||
db_path,
|
||||
["EURUSD"],
|
||||
["M1"],
|
||||
require_existing=True,
|
||||
) == ["rate_EURUSD__1"]
|
||||
|
||||
|
||||
class TestQuoteSqliteIdentifier:
|
||||
"""Tests for quote_sqlite_identifier."""
|
||||
|
||||
|
||||
+173
-1
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -31,8 +31,10 @@ from mt5cli.sdk import (
|
||||
history_orders,
|
||||
last_error,
|
||||
market_book,
|
||||
minimum_margins,
|
||||
orders,
|
||||
positions,
|
||||
recent_ticks,
|
||||
symbol_info,
|
||||
symbol_info_tick,
|
||||
symbols,
|
||||
@@ -808,3 +810,173 @@ class TestUpdateHistory:
|
||||
)
|
||||
after = datetime.now(UTC)
|
||||
assert before <= captured["end"] <= after
|
||||
|
||||
|
||||
class TestRecentTicks:
|
||||
"""Tests for recent_ticks helper."""
|
||||
|
||||
def test_recent_ticks_uses_explicit_date_to_window(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test recent_ticks fetches the requested trailing window."""
|
||||
client = MagicMock()
|
||||
end = datetime(2024, 1, 2, 12, 0, 0, tzinfo=UTC)
|
||||
client.copy_ticks_from_as_df.return_value = pd.DataFrame({
|
||||
"time": [end],
|
||||
"bid": [1.0],
|
||||
})
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client)
|
||||
result = recent_ticks(
|
||||
"EURUSD",
|
||||
60,
|
||||
date_to=end,
|
||||
count=100,
|
||||
flags="INFO",
|
||||
config=build_config(login=123),
|
||||
)
|
||||
assert isinstance(result, pd.DataFrame)
|
||||
client.copy_ticks_from_as_df.assert_called_once_with(
|
||||
symbol="EURUSD",
|
||||
date_from=end - timedelta(seconds=60),
|
||||
count=100,
|
||||
flags=2,
|
||||
)
|
||||
client.copy_ticks_range_as_df.assert_not_called()
|
||||
|
||||
def test_recent_ticks_uses_latest_tick_when_date_to_omitted(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test recent_ticks anchors the window on the latest tick time."""
|
||||
client = MagicMock()
|
||||
tick = MagicMock()
|
||||
tick.time = datetime(2024, 1, 2, 12, 0, 0, tzinfo=UTC)
|
||||
client.symbol_info_tick.return_value = tick
|
||||
client.copy_ticks_from_as_df.return_value = pd.DataFrame({
|
||||
"time": [1, 2],
|
||||
"bid": [1.0, 1.1],
|
||||
})
|
||||
client.copy_ticks_range_as_df.return_value = pd.DataFrame({
|
||||
"time": [1, 2, 3],
|
||||
"bid": [1.0, 1.1, 1.2],
|
||||
})
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client)
|
||||
result = Mt5CliClient().recent_ticks("EURUSD", 30, count=2, flags="ALL")
|
||||
assert len(result) == 2
|
||||
client.symbol_info_tick.assert_called_once_with("EURUSD")
|
||||
client.copy_ticks_from_as_df.assert_called_once()
|
||||
_, kwargs = client.copy_ticks_range_as_df.call_args
|
||||
assert kwargs["symbol"] == "EURUSD"
|
||||
assert kwargs["date_to"] == tick.time
|
||||
assert kwargs["date_from"] == tick.time - timedelta(seconds=30)
|
||||
assert kwargs["flags"] == 1
|
||||
|
||||
def test_recent_ticks_rejects_unsupported_tick_time(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test recent_ticks raises when the latest tick time is unsupported."""
|
||||
client = MagicMock()
|
||||
tick = MagicMock()
|
||||
tick.time = object()
|
||||
client.symbol_info_tick.return_value = tick
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client)
|
||||
with pytest.raises(TypeError, match="Unsupported tick time value"):
|
||||
Mt5CliClient().recent_ticks("EURUSD", 30)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tick_time",
|
||||
[
|
||||
"2024-01-02T12:00:00+00:00",
|
||||
1704196800,
|
||||
],
|
||||
)
|
||||
def test_recent_ticks_coerces_string_and_unix_tick_times(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
tick_time: str | int,
|
||||
) -> None:
|
||||
"""Test recent_ticks accepts string and unix tick timestamps."""
|
||||
client = MagicMock()
|
||||
tick = MagicMock()
|
||||
tick.time = tick_time
|
||||
client.symbol_info_tick.return_value = tick
|
||||
expected_end = (
|
||||
datetime(2024, 1, 2, 12, 0, 0, tzinfo=UTC)
|
||||
if isinstance(tick_time, str)
|
||||
else datetime.fromtimestamp(tick_time, tz=UTC)
|
||||
)
|
||||
client.copy_ticks_from_as_df.return_value = pd.DataFrame({
|
||||
"time": [expected_end],
|
||||
})
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client)
|
||||
Mt5CliClient().recent_ticks("EURUSD", 30)
|
||||
_, kwargs = client.copy_ticks_from_as_df.call_args
|
||||
assert kwargs["date_from"] == expected_end - timedelta(seconds=30)
|
||||
|
||||
def test_recent_ticks_returns_full_frame_when_count_not_positive(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test non-positive count returns the full range without trimming."""
|
||||
client = MagicMock()
|
||||
end = datetime(2024, 1, 2, 12, 0, 0, tzinfo=UTC)
|
||||
client.copy_ticks_range_as_df.return_value = pd.DataFrame({
|
||||
"time": [1, 2, 3],
|
||||
"bid": [1.0, 1.1, 1.2],
|
||||
})
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client)
|
||||
result = recent_ticks(
|
||||
"EURUSD",
|
||||
60,
|
||||
date_to=end,
|
||||
count=0,
|
||||
config=build_config(login=123),
|
||||
)
|
||||
assert len(result) == 3
|
||||
client.copy_ticks_from_as_df.assert_not_called()
|
||||
client.copy_ticks_range_as_df.assert_called_once_with(
|
||||
symbol="EURUSD",
|
||||
date_from=end - timedelta(seconds=60),
|
||||
date_to=end,
|
||||
flags=1,
|
||||
)
|
||||
|
||||
|
||||
class TestMinimumMargins:
|
||||
"""Tests for minimum_margins helper."""
|
||||
|
||||
def test_minimum_margins_shape(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test minimum_margins returns the expected summary columns."""
|
||||
client = MagicMock()
|
||||
sym = MagicMock(volume_min=0.01)
|
||||
account = MagicMock(currency="USD")
|
||||
tick = MagicMock(ask=1.1010, bid=1.1000)
|
||||
client.symbol_info.return_value = sym
|
||||
client.account_info.return_value = account
|
||||
client.symbol_info_tick.return_value = tick
|
||||
client.order_calc_margin.side_effect = [12.5, 12.4]
|
||||
client.mt5.ORDER_TYPE_BUY = 0
|
||||
client.mt5.ORDER_TYPE_SELL = 1
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client)
|
||||
|
||||
result = minimum_margins("EURUSD", config=build_config(login=123))
|
||||
|
||||
pd.testing.assert_frame_equal(
|
||||
result,
|
||||
pd.DataFrame([
|
||||
{
|
||||
"symbol": "EURUSD",
|
||||
"account_currency": "USD",
|
||||
"volume_min": 0.01,
|
||||
"buy_margin": 12.5,
|
||||
"sell_margin": 12.4,
|
||||
}
|
||||
]),
|
||||
)
|
||||
client.order_calc_margin.assert_any_call(0, "EURUSD", 0.01, 1.1010)
|
||||
client.order_calc_margin.assert_any_call(1, "EURUSD", 0.01, 1.1000)
|
||||
|
||||
@@ -21,8 +21,10 @@ from mt5cli.utils import (
|
||||
TIMEFRAME_MAP,
|
||||
TIMEFRAME_TYPE,
|
||||
Dataset,
|
||||
IfExists,
|
||||
detect_format,
|
||||
export_dataframe,
|
||||
export_dataframe_to_sqlite,
|
||||
parse_datetime,
|
||||
parse_request,
|
||||
parse_tick_flags,
|
||||
@@ -130,6 +132,112 @@ class TestExportDataframe:
|
||||
export_dataframe(sample_df, tmp_path / "out.txt", "xml")
|
||||
|
||||
|
||||
class TestExportDataframeToSqlite:
|
||||
"""Tests for export_dataframe_to_sqlite."""
|
||||
|
||||
def test_append_preserves_existing_rows(self, tmp_path: Path) -> None:
|
||||
"""Test append mode keeps prior rows in the SQLite table."""
|
||||
output = tmp_path / "append.db"
|
||||
first = pd.DataFrame({"id": [1], "value": ["a"]})
|
||||
second = pd.DataFrame({"id": [2], "value": ["b"]})
|
||||
export_dataframe_to_sqlite(first, output, "items", if_exists=IfExists.REPLACE)
|
||||
export_dataframe_to_sqlite(second, output, "items", if_exists=IfExists.APPEND)
|
||||
with sqlite3.connect(output) as conn:
|
||||
result = pd.read_sql( # type: ignore[reportUnknownMemberType]
|
||||
"SELECT id, value FROM items ORDER BY id",
|
||||
conn,
|
||||
)
|
||||
pd.testing.assert_frame_equal(
|
||||
result,
|
||||
pd.DataFrame({"id": [1, 2], "value": ["a", "b"]}),
|
||||
)
|
||||
|
||||
def test_deduplicate_keeps_latest_row(self, tmp_path: Path) -> None:
|
||||
"""Test deduplication keeps the latest ROWID for key columns."""
|
||||
output = tmp_path / "dedup.db"
|
||||
first = pd.DataFrame({
|
||||
"symbol": ["EURUSD", "EURUSD"],
|
||||
"time": ["2024-01-01", "2024-01-01"],
|
||||
"bid": [1.0, 1.1],
|
||||
})
|
||||
second = pd.DataFrame({
|
||||
"symbol": ["EURUSD"],
|
||||
"time": ["2024-01-01"],
|
||||
"bid": [1.2],
|
||||
})
|
||||
export_dataframe_to_sqlite(
|
||||
first,
|
||||
output,
|
||||
"ticks",
|
||||
if_exists=IfExists.REPLACE,
|
||||
deduplicate_on=("symbol", "time"),
|
||||
)
|
||||
export_dataframe_to_sqlite(
|
||||
second,
|
||||
output,
|
||||
"ticks",
|
||||
if_exists=IfExists.APPEND,
|
||||
deduplicate_on=("symbol", "time"),
|
||||
)
|
||||
with sqlite3.connect(output) as conn:
|
||||
result = pd.read_sql( # type: ignore[reportUnknownMemberType]
|
||||
"SELECT symbol, time, bid FROM ticks",
|
||||
conn,
|
||||
)
|
||||
pd.testing.assert_frame_equal(
|
||||
result.reset_index(drop=True),
|
||||
pd.DataFrame({
|
||||
"symbol": ["EURUSD"],
|
||||
"time": ["2024-01-01"],
|
||||
"bid": [1.2],
|
||||
}),
|
||||
)
|
||||
|
||||
def test_default_if_exists_appends_without_dropping_rows(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test the default append mode keeps prior rows."""
|
||||
output = tmp_path / "default-append.db"
|
||||
first = pd.DataFrame({"id": [1], "value": ["a"]})
|
||||
second = pd.DataFrame({"id": [2], "value": ["b"]})
|
||||
export_dataframe_to_sqlite(first, output, "items")
|
||||
export_dataframe_to_sqlite(second, output, "items")
|
||||
with sqlite3.connect(output) as conn:
|
||||
result = pd.read_sql( # type: ignore[reportUnknownMemberType]
|
||||
"SELECT id, value FROM items ORDER BY id",
|
||||
conn,
|
||||
)
|
||||
pd.testing.assert_frame_equal(
|
||||
result,
|
||||
pd.DataFrame({"id": [1, 2], "value": ["a", "b"]}),
|
||||
)
|
||||
|
||||
def test_writes_index_with_label(self, tmp_path: Path) -> None:
|
||||
"""Test optional index export with a custom label."""
|
||||
output = tmp_path / "index.db"
|
||||
frame = pd.DataFrame(
|
||||
{"value": [1.0]}, index=pd.Index(["EURUSD"], name="symbol")
|
||||
)
|
||||
export_dataframe_to_sqlite(
|
||||
frame,
|
||||
output,
|
||||
"margins",
|
||||
if_exists=IfExists.REPLACE,
|
||||
index=True,
|
||||
index_label="symbol",
|
||||
)
|
||||
with sqlite3.connect(output) as conn:
|
||||
result = pd.read_sql( # type: ignore[reportUnknownMemberType]
|
||||
"SELECT symbol, value FROM margins",
|
||||
conn,
|
||||
)
|
||||
pd.testing.assert_frame_equal(
|
||||
result,
|
||||
pd.DataFrame({"symbol": ["EURUSD"], "value": [1.0]}),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parse helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user