Add closed-bar rate helpers (v0.6.0) (#26)
* Add closed-bar rate helpers and bump version to 0.6.0. Expose drop_forming_rate_bar and multi-account collectors so downstream apps no longer need count+1 fetches and manual bar trimming. Co-authored-by: Cursor <cursoragent@cursor.com> * Bump pygments to 2.20.0 to fix CVE-2026-4539 ReDoS advisory. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback on closed-bar rate collection. Validate count and start_pos before MT5 fetches, avoid redundant frame copies, clarify empty-series errors, and expand test coverage. Co-authored-by: Cursor <cursoragent@cursor.com> * Include symbol and timeframe in empty closed-rate error messages. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -30,6 +30,7 @@ from mt5cli.history import (
|
||||
create_rate_compatibility_views,
|
||||
deduplicate_history_tables,
|
||||
drop_duplicates_in_table,
|
||||
drop_forming_rate_bar,
|
||||
filter_incremental_history_deals_frame,
|
||||
filter_trade_history_frame,
|
||||
get_history_deals_account_event_start_datetime,
|
||||
@@ -534,6 +535,46 @@ class TestResolveHistorySettings:
|
||||
assert resolve_granularity_name(1) == "M1"
|
||||
|
||||
|
||||
class TestDropFormingRateBar:
|
||||
"""Tests for drop_forming_rate_bar."""
|
||||
|
||||
def test_drops_still_forming_last_bar(self) -> None:
|
||||
"""Test the still-forming last bar is removed."""
|
||||
df_rate = pd.DataFrame(
|
||||
{"time": [1, 2, 3], "close": [1.1, 1.2, 1.3]},
|
||||
index=pd.Index(["a", "b", "c"], name="idx"),
|
||||
)
|
||||
|
||||
result = drop_forming_rate_bar(df_rate)
|
||||
|
||||
pd.testing.assert_frame_equal(
|
||||
result,
|
||||
pd.DataFrame(
|
||||
{"time": [1, 2], "close": [1.1, 1.2]},
|
||||
index=pd.Index(["a", "b"], name="idx"),
|
||||
),
|
||||
)
|
||||
assert df_rate.shape == (3, 2)
|
||||
|
||||
def test_returns_empty_frame_when_input_empty(self) -> None:
|
||||
"""Test empty frames stay empty."""
|
||||
df_rate = pd.DataFrame(columns=["time", "close"])
|
||||
|
||||
result = drop_forming_rate_bar(df_rate)
|
||||
|
||||
assert result.empty
|
||||
assert list(result.columns) == ["time", "close"]
|
||||
|
||||
def test_returns_empty_frame_when_only_forming_bar_present(self) -> None:
|
||||
"""Test a single-bar frame becomes empty after dropping the forming bar."""
|
||||
df_rate = pd.DataFrame({"time": [1], "close": [1.1]})
|
||||
|
||||
result = drop_forming_rate_bar(df_rate)
|
||||
|
||||
assert result.empty
|
||||
assert list(result.columns) == ["time", "close"]
|
||||
|
||||
|
||||
class TestParseSqliteTimestamp:
|
||||
"""Tests for parse_sqlite_timestamp."""
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ from mt5cli.sdk import (
|
||||
account_info,
|
||||
build_config,
|
||||
collect_history,
|
||||
collect_latest_closed_rates_by_granularity,
|
||||
collect_latest_closed_rates_for_accounts,
|
||||
collect_latest_rates,
|
||||
collect_latest_rates_for_accounts,
|
||||
collect_latest_rates_for_accounts_with_retries,
|
||||
@@ -1523,6 +1525,199 @@ class TestCollectLatestRatesForAccountsWithRetries:
|
||||
sleep.assert_not_called()
|
||||
|
||||
|
||||
class TestCollectLatestClosedRatesForAccounts:
|
||||
"""Tests for collect_latest_closed_rates_for_accounts."""
|
||||
|
||||
def test_fetches_count_plus_one_and_drops_forming_bar(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test closed-bar collection requests one extra bar at start_pos=0."""
|
||||
df_rate = pd.DataFrame({"time": [1, 2, 3], "close": [1.1, 1.2, 1.3]})
|
||||
wrapped = mocker.patch(
|
||||
"mt5cli.sdk.collect_latest_rates_for_accounts_with_retries",
|
||||
return_value={("EURUSD", 1): df_rate},
|
||||
)
|
||||
accounts = [AccountSpec(symbols=["EURUSD"])]
|
||||
|
||||
result = collect_latest_closed_rates_for_accounts(
|
||||
accounts,
|
||||
["M1"],
|
||||
count=2,
|
||||
retry_count=1,
|
||||
backoff_base=3,
|
||||
)
|
||||
|
||||
wrapped.assert_called_once_with(
|
||||
accounts,
|
||||
["M1"],
|
||||
3,
|
||||
start_pos=0,
|
||||
base_config=None,
|
||||
retry_count=1,
|
||||
backoff_base=3,
|
||||
)
|
||||
pd.testing.assert_frame_equal(
|
||||
result["EURUSD", 1],
|
||||
pd.DataFrame({"time": [1, 2], "close": [1.1, 1.2]}),
|
||||
)
|
||||
|
||||
def test_rejects_forming_bar_only_frames(self, mocker: MockerFixture) -> None:
|
||||
"""Test empty results after dropping the forming bar raise ValueError."""
|
||||
mocker.patch(
|
||||
"mt5cli.sdk.collect_latest_rates_for_accounts_with_retries",
|
||||
return_value={("EURUSD", 1): pd.DataFrame({"time": [1], "close": [1.1]})},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Rate data is empty"):
|
||||
collect_latest_closed_rates_for_accounts(
|
||||
[AccountSpec(symbols=["EURUSD"])],
|
||||
["M1"],
|
||||
count=1,
|
||||
)
|
||||
|
||||
def test_skips_extra_fetch_when_start_pos_nonzero(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test start_pos > 0 fetches count bars without dropping the last row."""
|
||||
df_rate = pd.DataFrame({"time": [1, 2], "close": [1.1, 1.2]})
|
||||
wrapped = mocker.patch(
|
||||
"mt5cli.sdk.collect_latest_rates_for_accounts_with_retries",
|
||||
return_value={("EURUSD", 1): df_rate},
|
||||
)
|
||||
|
||||
result = collect_latest_closed_rates_for_accounts(
|
||||
[AccountSpec(symbols=["EURUSD"])],
|
||||
["M1"],
|
||||
count=2,
|
||||
start_pos=1,
|
||||
)
|
||||
|
||||
wrapped.assert_called_once_with(
|
||||
[AccountSpec(symbols=["EURUSD"])],
|
||||
["M1"],
|
||||
2,
|
||||
start_pos=1,
|
||||
base_config=None,
|
||||
retry_count=0,
|
||||
backoff_base=2.0,
|
||||
)
|
||||
pd.testing.assert_frame_equal(result["EURUSD", 1], df_rate)
|
||||
|
||||
def test_rejects_zero_count_before_fetching(self, mocker: MockerFixture) -> None:
|
||||
"""Test count=0 is rejected before any MT5 collection attempt."""
|
||||
wrapped = mocker.patch(
|
||||
"mt5cli.sdk.collect_latest_rates_for_accounts_with_retries",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="count must be positive"):
|
||||
collect_latest_closed_rates_for_accounts(
|
||||
[AccountSpec(symbols=["EURUSD"])],
|
||||
["M1"],
|
||||
count=0,
|
||||
)
|
||||
|
||||
wrapped.assert_not_called()
|
||||
|
||||
def test_rejects_negative_start_pos(self, mocker: MockerFixture) -> None:
|
||||
"""Test negative start_pos is rejected before any MT5 collection attempt."""
|
||||
wrapped = mocker.patch(
|
||||
"mt5cli.sdk.collect_latest_rates_for_accounts_with_retries",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="start_pos must be non-negative"):
|
||||
collect_latest_closed_rates_for_accounts(
|
||||
[AccountSpec(symbols=["EURUSD"])],
|
||||
["M1"],
|
||||
count=1,
|
||||
start_pos=-1,
|
||||
)
|
||||
|
||||
wrapped.assert_not_called()
|
||||
|
||||
def test_rejects_empty_frames_with_start_pos_nonzero(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test empty upstream frames raise ValueError when start_pos > 0."""
|
||||
mocker.patch(
|
||||
"mt5cli.sdk.collect_latest_rates_for_accounts_with_retries",
|
||||
return_value={("EURUSD", 1): pd.DataFrame(columns=["time", "close"])},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Rate data is empty"):
|
||||
collect_latest_closed_rates_for_accounts(
|
||||
[AccountSpec(symbols=["EURUSD"])],
|
||||
["M1"],
|
||||
count=1,
|
||||
start_pos=1,
|
||||
)
|
||||
|
||||
def test_processes_multiple_symbol_timeframe_pairs(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test each returned series is trimmed and validated independently."""
|
||||
mocker.patch(
|
||||
"mt5cli.sdk.collect_latest_rates_for_accounts_with_retries",
|
||||
return_value={
|
||||
("EURUSD", 1): pd.DataFrame(
|
||||
{"time": [1, 2, 3], "close": [1.1, 1.2, 1.3]},
|
||||
),
|
||||
("GBPUSD", 16385): pd.DataFrame(
|
||||
{"time": [4, 5, 6], "close": [2.1, 2.2, 2.3]},
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
result = collect_latest_closed_rates_for_accounts(
|
||||
[AccountSpec(symbols=["EURUSD", "GBPUSD"])],
|
||||
["M1", "H1"],
|
||||
count=2,
|
||||
)
|
||||
|
||||
assert set(result) == {("EURUSD", 1), ("GBPUSD", 16385)}
|
||||
pd.testing.assert_frame_equal(
|
||||
result["EURUSD", 1],
|
||||
pd.DataFrame({"time": [1, 2], "close": [1.1, 1.2]}),
|
||||
)
|
||||
pd.testing.assert_frame_equal(
|
||||
result["GBPUSD", 16385],
|
||||
pd.DataFrame({"time": [4, 5], "close": [2.1, 2.2]}),
|
||||
)
|
||||
|
||||
|
||||
class TestCollectLatestClosedRatesByGranularity:
|
||||
"""Tests for collect_latest_closed_rates_by_granularity."""
|
||||
|
||||
def test_rekeys_by_granularity_name(self, mocker: MockerFixture) -> None:
|
||||
"""Test closed rates are keyed by symbol and granularity name."""
|
||||
df_rate = pd.DataFrame({"time": [1, 2], "close": [1.1, 1.2]})
|
||||
wrapped = mocker.patch(
|
||||
"mt5cli.sdk.collect_latest_closed_rates_for_accounts",
|
||||
return_value={("EURUSD", 1): df_rate},
|
||||
)
|
||||
|
||||
result = collect_latest_closed_rates_by_granularity(
|
||||
[AccountSpec(symbols=["EURUSD"])],
|
||||
["M1"],
|
||||
count=2,
|
||||
)
|
||||
|
||||
wrapped.assert_called_once_with(
|
||||
[AccountSpec(symbols=["EURUSD"])],
|
||||
["M1"],
|
||||
2,
|
||||
start_pos=0,
|
||||
base_config=None,
|
||||
retry_count=0,
|
||||
backoff_base=2.0,
|
||||
)
|
||||
assert ("EURUSD", "M1") in result
|
||||
pd.testing.assert_frame_equal(result["EURUSD", "M1"], df_rate)
|
||||
|
||||
|
||||
class TestSubstituteEnvPlaceholders:
|
||||
"""Tests for ${ENV_VAR} substitution."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user