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:
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user