feat: add fetch_latest_closed_rates_indexed and allow_whole_dollar_env opt-in (#45)
* feat: add fetch_latest_closed_rates_indexed and allow_whole_dollar_env opt-in (#43, #44) Closes #43: add fetch_latest_closed_rates_indexed(client, *, symbol, granularity, count) -> pd.DataFrame to mt5cli/trading.py. Internally reuses fetch_latest_closed_rates_for_trading_client(), converts the "time" column to a UTC-aware DatetimeIndex named "time", and drops the original column. Exported from trading.__all__, mt5cli.__init__, and STABLE_SDK_EXPORTS. Closes #44: extend substitute_env_placeholders() with opt-in allow_whole_dollar_env=False that expands whole-value $ENV_NAME strings (entire string must be exactly $IDENTIFIER). Threaded through build_config(), resolve_account_spec(), and resolve_account_specs() with the same default=False. Partial strings like "plan$pass", "abc$ENV", or "$ENV-suffix" are never expanded. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: align Markdown table columns in docs and skill file Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: treat numeric (float64) epoch seconds as UTC in _rate_time_to_utc After DataFrame concat or NA upcast the time column becomes float64, which is still epoch seconds. Using is_numeric_dtype instead of is_integer_dtype fixes the silent misalignment. Using series.to_numpy() before passing to pd.to_datetime avoids the redundant pd.DatetimeIndex() wrapper and aligns with how existing rate-time normalization in schemas.py handles numeric timestamps. Add test_converts_float_epoch_seconds_to_utc_datetime_index to cover the regression. Add a doc note clarifying that build_config cannot expand login since that parameter is int | None. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: reject NaT values after rate timestamp conversion in _rate_time_to_utc pd.to_datetime() silently produces NaT for None/NaN inputs rather than raising, so the function could return a DatetimeIndex containing NaT despite documenting invalid timestamps as a ValueError. Check any(idx.isna()) after conversion and raise with a clear message. Add test_raises_on_nat_time_column to cover the regression. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Bump version to v0.9.0 * fix: handle object numeric rate timestamps --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,7 @@ from mt5cli import (
|
||||
export_dataframe_to_sqlite,
|
||||
fetch_latest_closed_rates,
|
||||
fetch_latest_closed_rates_for_trading_client,
|
||||
fetch_latest_closed_rates_indexed,
|
||||
granularity_name,
|
||||
is_recoverable_mt5_error,
|
||||
load_rate_data,
|
||||
@@ -734,3 +735,32 @@ class TestStableSdkContract:
|
||||
raise RuntimeError(message)
|
||||
|
||||
mock_client.shutdown.assert_called_once()
|
||||
|
||||
def test_fetch_latest_closed_rates_indexed_from_package_root(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Indexed closed-bar helper returns a UTC DatetimeIndex named 'time'."""
|
||||
client = MagicMock()
|
||||
mocker.patch(
|
||||
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
||||
return_value=pd.DataFrame(
|
||||
{
|
||||
"time": [1704067200, 1704153600, 1704240000],
|
||||
"close": [1.0, 1.1, 1.2],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
result = fetch_latest_closed_rates_indexed(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=2,
|
||||
)
|
||||
|
||||
assert isinstance(result.index, pd.DatetimeIndex)
|
||||
assert result.index.name == "time"
|
||||
assert result.index.tz is not None
|
||||
assert "time" not in result.columns
|
||||
assert "close" in result.columns
|
||||
|
||||
@@ -1777,6 +1777,80 @@ class TestSubstituteEnvPlaceholders:
|
||||
with pytest.raises(ValueError, match="'MT5_MISSING' is not set"):
|
||||
substitute_env_placeholders("${MT5_MISSING}")
|
||||
|
||||
def test_whole_dollar_not_substituted_by_default(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test $ENV_NAME is not expanded without allow_whole_dollar_env=True."""
|
||||
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||
|
||||
assert substitute_env_placeholders("$MT5_PASSWORD") == "$MT5_PASSWORD"
|
||||
|
||||
def test_whole_dollar_substituted_with_opt_in(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test $ENV_NAME is expanded when allow_whole_dollar_env=True."""
|
||||
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||
|
||||
result = substitute_env_placeholders(
|
||||
"$MT5_PASSWORD", allow_whole_dollar_env=True
|
||||
)
|
||||
|
||||
assert result == "secret"
|
||||
|
||||
def test_whole_dollar_missing_variable_raises_value_error(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test missing $ENV_NAME raises ValueError when opt-in is enabled."""
|
||||
monkeypatch.delenv("MT5_MISSING", raising=False)
|
||||
|
||||
with pytest.raises(ValueError, match="'MT5_MISSING' is not set"):
|
||||
substitute_env_placeholders("$MT5_MISSING", allow_whole_dollar_env=True)
|
||||
|
||||
def test_partial_dollar_not_expanded_with_opt_in(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test $ENV embedded in a larger string is not expanded."""
|
||||
monkeypatch.setenv("pass", "secret")
|
||||
monkeypatch.setenv("ENV", "val")
|
||||
|
||||
assert (
|
||||
substitute_env_placeholders("plan$pass", allow_whole_dollar_env=True)
|
||||
== "plan$pass"
|
||||
)
|
||||
assert (
|
||||
substitute_env_placeholders("abc$ENV", allow_whole_dollar_env=True)
|
||||
== "abc$ENV"
|
||||
)
|
||||
|
||||
def test_dollar_with_suffix_not_expanded_with_opt_in(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test $ENV-suffix is not expanded (not a whole-value placeholder)."""
|
||||
monkeypatch.setenv("ENV", "val")
|
||||
|
||||
assert (
|
||||
substitute_env_placeholders("$ENV-suffix", allow_whole_dollar_env=True)
|
||||
== "$ENV-suffix"
|
||||
)
|
||||
|
||||
def test_brace_format_works_with_opt_in(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test ${ENV_VAR} substitution still works when allow_whole_dollar_env=True."""
|
||||
monkeypatch.setenv("MT5_LOGIN", "12345")
|
||||
|
||||
result = substitute_env_placeholders(
|
||||
"${MT5_LOGIN}", allow_whole_dollar_env=True
|
||||
)
|
||||
|
||||
assert result == "12345"
|
||||
|
||||
|
||||
class TestResolveAccountSpec:
|
||||
"""Tests for resolve_account_spec and resolve_account_specs."""
|
||||
@@ -1863,6 +1937,117 @@ class TestResolveAccountSpec:
|
||||
assert [a.server for a in resolved] == ["Shared", "Fixed"]
|
||||
assert all(a.timeout == 1000 for a in resolved)
|
||||
|
||||
def test_resolve_account_spec_with_whole_dollar_env(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Account spec expands $ENV_NAME when allow_whole_dollar_env=True."""
|
||||
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||
account = AccountSpec(symbols=["EURUSD"], password="$MT5_PASSWORD")
|
||||
|
||||
resolved = resolve_account_spec(account, allow_whole_dollar_env=True)
|
||||
|
||||
assert resolved.password == "secret" # noqa: S105
|
||||
|
||||
def test_resolve_account_spec_whole_dollar_not_expanded_by_default(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test resolve_account_spec leaves $ENV_NAME literal by default."""
|
||||
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||
account = AccountSpec(symbols=["EURUSD"], password="$MT5_PASSWORD")
|
||||
|
||||
resolved = resolve_account_spec(account)
|
||||
|
||||
assert resolved.password == "$MT5_PASSWORD" # noqa: S105
|
||||
|
||||
def test_resolve_account_specs_with_whole_dollar_env(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test resolve_account_specs threads allow_whole_dollar_env to each account."""
|
||||
monkeypatch.setenv("MT5_SERVER", "Broker-Demo")
|
||||
accounts = [
|
||||
AccountSpec(symbols=["EURUSD"], server="$MT5_SERVER"),
|
||||
AccountSpec(symbols=["GBPUSD"], server="Fixed"),
|
||||
]
|
||||
|
||||
resolved = resolve_account_specs(accounts, allow_whole_dollar_env=True)
|
||||
|
||||
assert resolved[0].server == "Broker-Demo"
|
||||
assert resolved[1].server == "Fixed"
|
||||
|
||||
def test_resolve_account_spec_whole_dollar_login(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test $ENV_NAME login string is expanded when allow_whole_dollar_env=True."""
|
||||
monkeypatch.setenv("MT5_LOGIN", "12345")
|
||||
account = AccountSpec(symbols=["EURUSD"], login="$MT5_LOGIN")
|
||||
|
||||
resolved = resolve_account_spec(account, allow_whole_dollar_env=True)
|
||||
|
||||
assert resolved.login == "12345"
|
||||
|
||||
|
||||
class TestBuildConfigWholeDollarEnv:
|
||||
"""Tests for build_config with allow_whole_dollar_env."""
|
||||
|
||||
def test_build_config_substitutes_server_with_opt_in(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""build_config expands $ENV_NAME server when allow_whole_dollar_env=True."""
|
||||
monkeypatch.setenv("MT5_SERVER", "Broker-Demo")
|
||||
|
||||
config = build_config(server="$MT5_SERVER", allow_whole_dollar_env=True)
|
||||
|
||||
assert config.server == "Broker-Demo"
|
||||
|
||||
def test_build_config_substitutes_password_with_opt_in(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""build_config expands $ENV_NAME password when allow_whole_dollar_env=True."""
|
||||
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||
|
||||
config = build_config(password="$MT5_PASSWORD", allow_whole_dollar_env=True)
|
||||
|
||||
assert config.password == "secret" # noqa: S105
|
||||
|
||||
def test_build_config_substitutes_path_with_opt_in(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test build_config expands $ENV_NAME path when allow_whole_dollar_env=True."""
|
||||
monkeypatch.setenv("MT5_PATH", "/opt/mt5/terminal64.exe")
|
||||
|
||||
config = build_config(path="$MT5_PATH", allow_whole_dollar_env=True)
|
||||
|
||||
assert config.path == "/opt/mt5/terminal64.exe"
|
||||
|
||||
def test_build_config_leaves_dollar_literal_by_default(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test build_config does not substitute $ENV without opt-in."""
|
||||
monkeypatch.setenv("MT5_SERVER", "Broker-Demo")
|
||||
|
||||
config = build_config(server="$MT5_SERVER")
|
||||
|
||||
assert config.server == "$MT5_SERVER"
|
||||
|
||||
def test_build_config_none_params_not_substituted(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch, # noqa: ARG002
|
||||
) -> None:
|
||||
"""Test build_config with None params does not raise even with opt-in."""
|
||||
config = build_config(allow_whole_dollar_env=True)
|
||||
|
||||
assert config.server is None
|
||||
assert config.password is None
|
||||
assert config.path is None
|
||||
|
||||
|
||||
class TestThrottledHistoryUpdater:
|
||||
"""Tests for the throttled incremental history updater."""
|
||||
|
||||
@@ -29,6 +29,7 @@ from mt5cli.trading import (
|
||||
ensure_symbol_selected,
|
||||
estimate_order_margin,
|
||||
fetch_latest_closed_rates_for_trading_client,
|
||||
fetch_latest_closed_rates_indexed,
|
||||
get_account_snapshot,
|
||||
get_positions_frame,
|
||||
get_symbol_snapshot,
|
||||
@@ -2627,3 +2628,265 @@ class TestFetchLatestClosedRatesForTradingClient:
|
||||
)
|
||||
|
||||
client.fetch_latest_rates_as_df.assert_not_called()
|
||||
|
||||
def test_returns_range_index_and_time_column_for_backward_compat(self) -> None:
|
||||
"""Test original helper returns RangeIndex with a time column."""
|
||||
client = MagicMock()
|
||||
client.fetch_latest_rates_as_df.return_value = pd.DataFrame(
|
||||
{"time": [1700000000, 1700003600, 1700007200], "close": [1.1, 1.2, 1.3]},
|
||||
)
|
||||
|
||||
result = fetch_latest_closed_rates_for_trading_client(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=2,
|
||||
)
|
||||
|
||||
assert isinstance(result.index, pd.RangeIndex)
|
||||
assert "time" in result.columns
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
class TestFetchLatestClosedRatesIndexed:
|
||||
"""Tests for fetch_latest_closed_rates_indexed."""
|
||||
|
||||
def test_converts_epoch_seconds_to_utc_datetime_index(
|
||||
self, mocker: MockerFixture
|
||||
) -> None:
|
||||
"""Test integer epoch second timestamps become a UTC DatetimeIndex."""
|
||||
frame = pd.DataFrame(
|
||||
{"time": [1700000000, 1700003600], "close": [1.1, 1.2]},
|
||||
)
|
||||
mocker.patch(
|
||||
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
||||
return_value=frame,
|
||||
)
|
||||
|
||||
result = fetch_latest_closed_rates_indexed(
|
||||
MagicMock(),
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=2,
|
||||
)
|
||||
|
||||
assert isinstance(result.index, pd.DatetimeIndex)
|
||||
assert result.index.name == "time"
|
||||
assert result.index.tz is not None
|
||||
assert str(result.index.tz) == "UTC"
|
||||
assert "time" not in result.columns
|
||||
assert list(result["close"]) == [1.1, 1.2]
|
||||
|
||||
def test_converts_float_epoch_seconds_to_utc_datetime_index(
|
||||
self, mocker: MockerFixture
|
||||
) -> None:
|
||||
"""Test float64 epoch second timestamps (after concat/NA upcast) become UTC."""
|
||||
frame = pd.DataFrame(
|
||||
{"time": [1700000000.0, 1700003600.0], "close": [1.1, 1.2]},
|
||||
)
|
||||
mocker.patch(
|
||||
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
||||
return_value=frame,
|
||||
)
|
||||
|
||||
result = fetch_latest_closed_rates_indexed(
|
||||
MagicMock(),
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=2,
|
||||
)
|
||||
|
||||
assert isinstance(result.index, pd.DatetimeIndex)
|
||||
assert result.index.tz is not None
|
||||
assert str(result.index.tz) == "UTC"
|
||||
assert result.index[0].year == 2023
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"timestamps",
|
||||
[
|
||||
[1700000000, 1700003600],
|
||||
[1700000000.0, 1700003600.0],
|
||||
],
|
||||
ids=["integers", "floats"],
|
||||
)
|
||||
def test_converts_object_numeric_epoch_seconds_to_utc_datetime_index(
|
||||
self, mocker: MockerFixture, timestamps: list[int] | list[float]
|
||||
) -> None:
|
||||
"""Test object-dtype Python numbers are interpreted as epoch seconds."""
|
||||
frame = pd.DataFrame(
|
||||
{
|
||||
"time": pd.Series(timestamps, dtype=object),
|
||||
"close": [1.1, 1.2],
|
||||
},
|
||||
)
|
||||
mocker.patch(
|
||||
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
||||
return_value=frame,
|
||||
)
|
||||
|
||||
result = fetch_latest_closed_rates_indexed(
|
||||
MagicMock(), symbol="EURUSD", granularity="M1", count=2
|
||||
)
|
||||
|
||||
assert list(result.index) == list(
|
||||
pd.to_datetime(timestamps, unit="s", utc=True)
|
||||
)
|
||||
|
||||
def test_parses_mixed_datetime_like_strings(self, mocker: MockerFixture) -> None:
|
||||
"""Test object-dtype datetime strings retain datetime-like parsing."""
|
||||
timestamps = ["2024-01-01T00:00:00Z", "2024-01-01T01:00:00+01:00"]
|
||||
frame = pd.DataFrame({"time": timestamps, "close": [1.1, 1.2]})
|
||||
mocker.patch(
|
||||
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
||||
return_value=frame,
|
||||
)
|
||||
|
||||
result = fetch_latest_closed_rates_indexed(
|
||||
MagicMock(), symbol="EURUSD", granularity="M1", count=2
|
||||
)
|
||||
|
||||
assert list(result.index) == list(pd.to_datetime(timestamps, utc=True))
|
||||
|
||||
def test_does_not_treat_bool_as_epoch_seconds(self, mocker: MockerFixture) -> None:
|
||||
"""Test bool timestamps do not enter the numeric epoch-seconds path."""
|
||||
frame = pd.DataFrame(
|
||||
{"time": pd.Series([True, False], dtype=object), "close": [1.1, 1.2]},
|
||||
)
|
||||
mocker.patch(
|
||||
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
||||
return_value=frame,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="invalid or unparseable time data"):
|
||||
fetch_latest_closed_rates_indexed(
|
||||
MagicMock(), symbol="EURUSD", granularity="M1", count=2
|
||||
)
|
||||
|
||||
def test_converts_naive_datetime_to_utc_datetime_index(
|
||||
self, mocker: MockerFixture
|
||||
) -> None:
|
||||
"""Test timezone-naive datetime values are localized to UTC."""
|
||||
from datetime import datetime # noqa: PLC0415
|
||||
|
||||
frame = pd.DataFrame(
|
||||
{
|
||||
"time": [datetime(2024, 1, 1, 0, 0), datetime(2024, 1, 1, 1, 0)], # noqa: DTZ001
|
||||
"close": [1.1, 1.2],
|
||||
},
|
||||
)
|
||||
mocker.patch(
|
||||
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
||||
return_value=frame,
|
||||
)
|
||||
|
||||
result = fetch_latest_closed_rates_indexed(
|
||||
MagicMock(),
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=2,
|
||||
)
|
||||
|
||||
assert isinstance(result.index, pd.DatetimeIndex)
|
||||
assert result.index.tz is not None
|
||||
assert str(result.index.tz) == "UTC"
|
||||
assert result.index[0].year == 2024
|
||||
|
||||
def test_converts_aware_datetime_to_utc(self, mocker: MockerFixture) -> None:
|
||||
"""Test timezone-aware datetime values are converted to UTC."""
|
||||
from datetime import datetime, timedelta, timezone # noqa: PLC0415
|
||||
|
||||
tz_plus5 = timezone(timedelta(hours=5))
|
||||
frame = pd.DataFrame(
|
||||
{
|
||||
"time": [datetime(2024, 1, 1, 5, 0, tzinfo=tz_plus5)],
|
||||
"close": [1.1],
|
||||
},
|
||||
)
|
||||
mocker.patch(
|
||||
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
||||
return_value=frame,
|
||||
)
|
||||
|
||||
result = fetch_latest_closed_rates_indexed(
|
||||
MagicMock(),
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=1,
|
||||
)
|
||||
|
||||
assert isinstance(result.index, pd.DatetimeIndex)
|
||||
assert str(result.index.tz) == "UTC"
|
||||
assert result.index[0].hour == 0
|
||||
|
||||
def test_raises_on_missing_time_column(self, mocker: MockerFixture) -> None:
|
||||
"""Test missing time column after the underlying fetch raises ValueError."""
|
||||
mocker.patch(
|
||||
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
||||
return_value=pd.DataFrame({"close": [1.1]}),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="missing a time column"):
|
||||
fetch_latest_closed_rates_indexed(
|
||||
MagicMock(),
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=1,
|
||||
)
|
||||
|
||||
def test_raises_on_unparseable_time_column(self, mocker: MockerFixture) -> None:
|
||||
"""Test unparseable time data raises a clear ValueError."""
|
||||
mocker.patch(
|
||||
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
||||
return_value=pd.DataFrame({"time": ["not-a-date"], "close": [1.1]}),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="invalid or unparseable time data"):
|
||||
fetch_latest_closed_rates_indexed(
|
||||
MagicMock(),
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=1,
|
||||
)
|
||||
|
||||
def test_raises_on_nat_time_column(self, mocker: MockerFixture) -> None:
|
||||
"""Test NaT in the time column raises ValueError instead of silently passing."""
|
||||
mocker.patch(
|
||||
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
||||
return_value=pd.DataFrame(
|
||||
{"time": [1700000000, None], "close": [1.1, 1.2]},
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match=r"missing.*NaT.*timestamp"):
|
||||
fetch_latest_closed_rates_indexed(
|
||||
MagicMock(),
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=2,
|
||||
)
|
||||
|
||||
def test_drops_time_column_and_sets_index(self, mocker: MockerFixture) -> None:
|
||||
"""Test the returned DataFrame has the DatetimeIndex and no time column."""
|
||||
frame = pd.DataFrame(
|
||||
{
|
||||
"time": [1700000000, 1700003600],
|
||||
"open": [1.0, 1.1],
|
||||
"close": [1.1, 1.2],
|
||||
},
|
||||
)
|
||||
mocker.patch(
|
||||
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
||||
return_value=frame,
|
||||
)
|
||||
|
||||
result = fetch_latest_closed_rates_indexed(
|
||||
MagicMock(),
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=2,
|
||||
)
|
||||
|
||||
assert "time" not in result.columns
|
||||
assert "open" in result.columns
|
||||
assert "close" in result.columns
|
||||
assert isinstance(result.index, pd.DatetimeIndex)
|
||||
|
||||
Reference in New Issue
Block a user