From 4076aa9ab4668153a24d22c4077fdc2b0155d85b Mon Sep 17 00:00:00 2001 From: Pigbibi Date: Fri, 9 Oct 2026 16:03:06 +0800 Subject: [PATCH] Fall back to sole Firstrade account for account-facts-sync. RUNTIME_TARGET account_selector is the platform label "firstrade", so collect failed with account_identity_mismatch despite a healthy session. When the selector is absent from the broker account list and exactly one account is returned, bind that sole account for read-only facts publish only. Log accounts_len on remaining collect failures. --- main.py | 38 ++++++++++++++++++++++-- tests/test_account_facts_endpoint.py | 43 ++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index 5ead27a..4d30288 100644 --- a/main.py +++ b/main.py @@ -928,9 +928,36 @@ def account_facts_sync(): client = None try: client = READ_ONLY_ACCOUNT_FACTS_CLIENT_BUILDER() + expected_account = config.account_id + try: + broker_accounts = client.account_numbers() + except Exception: + broker_accounts = None + if ( + isinstance(expected_account, str) + and isinstance(broker_accounts, list) + and expected_account not in broker_accounts + and len(broker_accounts) == 1 + and isinstance(broker_accounts[0], str) + and broker_accounts[0] + ): + # RUNTIME_TARGET account_selector may be a deployment label (e.g. platform id) + # rather than the Firstrade broker account number. For single-account brokers, + # bind the sole returned account for read-only facts publish only. + sole = broker_accounts[0] + app.logger.warning( + "account_facts_sync sole_account_fallback selector=%s sole=%s", + mask_account_id(expected_account), + mask_account_id(sole), + ) + config = bind_account_facts_runtime_selector( + replace(config, account_id=None), + sole, + ) + expected_account = config.account_id observation = collect_firstrade_account_facts( client, - expected_account=config.account_id, + expected_account=expected_account, include_positions=False, ) payload = build_firstrade_account_snapshot( @@ -939,10 +966,17 @@ def account_facts_sync(): account_scope=str(runtime_target.account_scope), ) except AccountFactsUnavailable as exc: + accounts_len = -1 + try: + if client is not None: + accounts_len = len(client.account_numbers()) + except Exception: + accounts_len = -1 app.logger.warning( - "account_facts_sync collect failed reason=%s expected=%s", + "account_facts_sync collect failed reason=%s expected=%s accounts_len=%s", exc.reason_code, mask_account_id(config.account_id or ""), + accounts_len, ) return jsonify({"ok": False, "error": exc.reason_code}), 503 except AccountFactsPublishError as exc: diff --git a/tests/test_account_facts_endpoint.py b/tests/test_account_facts_endpoint.py index 3471b82..06aba49 100644 --- a/tests/test_account_facts_endpoint.py +++ b/tests/test_account_facts_endpoint.py @@ -257,3 +257,46 @@ def test_collection_failure_is_safe_and_client_still_closes(monkeypatch): assert response.get_json() == {"ok": False, "error": "balances_unavailable"} assert client.closed is True assert published == [] + + +def test_sync_endpoint_falls_back_to_sole_broker_account_when_selector_unmatched(monkeypatch): + _configure(monkeypatch, FIRSTRADE_ACCOUNT="firstrade") + monkeypatch.setattr(main, "_runtime_settings", lambda: SimpleNamespace(runtime_target=SimpleNamespace( + platform_id="firstrade", + account_selector=("firstrade",), + account_scope="us", + ))) + + class SoleAccountClient(FakeClient): + def account_numbers(self): + return ["synthetic-native-id"] + + client = SoleAccountClient() + monkeypatch.setattr(main, "READ_ONLY_ACCOUNT_FACTS_CLIENT_BUILDER", lambda: client) + collector_calls = [] + + def collect(_client, *, expected_account, include_positions): + collector_calls.append({"expected_account": expected_account, "include_positions": include_positions}) + return { + "status": "available", + "platform": "firstrade", + "account_selector_status": "matched", + "broker_account_id": expected_account, + "observed_started_at": "2026-10-08T08:00:00+00:00", + "observed_finished_at": "2026-10-08T08:00:03+00:00", + "balances": {"currency": "USD", "provider_equity": "100.00", "cash_balance": "50.00"}, + } + + monkeypatch.setattr(main, "collect_firstrade_account_facts", collect) + published = [] + monkeypatch.setattr(main, "publish_firstrade_account_snapshot", lambda payload, cfg: published.append((payload, cfg))) + + response = main.app.test_client().post( + "/account-facts-sync", + headers={"Authorization": "Bearer synthetic-facts-token"}, + ) + assert response.status_code == 200 + assert collector_calls == [{"expected_account": "synthetic-native-id", "include_positions": False}] + assert published[0][0]["broker_account_id"] == "synthetic-native-id" + assert published[0][1].account_id == "synthetic-native-id" + assert client.closed is True