* feat: add MT5 order metadata, coverage report, and env-backed CLI config * fix: align review-driven trading and history contracts * Bump pdmt5 to 1.1.0 * test: stabilize history gaps CLI assertion * fix: address review follow-ups for gaps and filling mode
This commit is contained in:
+289
-10
@@ -21,7 +21,9 @@ if TYPE_CHECKING:
|
||||
from mt5cli.cli import (
|
||||
_execute_export, # type: ignore[reportPrivateUsage]
|
||||
_ExportContext, # type: ignore[reportPrivateUsage]
|
||||
_infer_gap_table_granularity_seconds, # type: ignore[reportPrivateUsage]
|
||||
_sdk_client, # type: ignore[reportPrivateUsage]
|
||||
_timeframe_interval_seconds, # type: ignore[reportPrivateUsage]
|
||||
app,
|
||||
main,
|
||||
)
|
||||
@@ -631,8 +633,8 @@ class TestClosePositions:
|
||||
"""Patch create_trading_client and return a mock trading client."""
|
||||
client = _build_mock_trading_client()
|
||||
client.positions_get_as_df.return_value = pd.DataFrame([
|
||||
{"ticket": 1, "symbol": "JP225", "type": 0, "volume": 1.0},
|
||||
{"ticket": 2, "symbol": "EURUSD", "type": 1, "volume": 0.5},
|
||||
{"ticket": 1, "symbol": "JP225", "type": 0, "volume": 1.0, "magic": 7},
|
||||
{"ticket": 2, "symbol": "EURUSD", "type": 1, "volume": 0.5, "magic": 9},
|
||||
])
|
||||
client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1}
|
||||
mocker.patch("mt5cli.cli.create_trading_client", return_value=client)
|
||||
@@ -762,6 +764,40 @@ class TestClosePositions:
|
||||
assert data[0]["dry_run"] is True
|
||||
assert data[0]["order_side"] == "SELL"
|
||||
|
||||
def test_close_positions_dry_run_forwards_request_fields(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
trading_client: MagicMock,
|
||||
) -> None:
|
||||
"""Dry-run close export preserves deviation/comment/magic passthrough."""
|
||||
output = tmp_path / "close.json"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"-o",
|
||||
str(output),
|
||||
"close-positions",
|
||||
"--symbol",
|
||||
"JP225",
|
||||
"--deviation",
|
||||
"7",
|
||||
"--comment",
|
||||
"close-me",
|
||||
"--magic",
|
||||
"7",
|
||||
"--dry-run",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
trading_client.order_send.assert_not_called()
|
||||
data = json.loads(output.read_text())
|
||||
assert len(data) == 1
|
||||
assert data[0]["symbol"] == "JP225"
|
||||
request = json.loads(data[0]["request"])
|
||||
assert request["deviation"] == 7
|
||||
assert request["comment"] == "close-me"
|
||||
assert request["magic"] == 7
|
||||
|
||||
def test_order_send_unchanged(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
@@ -847,11 +883,10 @@ class TestCallback:
|
||||
"""Test that connection arguments reach Mt5Config."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.account_info_as_df.return_value = pd.DataFrame({"a": [1]})
|
||||
mocker.patch(
|
||||
mt5_client = mocker.patch(
|
||||
"mt5cli.sdk.Mt5DataClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
mock_config = mocker.patch("mt5cli.cli.Mt5Config")
|
||||
output = tmp_path / "out.csv"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
@@ -868,13 +903,132 @@ class TestCallback:
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
mock_config.assert_called_once_with(
|
||||
path=None,
|
||||
login=123,
|
||||
password="pw",
|
||||
server="srv",
|
||||
timeout=None,
|
||||
config = mt5_client.call_args.kwargs["config"]
|
||||
assert config.path is None
|
||||
assert config.login == 123
|
||||
assert config.password is not None
|
||||
assert config.password.get_secret_value() == "pw"
|
||||
assert config.server == "srv"
|
||||
assert config.timeout is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("env", "extra_args", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
{
|
||||
"MT5_LOGIN": "456",
|
||||
"MT5_PASSWORD": "env-pass",
|
||||
"MT5_SERVER": "Env-Server",
|
||||
},
|
||||
[],
|
||||
{"login": 456, "password": "env-pass", "server": "Env-Server"},
|
||||
id="env-defaults",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"MT5_LOGIN": "456",
|
||||
"MT5_PASSWORD": "env-pass",
|
||||
"MT5_SERVER": "Env-Server",
|
||||
},
|
||||
["--login", "123", "--password", "cli-pass", "--server", "Cli-Server"],
|
||||
{"login": 123, "password": "cli-pass", "server": "Cli-Server"},
|
||||
id="cli-overrides-env",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_connection_args_resolve_env_and_precedence(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
mocker: MockerFixture,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
env: dict[str, str],
|
||||
extra_args: list[str],
|
||||
expected: dict[str, object],
|
||||
) -> None:
|
||||
"""CLI args fall back to env vars and preserve explicit precedence."""
|
||||
for name, value in env.items():
|
||||
monkeypatch.setenv(name, value)
|
||||
mock_client = MagicMock()
|
||||
mock_client.account_info_as_df.return_value = pd.DataFrame({"a": [1]})
|
||||
mt5_client = mocker.patch(
|
||||
"mt5cli.sdk.Mt5DataClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
output = tmp_path / "out.csv"
|
||||
|
||||
result = runner.invoke(app, [*extra_args, "-o", str(output), "account-info"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
config = mt5_client.call_args.kwargs["config"]
|
||||
assert config.login == expected["login"]
|
||||
assert config.password is not None
|
||||
assert config.password.get_secret_value() == expected["password"]
|
||||
assert config.server == expected["server"]
|
||||
|
||||
def test_help_documents_mt5_env_vars(self) -> None:
|
||||
"""Top-level help output should expose the supported MT5 env vars."""
|
||||
result = runner.invoke(app, ["--help"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
normalized = normalize_cli_output(result.output)
|
||||
for env_name in ("MT5_LOGIN", "MT5_PASSWORD", "MT5_SERVER", "MT5_PATH"):
|
||||
assert env_name in normalized
|
||||
|
||||
def test_gap_granularity_helpers_cover_unknown_cases(self) -> None:
|
||||
"""Gap-table granularity helpers should fail cleanly for unknown inputs."""
|
||||
assert _timeframe_interval_seconds(49153) is None
|
||||
assert _infer_gap_table_granularity_seconds("custom_rates") is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("args", "env", "exit_code", "match"),
|
||||
[
|
||||
pytest.param(
|
||||
["--login", "${CLI_MT5_LOGIN}", "--server", "${CLI_MT5_SERVER}"],
|
||||
{"CLI_MT5_LOGIN": "789", "CLI_MT5_SERVER": "Placeholder-Server"},
|
||||
0,
|
||||
None,
|
||||
id="placeholder-expansion",
|
||||
),
|
||||
pytest.param(
|
||||
["--password", "${CLI_MT5_MISSING}"],
|
||||
{},
|
||||
2,
|
||||
"Environment variable 'CLI_MT5_MISSING' is not set.",
|
||||
id="missing-placeholder",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_cli_placeholder_resolution(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
mocker: MockerFixture,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
args: list[str],
|
||||
env: dict[str, str],
|
||||
exit_code: int,
|
||||
match: str | None,
|
||||
) -> None:
|
||||
"""CLI config fields support SDK-style ${ENV_VAR} placeholders."""
|
||||
for name, value in env.items():
|
||||
monkeypatch.setenv(name, value)
|
||||
mock_client = MagicMock()
|
||||
mock_client.account_info_as_df.return_value = pd.DataFrame({"a": [1]})
|
||||
mt5_client = mocker.patch(
|
||||
"mt5cli.sdk.Mt5DataClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
output = tmp_path / "out.csv"
|
||||
|
||||
result = runner.invoke(app, [*args, "-o", str(output), "account-info"])
|
||||
|
||||
assert result.exit_code == exit_code, result.output
|
||||
if exit_code == 0:
|
||||
config = mt5_client.call_args.kwargs["config"]
|
||||
assert config.login == 789
|
||||
assert config.server == "Placeholder-Server"
|
||||
else:
|
||||
assert match is not None
|
||||
assert match in normalize_cli_output(result.output)
|
||||
|
||||
def test_explicit_format(
|
||||
self,
|
||||
@@ -1526,6 +1680,131 @@ class TestCollectHistory:
|
||||
)
|
||||
|
||||
|
||||
class TestHistoryGapsCommand:
|
||||
"""Tests for the history-gaps CLI command."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("extra_args", "expected_tables", "expected_rows"),
|
||||
[
|
||||
pytest.param([], {"rate_EURUSD__M1_1", "rate_GBPUSD__M1_1"}, 2, id="all"),
|
||||
pytest.param(
|
||||
["--table", "rate_EURUSD__M1_1"],
|
||||
{"rate_EURUSD__M1_1"},
|
||||
1,
|
||||
id="explicit-table",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_history_gaps_exports_sqlite_report_without_mt5(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
mock_client: MagicMock,
|
||||
extra_args: list[str],
|
||||
expected_tables: set[str],
|
||||
expected_rows: int,
|
||||
) -> None:
|
||||
"""history-gaps reads SQLite only and exports one row per gap."""
|
||||
database = tmp_path / "history.db"
|
||||
output = tmp_path / "gaps.json"
|
||||
with sqlite3.connect(database) 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", 1, "2024-01-01T00:02:00+00:00", 1.1),
|
||||
("GBPUSD", 1, "2024-01-01T00:00:00+00:00", 1.2),
|
||||
("GBPUSD", 1, "2024-01-01T00:02:00+00:00", 1.3),
|
||||
],
|
||||
)
|
||||
conn.execute(
|
||||
'CREATE VIEW "rate_EURUSD__M1_1" AS '
|
||||
"SELECT time, close FROM rates "
|
||||
"WHERE symbol = 'EURUSD' AND timeframe = 1",
|
||||
)
|
||||
conn.execute(
|
||||
'CREATE VIEW "rate_GBPUSD__M1_1" AS '
|
||||
"SELECT time, close FROM rates "
|
||||
"WHERE symbol = 'GBPUSD' AND timeframe = 1",
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"-o",
|
||||
str(output),
|
||||
"history-gaps",
|
||||
"--sqlite3",
|
||||
str(database),
|
||||
*extra_args,
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
data = json.loads(output.read_text())
|
||||
assert len(data) == expected_rows
|
||||
assert {row["table"] for row in data} == expected_tables
|
||||
mock_client.initialize_and_login_mt5.assert_not_called()
|
||||
|
||||
def test_history_gaps_requires_compatible_default_views(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Without --table, history-gaps should reject DBs with no managed views."""
|
||||
database = tmp_path / "empty.db"
|
||||
output = tmp_path / "gaps.json"
|
||||
with sqlite3.connect(database):
|
||||
pass
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["-o", str(output), "history-gaps", "--sqlite3", str(database)],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "No managed rate compatibility views found" in result.output
|
||||
|
||||
def test_history_gaps_requires_granularity_for_custom_tables(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Custom tables need an explicit granularity when no view naming exists."""
|
||||
database = tmp_path / "custom.db"
|
||||
output = tmp_path / "gaps.json"
|
||||
with sqlite3.connect(database) as conn:
|
||||
conn.execute("CREATE TABLE custom_rates(time TEXT, close REAL)")
|
||||
conn.executemany(
|
||||
"INSERT INTO custom_rates(time, close) VALUES (?, ?)",
|
||||
[
|
||||
("2024-01-01T00:00:00+00:00", 1.0),
|
||||
("2024-01-01T00:02:00+00:00", 1.1),
|
||||
],
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"-o",
|
||||
str(output),
|
||||
"history-gaps",
|
||||
"--sqlite3",
|
||||
str(database),
|
||||
"--table",
|
||||
"custom_rates",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
output = normalize_cli_output(result.output)
|
||||
assert "Could not infer granularity" in output
|
||||
assert "'custom_rates'" in output
|
||||
assert re.search(r"--granularity-\s*seconds", output) is not None
|
||||
|
||||
|
||||
class TestGrafanaSchemaCommand:
|
||||
"""Tests for the grafana-schema CLI command."""
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ from mt5cli.history import (
|
||||
parse_sqlite_timestamp,
|
||||
quote_sqlite_identifier,
|
||||
record_written_columns,
|
||||
report_rate_gaps,
|
||||
resolve_granularity_name,
|
||||
resolve_history_datasets,
|
||||
resolve_history_tick_flags,
|
||||
@@ -3075,6 +3076,299 @@ class TestRateSourceHelpers:
|
||||
|
||||
assert set(result) == {("EURUSD", "M1"), ("EURUSD", "H1")}
|
||||
|
||||
def test_report_rate_gaps_reports_one_row_per_gap(self, tmp_path: Path) -> None:
|
||||
"""Gap reports emit one row for each detected missing interval run."""
|
||||
db_path = tmp_path / "gaps.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", 1, "2024-01-01T00:01:00+00:00", 1.1),
|
||||
("EURUSD", 1, "2024-01-01T00:03:00+00:00", 1.2),
|
||||
],
|
||||
)
|
||||
conn.execute(
|
||||
'CREATE VIEW "rate_EURUSD__M1_1" AS '
|
||||
"SELECT time, close FROM rates "
|
||||
"WHERE symbol = 'EURUSD' AND timeframe = 1",
|
||||
)
|
||||
result = report_rate_gaps(
|
||||
conn,
|
||||
"rate_EURUSD__M1_1",
|
||||
granularity_seconds=60,
|
||||
)
|
||||
|
||||
records = cast("list[dict[str, object]]", result.to_dict("records"))
|
||||
assert records == [
|
||||
{
|
||||
"table": "rate_EURUSD__M1_1",
|
||||
"symbol": "EURUSD",
|
||||
"timeframe": 1,
|
||||
"granularity": "M1",
|
||||
"granularity_seconds": 60,
|
||||
"gap_start": datetime(2024, 1, 1, 0, 2, tzinfo=UTC),
|
||||
"gap_end": datetime(2024, 1, 1, 0, 2, tzinfo=UTC),
|
||||
"missing_intervals": 1,
|
||||
},
|
||||
]
|
||||
|
||||
def test_report_rate_gaps_parses_numeric_sqlite_times_as_epoch_seconds(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Numeric SQLite timestamps must be interpreted as Unix seconds."""
|
||||
db_path = tmp_path / "numeric-gaps.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("CREATE TABLE custom_rates(time INTEGER, close REAL)")
|
||||
conn.executemany(
|
||||
"INSERT INTO custom_rates(time, close) VALUES (?, ?)",
|
||||
[
|
||||
(1704067200, 1.0),
|
||||
(1704067320, 1.1),
|
||||
],
|
||||
)
|
||||
result = report_rate_gaps(
|
||||
conn,
|
||||
"custom_rates",
|
||||
granularity_seconds=60,
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result.iloc[0]["missing_intervals"] == 1
|
||||
|
||||
def test_report_rate_gaps_empty_schema_for_zero_gap_tables(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Tables without gaps return the stable empty result schema."""
|
||||
db_path = tmp_path / "no-gaps.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("CREATE TABLE custom_rates(time TEXT, close REAL)")
|
||||
conn.executemany(
|
||||
"INSERT INTO custom_rates(time, close) VALUES (?, ?)",
|
||||
[
|
||||
("2024-01-01T00:00:00+00:00", 1.0),
|
||||
("2024-01-01T00:01:00+00:00", 1.1),
|
||||
],
|
||||
)
|
||||
result = report_rate_gaps(
|
||||
conn,
|
||||
"custom_rates",
|
||||
granularity_seconds=60,
|
||||
)
|
||||
|
||||
assert list(result.columns) == [
|
||||
"table",
|
||||
"symbol",
|
||||
"timeframe",
|
||||
"granularity",
|
||||
"granularity_seconds",
|
||||
"gap_start",
|
||||
"gap_end",
|
||||
"missing_intervals",
|
||||
]
|
||||
assert result.empty
|
||||
|
||||
def test_report_rate_gaps_filters_by_min_gap_intervals(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Small gaps are filtered out when min_gap_intervals is raised."""
|
||||
db_path = tmp_path / "filtered-gaps.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("CREATE TABLE custom_rates(time TEXT, close REAL)")
|
||||
conn.executemany(
|
||||
"INSERT INTO custom_rates(time, close) VALUES (?, ?)",
|
||||
[
|
||||
("2024-01-01T00:00:00+00:00", 1.0),
|
||||
("2024-01-01T00:02:00+00:00", 1.1),
|
||||
],
|
||||
)
|
||||
result = report_rate_gaps(
|
||||
conn,
|
||||
"custom_rates",
|
||||
granularity_seconds=60,
|
||||
min_gap_intervals=2,
|
||||
)
|
||||
|
||||
assert result.empty
|
||||
|
||||
def test_report_rate_gaps_computes_gaps_per_series_key(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Managed rates tables must detect gaps within each symbol/timeframe series."""
|
||||
db_path = tmp_path / "series-gaps.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),
|
||||
("GBPUSD", 1, "2024-01-01T00:01:00+00:00", 1.1),
|
||||
("EURUSD", 1, "2024-01-01T00:02:00+00:00", 1.2),
|
||||
],
|
||||
)
|
||||
|
||||
result = report_rate_gaps(
|
||||
conn,
|
||||
"rates",
|
||||
granularity_seconds=60,
|
||||
)
|
||||
|
||||
records = cast("list[dict[str, object]]", result.to_dict("records"))
|
||||
assert records == [
|
||||
{
|
||||
"table": "rates",
|
||||
"symbol": "EURUSD",
|
||||
"timeframe": 1,
|
||||
"granularity": "M1",
|
||||
"granularity_seconds": 60,
|
||||
"gap_start": datetime(2024, 1, 1, 0, 1, tzinfo=UTC),
|
||||
"gap_end": datetime(2024, 1, 1, 0, 1, tzinfo=UTC),
|
||||
"missing_intervals": 1,
|
||||
},
|
||||
]
|
||||
|
||||
def test_rate_gap_private_helpers_and_validation(self) -> None:
|
||||
"""Private helpers should preserve schema and reject invalid inputs."""
|
||||
empty = history._empty_rate_gap_report() # type: ignore[attr-defined]
|
||||
assert list(empty.columns) == [
|
||||
"table",
|
||||
"symbol",
|
||||
"timeframe",
|
||||
"granularity",
|
||||
"granularity_seconds",
|
||||
"gap_start",
|
||||
"gap_end",
|
||||
"missing_intervals",
|
||||
]
|
||||
|
||||
class _BadInt:
|
||||
def __int__(self) -> int:
|
||||
msg = "bad-int"
|
||||
raise ValueError(msg)
|
||||
|
||||
assert history._coerce_optional_int(None) is None # type: ignore[attr-defined]
|
||||
false_value: object = False
|
||||
assert history._coerce_optional_int(false_value) is None # type: ignore[attr-defined]
|
||||
assert history._coerce_optional_int(" +7 ") == 7 # type: ignore[attr-defined]
|
||||
assert history._coerce_optional_int("bad") is None # type: ignore[attr-defined]
|
||||
assert history._coerce_optional_int(object()) is None # type: ignore[attr-defined]
|
||||
assert history._coerce_optional_int(_BadInt()) is None # type: ignore[attr-defined]
|
||||
|
||||
metadata = history._rate_gap_metadata( # type: ignore[attr-defined]
|
||||
"custom_rates",
|
||||
pd.DataFrame({
|
||||
"symbol": ["EURUSD", "GBPUSD"],
|
||||
"timeframe": [1, 1],
|
||||
}),
|
||||
granularity_seconds=60,
|
||||
)
|
||||
assert metadata["symbol"] is None
|
||||
assert metadata["timeframe"] == 1
|
||||
assert metadata["granularity"] == "M1"
|
||||
|
||||
fallback_metadata = history._rate_gap_metadata( # type: ignore[attr-defined]
|
||||
"rate_USDJPY__M1_1",
|
||||
pd.DataFrame({"time": []}),
|
||||
granularity_seconds=60,
|
||||
)
|
||||
assert fallback_metadata["symbol"] == "USDJPY"
|
||||
assert fallback_metadata["timeframe"] == 1
|
||||
assert fallback_metadata["granularity"] == "M1"
|
||||
|
||||
unique_symbol_metadata = history._rate_gap_metadata( # type: ignore[attr-defined]
|
||||
"custom_rates",
|
||||
pd.DataFrame({"symbol": ["EURUSD"], "timeframe": [1]}),
|
||||
granularity_seconds=60,
|
||||
)
|
||||
assert unique_symbol_metadata["symbol"] == "EURUSD"
|
||||
|
||||
multi_timeframe_metadata = history._rate_gap_metadata( # type: ignore[attr-defined]
|
||||
"custom_rates",
|
||||
pd.DataFrame({"timeframe": [1, 5]}),
|
||||
granularity_seconds=60,
|
||||
)
|
||||
assert multi_timeframe_metadata["timeframe"] is None
|
||||
assert multi_timeframe_metadata["granularity"] is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("granularity_seconds", "min_gap_intervals", "match"),
|
||||
[
|
||||
pytest.param(
|
||||
0, 1, "granularity_seconds must be positive", id="bad-seconds"
|
||||
),
|
||||
pytest.param(60, 0, "min_gap_intervals must be positive", id="bad-min-gap"),
|
||||
],
|
||||
)
|
||||
def test_report_rate_gaps_rejects_invalid_parameters(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
granularity_seconds: int,
|
||||
min_gap_intervals: int,
|
||||
match: str,
|
||||
) -> None:
|
||||
"""Gap reports reject non-positive granularity and min-gap values."""
|
||||
db_path = tmp_path / "invalid-gaps.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("CREATE TABLE custom_rates(time TEXT, close REAL)")
|
||||
conn.execute(
|
||||
"INSERT INTO custom_rates(time, close) VALUES (?, ?)",
|
||||
("2024-01-01T00:00:00+00:00", 1.0),
|
||||
)
|
||||
with pytest.raises(ValueError, match=match):
|
||||
report_rate_gaps(
|
||||
conn,
|
||||
"custom_rates",
|
||||
granularity_seconds=granularity_seconds,
|
||||
min_gap_intervals=min_gap_intervals,
|
||||
)
|
||||
|
||||
def test_report_rate_gaps_rejects_unparseable_timestamps(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Unparseable table times should fail clearly."""
|
||||
db_path = tmp_path / "bad-times.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("CREATE TABLE custom_rates(time TEXT, close REAL)")
|
||||
conn.execute(
|
||||
"INSERT INTO custom_rates(time, close) VALUES (?, ?)",
|
||||
("bad-time", 1.0),
|
||||
)
|
||||
with pytest.raises(ValueError, match="contains unparsable time values"):
|
||||
report_rate_gaps(
|
||||
conn,
|
||||
"custom_rates",
|
||||
granularity_seconds=60,
|
||||
)
|
||||
|
||||
def test_report_rate_gaps_empty_and_single_row_sources_return_empty(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Empty or single-row sources cannot produce gap rows."""
|
||||
db_path = tmp_path / "too-short.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("CREATE TABLE custom_rates(time TEXT, close REAL)")
|
||||
assert report_rate_gaps(conn, "custom_rates", granularity_seconds=60).empty
|
||||
conn.execute(
|
||||
"INSERT INTO custom_rates(time, close) VALUES (?, ?)",
|
||||
("2024-01-01T00:00:00+00:00", 1.0),
|
||||
)
|
||||
assert report_rate_gaps(conn, "custom_rates", granularity_seconds=60).empty
|
||||
|
||||
def test_load_rate_series_by_granularity_explicit_tables(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
|
||||
@@ -23,6 +23,7 @@ from mt5cli.trading import (
|
||||
OrderLimits,
|
||||
OrderSide,
|
||||
ProjectionMode,
|
||||
_filter_positions, # type: ignore[reportPrivateUsage]
|
||||
_Mt5ClientProtocol, # type: ignore[reportPrivateUsage]
|
||||
calculate_account_projected_margin_ratio,
|
||||
calculate_margin_and_volume,
|
||||
@@ -52,6 +53,7 @@ from mt5cli.trading import (
|
||||
mt5_trading_session,
|
||||
normalize_order_volume,
|
||||
place_market_order,
|
||||
resolve_broker_filling_mode,
|
||||
update_sltp_for_open_positions,
|
||||
update_trailing_stop_loss_for_open_positions,
|
||||
)
|
||||
@@ -66,7 +68,11 @@ def _mock_trade_client() -> MagicMock:
|
||||
client.mt5.TRADE_ACTION_DEAL = 20
|
||||
client.mt5.TRADE_ACTION_SLTP = 21
|
||||
client.mt5.ORDER_FILLING_IOC = 30
|
||||
client.mt5.SYMBOL_FILLING_FOK = 1
|
||||
client.mt5.SYMBOL_FILLING_IOC = 2
|
||||
client.mt5.ORDER_TIME_GTC = 40
|
||||
client.mt5.SYMBOL_TRADE_EXECUTION_MARKET = 3
|
||||
client.mt5.SYMBOL_TRADE_EXECUTION_REQUEST = 4
|
||||
client.mt5.TRADE_RETCODE_PLACED = 10008
|
||||
client.mt5.TRADE_RETCODE_DONE = 10009
|
||||
client.mt5.TRADE_RETCODE_DONE_PARTIAL = 10010
|
||||
@@ -115,6 +121,34 @@ class TestDetectPositionSide:
|
||||
|
||||
assert detect_position_side(client, "EURUSD") == expected
|
||||
|
||||
def test_detect_position_side_filters_by_magic(self) -> None:
|
||||
"""Magic-scoped side detection ignores foreign positions."""
|
||||
client = MagicMock()
|
||||
client.mt5.POSITION_TYPE_BUY = 0
|
||||
client.mt5.POSITION_TYPE_SELL = 1
|
||||
client.positions_get_as_df.return_value = pd.DataFrame(
|
||||
[
|
||||
{"type": 0, "volume": 0.3, "magic": 7},
|
||||
{"type": 1, "volume": 0.2, "magic": 9},
|
||||
],
|
||||
)
|
||||
|
||||
assert detect_position_side(client, "EURUSD", magic=7) == "long"
|
||||
assert detect_position_side(client, "EURUSD", magic=9) == "short"
|
||||
|
||||
def test_detect_position_side_magic_is_fail_closed_without_magic_column(
|
||||
self,
|
||||
) -> None:
|
||||
"""Magic-scoped side detection returns None without magic metadata."""
|
||||
client = MagicMock()
|
||||
client.mt5.POSITION_TYPE_BUY = 0
|
||||
client.mt5.POSITION_TYPE_SELL = 1
|
||||
client.positions_get_as_df.return_value = pd.DataFrame(
|
||||
[{"type": 0, "volume": 0.3}],
|
||||
)
|
||||
|
||||
assert detect_position_side(client, "EURUSD", magic=7) is None
|
||||
|
||||
|
||||
class TestCalculateMarginAndVolume:
|
||||
"""Tests for calculate_margin_and_volume."""
|
||||
@@ -2102,6 +2136,28 @@ class TestVolumeAndExecution:
|
||||
_assert_close(_request_from_result(result)["sl"], 1.0)
|
||||
_assert_close(_request_from_result(result)["tp"], 1.4)
|
||||
|
||||
def test_place_market_order_supports_optional_deviation_comment_and_magic(
|
||||
self,
|
||||
) -> None:
|
||||
"""Test optional request metadata is preserved for market orders."""
|
||||
client = _mock_trade_client()
|
||||
client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1}
|
||||
|
||||
result = place_market_order(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
volume=0.1,
|
||||
order_side="BUY",
|
||||
deviation=7,
|
||||
comment="close-me",
|
||||
magic=42,
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
assert _request_from_result(result)["deviation"] == 7
|
||||
assert _request_from_result(result)["comment"] == "close-me"
|
||||
assert _request_from_result(result)["magic"] == 42
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode_kwarg", "match"),
|
||||
[
|
||||
@@ -2252,6 +2308,153 @@ class TestVolumeAndExecution:
|
||||
assert result["retcode"] == expected_retcode
|
||||
assert result["status"] == "failed"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("symbol_info", "preferred_modes", "default_mode", "expected"),
|
||||
[
|
||||
(
|
||||
{"filling_mode": 2, "trade_exemode": 3},
|
||||
("IOC", "FOK"),
|
||||
"IOC",
|
||||
"IOC",
|
||||
),
|
||||
(
|
||||
{"filling_mode": 1, "trade_exemode": 3},
|
||||
("IOC", "FOK"),
|
||||
"IOC",
|
||||
"FOK",
|
||||
),
|
||||
(
|
||||
{"filling_mode": 0, "trade_exemode": 4},
|
||||
("RETURN", "IOC"),
|
||||
"IOC",
|
||||
"RETURN",
|
||||
),
|
||||
(
|
||||
{"filling_mode": None, "trade_exemode": None},
|
||||
("RETURN", "FOK"),
|
||||
"IOC",
|
||||
"RETURN",
|
||||
),
|
||||
(
|
||||
{"filling_mode": 2, "trade_exemode": 3},
|
||||
("FOK",),
|
||||
"IOC",
|
||||
"IOC",
|
||||
),
|
||||
(
|
||||
{"filling_mode": 2, "trade_exemode": 3},
|
||||
("FOK",),
|
||||
"RETURN",
|
||||
"IOC",
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"ioc",
|
||||
"fok-fallback",
|
||||
"return",
|
||||
"default-fallback",
|
||||
"supported-default-fallback",
|
||||
"ignore-unsupported-default",
|
||||
],
|
||||
)
|
||||
def test_resolve_broker_filling_mode(
|
||||
self,
|
||||
symbol_info: dict[str, object],
|
||||
preferred_modes: tuple[str, ...],
|
||||
default_mode: str,
|
||||
expected: str,
|
||||
) -> None:
|
||||
"""Test filling-mode resolution prefers supported modes then falls back."""
|
||||
client = _mock_trade_client()
|
||||
client.symbol_info_as_dict.return_value = symbol_info
|
||||
|
||||
result = resolve_broker_filling_mode(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
preferred_modes=cast("Any", preferred_modes),
|
||||
default_mode=cast("Any", default_mode),
|
||||
)
|
||||
|
||||
assert result == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("preferred_modes", "default_mode"),
|
||||
[
|
||||
pytest.param(("BAD",), "IOC", id="bad-preferred"),
|
||||
pytest.param(("IOC",), "BAD", id="bad-default"),
|
||||
],
|
||||
)
|
||||
def test_resolve_broker_filling_mode_rejects_invalid_mode_names(
|
||||
self,
|
||||
preferred_modes: tuple[str, ...],
|
||||
default_mode: str,
|
||||
) -> None:
|
||||
"""Test invalid preferred/default filling mode names raise ValueError."""
|
||||
client = _mock_trade_client()
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported order_filling mode"):
|
||||
resolve_broker_filling_mode(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
preferred_modes=cast("Any", preferred_modes),
|
||||
default_mode=cast("Any", default_mode),
|
||||
)
|
||||
|
||||
def test_resolve_broker_filling_mode_keeps_preferred_when_metadata_missing(
|
||||
self,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Missing metadata should fail open to the caller-preferred mode."""
|
||||
client = _mock_trade_client()
|
||||
client.symbol_info_as_dict.return_value = {"filling_mode": None}
|
||||
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
result = resolve_broker_filling_mode(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
preferred_modes=("FOK", "IOC"),
|
||||
)
|
||||
|
||||
assert result == "FOK"
|
||||
assert "keeping preferred mode" in caplog.text
|
||||
|
||||
def test_resolve_broker_filling_mode_supports_return_without_bitmask(self) -> None:
|
||||
"""RETURN should be allowed when execution mode is non-market."""
|
||||
client = _mock_trade_client()
|
||||
client.symbol_info_as_dict.return_value = {
|
||||
"filling_mode": None,
|
||||
"trade_exemode": client.mt5.SYMBOL_TRADE_EXECUTION_REQUEST,
|
||||
}
|
||||
|
||||
result = resolve_broker_filling_mode(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
preferred_modes=("RETURN", "FOK"),
|
||||
)
|
||||
|
||||
assert result == "RETURN"
|
||||
|
||||
def test_resolve_broker_filling_mode_keeps_preferred_when_metadata_unparseable(
|
||||
self,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Unparseable metadata should still fail open to the preferred mode."""
|
||||
client = _mock_trade_client()
|
||||
client.symbol_info_as_dict.return_value = {
|
||||
"filling_mode": 0,
|
||||
"trade_exemode": client.mt5.SYMBOL_TRADE_EXECUTION_MARKET,
|
||||
}
|
||||
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
result = resolve_broker_filling_mode(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
preferred_modes=("FOK", "IOC"),
|
||||
)
|
||||
|
||||
assert result == "FOK"
|
||||
assert "unparseable" in caplog.text
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("filter_kwargs", "expected_order_side", "expected_position"),
|
||||
[
|
||||
@@ -2306,6 +2509,50 @@ class TestVolumeAndExecution:
|
||||
|
||||
assert client.order_send.call_args.args[0]["position"] == 9
|
||||
|
||||
def test_close_open_positions_forwards_optional_request_fields(self) -> None:
|
||||
"""Test close helper forwards deviation/comment/magic into dry-run requests."""
|
||||
client = _mock_trade_client()
|
||||
client.positions_get_as_df.return_value = pd.DataFrame(
|
||||
[{"ticket": 9, "symbol": "EURUSD", "type": 0, "volume": 0.1, "magic": 42}],
|
||||
)
|
||||
client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1}
|
||||
|
||||
result = close_open_positions(
|
||||
client,
|
||||
tickets=[9],
|
||||
deviation=8,
|
||||
comment="close-me",
|
||||
magic=42,
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
request = _request_from_result(result[0])
|
||||
assert request["deviation"] == 8
|
||||
assert request["comment"] == "close-me"
|
||||
assert request["magic"] == 42
|
||||
|
||||
def test_close_open_positions_magic_filter_is_fail_closed_without_column(
|
||||
self,
|
||||
) -> None:
|
||||
"""Test magic-scoped close operations skip rows without magic metadata."""
|
||||
client = _mock_trade_client()
|
||||
client.positions_get_as_df.return_value = pd.DataFrame(
|
||||
[{"ticket": 9, "symbol": "EURUSD", "type": 0, "volume": 0.1}],
|
||||
)
|
||||
|
||||
result = close_open_positions(client, magic=42, dry_run=True)
|
||||
|
||||
assert result == []
|
||||
client.order_send.assert_not_called()
|
||||
|
||||
def test_filter_positions_magic_is_fail_closed_without_magic_column(self) -> None:
|
||||
"""Test direct magic filtering fails closed when the DataFrame lacks magic."""
|
||||
positions = pd.DataFrame([{"ticket": 1, "symbol": "EURUSD"}])
|
||||
|
||||
result = _filter_positions(positions, magic=42)
|
||||
|
||||
assert result.empty
|
||||
|
||||
def test_calculate_trailing_stop_updates_no_positions(self) -> None:
|
||||
"""Test empty position sets produce no trailing updates."""
|
||||
client = _mock_trade_client()
|
||||
|
||||
Reference in New Issue
Block a user