From d6ed53a02aeba66e144579baeb4118dbbef2c3da Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:42:31 +0800 Subject: [PATCH] fix: bind declared runtime account before portfolio execution Co-Authored-By: Codex --- application/rebalance_service.py | 57 +++++++- tests/test_rebalance_service.py | 218 ++++++++++++++++++++++++++++++- 2 files changed, 270 insertions(+), 5 deletions(-) diff --git a/application/rebalance_service.py b/application/rebalance_service.py index d5e27fb..2c1eb08 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -23,6 +23,7 @@ from application.firstrade_client import ( FirstradeBrokerClient, FirstradeCredentials, + FirstradePlatformError, mask_account_id, ) from application.runtime_broker_adapters import build_runtime_broker_adapters @@ -226,6 +227,46 @@ def _connect_client( ).connect() +def _runtime_target_account_selector(runtime_target: object) -> str | None: + if runtime_target is None: + return None + selectors = getattr(runtime_target, "account_selector", None) + if not isinstance(selectors, (tuple, list)) or len(selectors) != 1: + raise FirstradePlatformError( + "Runtime target must declare exactly one Firstrade account selector." + ) + selector = selectors[0] + if not isinstance(selector, str) or not selector.strip(): + raise FirstradePlatformError( + "Runtime target must declare exactly one Firstrade account selector." + ) + return selector.strip() + + +def _select_runtime_target_account( + client: FirstradeBrokerClient, expected_selector: str +) -> str: + account_numbers = getattr(client, "account_numbers", None) + if not callable(account_numbers): + raise FirstradePlatformError( + "Configured Firstrade account selector did not match the connected account list." + ) + accounts = account_numbers() + if ( + not isinstance(accounts, (tuple, list)) + or sum(account == expected_selector for account in accounts) != 1 + ): + raise FirstradePlatformError( + "Configured Firstrade account selector did not match the connected account list." + ) + selected = client.select_account(expected_selector) + if selected != expected_selector: + raise FirstradePlatformError( + "Configured Firstrade account selector did not match the connected account list." + ) + return expected_selector + + def _publish_cycle_notification( result: Mapping[str, Any], *, @@ -447,6 +488,16 @@ def log_message(message: str) -> None: strategy_plugin_error=strategy_plugin_error, translator=translator, ) + expected_account = _runtime_target_account_selector(settings.runtime_target) + configured_account = ( + str(env_reader("FIRSTRADE_ACCOUNT", "") or "").strip() + if expected_account is not None + else None + ) + if configured_account and configured_account != expected_account: + raise FirstradePlatformError( + "FIRSTRADE_ACCOUNT conflicts with the runtime target account selector." + ) resolved_credentials = credentials or FirstradeCredentials.from_env(env_reader) store = state_store or build_gcs_state_store_from_env(env_reader) persist_strategy_runs = bool(settings.persist_strategy_runs and store is not None) @@ -456,7 +507,11 @@ def log_message(message: str) -> None: client_factory=client_factory, ) print(f"Firstrade session reused={bool(getattr(client, 'session_reused', False))}", flush=True) - account = client.select_account(env_reader("FIRSTRADE_ACCOUNT", "") or None) + account = ( + _select_runtime_target_account(client, expected_account) + if expected_account is not None + else client.select_account(env_reader("FIRSTRADE_ACCOUNT", "") or None) + ) strategy_runtime = load_strategy_runtime( settings.strategy_profile, runtime_settings=settings, diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index 92e9524..1f33970 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -6,7 +6,7 @@ import pytest -from application.firstrade_client import FirstradeCredentials +from application.firstrade_client import FirstradeCredentials, FirstradePlatformError from application.rebalance_service import ( _publish_cycle_notification, _runtime_metadata_with_execution_policy, @@ -89,17 +89,34 @@ class FakeFirstradeClient: def __init__(self, _credentials, *, live_trading_enabled=False): self.live_trading_enabled = live_trading_enabled self.orders = [] + self.accounts = ["12345678"] + self.selected_account = None + self.connect_calls = 0 + self.account_numbers_calls = 0 + self.select_account_calls = [] + self.balance_calls = [] + self.position_calls = [] def connect(self): + self.connect_calls += 1 return self + def account_numbers(self): + self.account_numbers_calls += 1 + return list(self.accounts) + def select_account(self, requested_account=None): - return requested_account or "12345678" + self.select_account_calls.append(requested_account) + if self.selected_account is not None: + return self.selected_account + return requested_account or (self.accounts[0] if len(self.accounts) == 1 else "") - def get_balances(self, _account): + def get_balances(self, account): + self.balance_calls.append(account) return {"total_value": "1000.00", "cash": "1000.00", "buying_power": "1000.00"} - def get_positions(self, _account): + def get_positions(self, account): + self.position_calls.append(account) return {"items": []} def get_quote(self, _account, symbol): @@ -388,6 +405,7 @@ def fake_client_factory(*args, **kwargs): assert request.limit_price == 10.05 assert dry_run is True assert explicit_live_ack is False + assert observed["client"].select_account_calls == [None] assert result["notification_sent"] is True assert "🔔 【Rebalance Instruction】" in messages[0] assert "🧭 Strategy: TQQQ Growth Income" in messages[0] @@ -398,6 +416,198 @@ def fake_client_factory(*args, **kwargs): assert "🧪 Dry-run limit buy AAA: 2 shares @ $10.05" in messages[0] +@pytest.mark.parametrize( + "selectors", + [None, (), ("",), ("SYNTHETIC_ACCOUNT_A", "SYNTHETIC_ACCOUNT_B")], +) +def test_runtime_target_requires_exactly_one_account_selector_before_connect( + selectors, monkeypatch +): + factory_calls = [] + monkeypatch.setattr( + "application.rebalance_service.load_strategy_runtime", + lambda *_args, **_kwargs: pytest.fail("strategy must not load"), + ) + + def client_factory(*args, **kwargs): + factory_calls.append((args, kwargs)) + return FakeFirstradeClient(*args, **kwargs) + + with pytest.raises( + FirstradePlatformError, + match="Runtime target must declare exactly one Firstrade account selector", + ) as caught: + run_strategy_cycle( + runtime_settings=_runtime_settings_with_persistence( + runtime_target=SimpleNamespace(account_selector=selectors) + ), + credentials=FirstradeCredentials(username="user", password="pass"), + client_factory=client_factory, + env_reader=lambda _name, default=None: default, + send_cycle_notification=False, + ) + + assert factory_calls == [] + assert "SYNTHETIC_ACCOUNT" not in str(caught.value) + + +def test_runtime_target_rejects_conflicting_legacy_account_before_connect(): + factory_calls = [] + + def client_factory(*args, **kwargs): + factory_calls.append((args, kwargs)) + return FakeFirstradeClient(*args, **kwargs) + + def env_reader(name, default=None): + if name == "FIRSTRADE_ACCOUNT": + return "SYNTHETIC_OTHER_ACCOUNT" + return default + + with pytest.raises(FirstradePlatformError, match="FIRSTRADE_ACCOUNT conflicts with the runtime target account selector") as caught: + run_strategy_cycle( + runtime_settings=_runtime_settings_with_persistence( + runtime_target=SimpleNamespace(account_selector=("SYNTHETIC_ACCOUNT",)) + ), + credentials=FirstradeCredentials(username="user", password="pass"), + client_factory=client_factory, + env_reader=env_reader, + send_cycle_notification=False, + ) + + assert factory_calls == [] + assert "SYNTHETIC" not in str(caught.value) + + +@pytest.mark.parametrize( + ("accounts", "selected"), + [ + (["SYNTHETIC_OTHER_ACCOUNT"], None), + (["SYNTHETIC_ACCOUNT", "SYNTHETIC_ACCOUNT"], None), + (["SYNTHETIC_ACCOUNT"], "SYNTHETIC_OTHER_ACCOUNT"), + ], +) +def test_runtime_target_native_account_mismatch_stops_before_strategy_and_snapshot( + accounts, selected, monkeypatch +): + observed = {} + strategy_calls = [] + monkeypatch.setattr( + "application.rebalance_service.load_strategy_runtime", + lambda *_args, **_kwargs: strategy_calls.append(True), + ) + + def client_factory(*args, **kwargs): + client = FakeFirstradeClient(*args, **kwargs) + client.accounts = accounts + client.selected_account = selected + observed["client"] = client + return client + + with pytest.raises(FirstradePlatformError, match="Configured Firstrade account selector did not match the connected account list") as caught: + run_strategy_cycle( + runtime_settings=_runtime_settings_with_persistence( + runtime_target=SimpleNamespace(account_selector=("SYNTHETIC_ACCOUNT",)) + ), + credentials=FirstradeCredentials(username="user", password="pass"), + client_factory=client_factory, + env_reader=lambda _name, default=None: default, + send_cycle_notification=False, + ) + + client = observed["client"] + assert client.connect_calls == 1 + assert client.account_numbers_calls == 1 + assert client.select_account_calls == ( + ["SYNTHETIC_ACCOUNT"] + if accounts.count("SYNTHETIC_ACCOUNT") == 1 + else [] + ) + assert client.balance_calls == [] + assert client.position_calls == [] + assert client.orders == [] + assert strategy_calls == [] + assert "SYNTHETIC" not in str(caught.value) + + +def test_runtime_target_selects_exact_account_before_strategy_and_snapshot(monkeypatch): + observed = {} + monkeypatch.setattr( + "application.rebalance_service.load_strategy_runtime", + lambda *_args, **_kwargs: FakeStrategyRuntime(), + ) + + def client_factory(*args, **kwargs): + client = FakeFirstradeClient(*args, **kwargs) + client.accounts = ["SYNTHETIC_ACCOUNT"] + observed["client"] = client + return client + + result = run_strategy_cycle( + runtime_settings=_runtime_settings_with_persistence( + runtime_target=SimpleNamespace(account_selector=("SYNTHETIC_ACCOUNT",)) + ), + credentials=FirstradeCredentials(username="user", password="pass"), + client_factory=client_factory, + env_reader=lambda _name, default=None: default, + send_cycle_notification=False, + ) + + client = observed["client"] + assert result["ok"] is True + assert result["account"] == "SYNTHETIC_ACCOUNT" + assert client.select_account_calls == ["SYNTHETIC_ACCOUNT"] + assert client.balance_calls == ["SYNTHETIC_ACCOUNT"] + assert client.position_calls == ["SYNTHETIC_ACCOUNT"] + assert len(client.orders) == 1 + + +@pytest.mark.parametrize( + ("selectors", "configured_account"), + [ + (None, None), + (("SYNTHETIC_ACCOUNT_A", "SYNTHETIC_ACCOUNT_B"), None), + (("SYNTHETIC_ACCOUNT",), "SYNTHETIC_OTHER_ACCOUNT"), + ], +) +def test_unsupported_strategy_returns_before_runtime_account_validation( + selectors, configured_account, monkeypatch +): + client_factory_calls = [] + monkeypatch.setattr( + "application.rebalance_service.dca_execution_unsupported_reason", + lambda _profile: "Synthetic unsupported strategy", + ) + monkeypatch.setattr( + "application.rebalance_service.load_strategy_runtime", + lambda *_args, **_kwargs: pytest.fail("strategy must not load"), + ) + + def client_factory(*args, **kwargs): + client_factory_calls.append((args, kwargs)) + return FakeFirstradeClient(*args, **kwargs) + + def env_reader(name, default=None): + if name == "FIRSTRADE_ACCOUNT": + return configured_account or default + return default + + result = run_strategy_cycle( + runtime_settings=_runtime_settings_with_persistence( + runtime_target=SimpleNamespace(account_selector=selectors) + ), + credentials=FirstradeCredentials(username="user", password="pass"), + client_factory=client_factory, + env_reader=env_reader, + send_cycle_notification=False, + ) + + assert result["ok"] is True + assert result["status"] == "skipped" + assert result["strategy_run_stage"] == "NO_ACTION" + assert result["submitted_orders"] == [] + assert client_factory_calls == [] + + def test_run_strategy_cycle_persists_broker_rejection_as_retryable_block(monkeypatch): class IbitRuntime(FakeStrategyRuntime): profile = "ibit_smart_dca"