diff --git a/application/account_facts.py b/application/account_facts.py index 5f138333..163e6e58 100644 --- a/application/account_facts.py +++ b/application/account_facts.py @@ -27,8 +27,21 @@ UNCOVERED_SCOPES = ["funding", "margin", "futures", "locked_earn"] PAGE_SIZE = 100 MAX_EARN_POSITIONS = 10_000 +MAX_WALLET_ROWS = 32 +WALLET_VALUATION_CURRENCY = "USDT" +WALLET_VALUATION_SOURCE = "GET /sapi/v1/asset/wallet/balance" +WALLET_VALUATION_SCOPE = "provider_returned_wallet_rows" +WALLET_VALUATION_FAILURES = frozenset({ + "wallet_read_failed", + "wallet_response_invalid", + "wallet_row_invalid", + "wallet_duplicate_name", + "wallet_inactive_nonzero", + "wallet_balance_invalid", +}) _HEX_SHA256 = re.compile(r"[0-9a-f]{64}\Z") _GIT_SHA = re.compile(r"[0-9a-f]{40}\Z") +_WALLET_DECIMAL = re.compile(r"(?:0|[1-9]\d{0,29})(?:\.\d{1,30})?\Z") _HANGUL_FILLERS = frozenset("\u115f\u1160\u3164\uffa0") @@ -66,6 +79,17 @@ def get_simple_earn_flexible_product_position( current=current, size=size ) + def get_wallet_valuation_usdt(self) -> Any: + """Read quote-valued wallet rows using the locked SDK's signed SAPI path.""" + return self.__client._request_margin_api( + "get", + "asset/wallet/balance", + signed=True, + version=1, + data={"quoteAsset": WALLET_VALUATION_CURRENCY, "needBalanceDetail": "false"}, + allow_redirects=False, + ) + def _fail(code: str) -> AccountFactsUnavailable: return AccountFactsUnavailable(f"binance_account_facts_{code}") @@ -124,6 +148,91 @@ def _decimal_text(value: Decimal) -> str: raise _fail("amount_invalid") from None +def _wallet_valuation_unavailable(reason_code: str) -> dict[str, Any]: + if reason_code not in WALLET_VALUATION_FAILURES: + reason_code = "wallet_response_invalid" + return { + "status": "unavailable", + "amount": None, + "currency": None, + "source": WALLET_VALUATION_SOURCE, + "scope": WALLET_VALUATION_SCOPE, + "observed_at": None, + "wallet_count": None, + "reason_code": reason_code, + } + + +def _wallet_valuation(response: Any, observed_at: datetime) -> dict[str, Any]: + if not isinstance(response, list) or not 1 <= len(response) <= MAX_WALLET_ROWS: + return _wallet_valuation_unavailable("wallet_response_invalid") + names: set[str] = set() + total = Decimal(0) + try: + with localcontext() as context: + context.prec = 100 + for row in response: + if not isinstance(row, Mapping) or set(row) != {"activate", "balance", "walletName"}: + return _wallet_valuation_unavailable("wallet_row_invalid") + name, active, balance_text = row["walletName"], row["activate"], row["balance"] + if not isinstance(name, str) or not name.strip() or len(name) > 128 or type(active) is not bool: + return _wallet_valuation_unavailable("wallet_row_invalid") + if name in names: + return _wallet_valuation_unavailable("wallet_duplicate_name") + names.add(name) + if not isinstance(balance_text, str) or not _WALLET_DECIMAL.fullmatch(balance_text): + return _wallet_valuation_unavailable("wallet_balance_invalid") + amount = _decimal(balance_text) + if amount and not active: + return _wallet_valuation_unavailable("wallet_inactive_nonzero") + total += amount + except AccountFactsUnavailable: + return _wallet_valuation_unavailable("wallet_balance_invalid") + except Exception: + return _wallet_valuation_unavailable("wallet_response_invalid") + try: + amount_text = _decimal_text(total) + observed_text = _utc_timestamp(observed_at) + except AccountFactsUnavailable: + return _wallet_valuation_unavailable("wallet_response_invalid") + return { + "status": "available", + "amount": amount_text, + "currency": WALLET_VALUATION_CURRENCY, + "source": WALLET_VALUATION_SOURCE, + "scope": WALLET_VALUATION_SCOPE, + "observed_at": observed_text, + "wallet_count": len(response), + } + + +def _validate_wallet_valuation(summary: Any) -> None: + common = {"status", "amount", "currency", "source", "scope", "observed_at", "wallet_count"} + if not isinstance(summary, Mapping): + raise _fail("payload_invalid") + if summary.get("source") != WALLET_VALUATION_SOURCE or summary.get("scope") != WALLET_VALUATION_SCOPE: + raise _fail("payload_invalid") + if summary.get("status") == "available": + if set(summary) != common or summary.get("currency") != WALLET_VALUATION_CURRENCY: + raise _fail("payload_invalid") + amount, count = summary.get("amount"), summary.get("wallet_count") + if not isinstance(amount, str) or type(count) is not int or not 1 <= count <= MAX_WALLET_ROWS: + raise _fail("payload_invalid") + if not _WALLET_DECIMAL.fullmatch(amount): + raise _fail("payload_invalid") + parsed = _decimal(amount) + if _decimal_text(parsed) != amount or not isinstance(summary.get("observed_at"), str): + raise _fail("payload_invalid") + _parsed_timestamp(summary["observed_at"]) + elif summary.get("status") == "unavailable": + if set(summary) != common | {"reason_code"} or any( + summary.get(key) is not None for key in ("amount", "currency", "observed_at", "wallet_count") + ) or summary.get("reason_code") not in WALLET_VALUATION_FAILURES: + raise _fail("payload_invalid") + else: + raise _fail("payload_invalid") + + def build_source_binding_id( *, account_scope_sha256: str, reader_public_revision: str, approved_application_revision: str, @@ -161,7 +270,7 @@ def collect_account_facts( observed_started_at: datetime, clock, ) -> dict[str, Any]: - """Collect complete Spot plus Flexible Earn quantities, without valuation.""" + """Collect Spot + Flexible Earn quantities and independent wallet valuation.""" if not isinstance(client, ReadOnlyBinanceClient): raise _fail("readonly_client_required") if not isinstance(target_id, str) or not target_id.strip(): @@ -272,11 +381,27 @@ def collect_account_facts( raise _fail("flexible_earn_page_incomplete") earn_finished_at = clock() earn_finished = _utc_timestamp(earn_finished_at) + try: + wallet_response = client.get_wallet_valuation_usdt() + except Exception: + wallet_response = None + wallet_valuation = _wallet_valuation_unavailable("wallet_read_failed") + wallet_observed_at = None + else: + wallet_observed_at = clock() + wallet_valuation = _wallet_valuation(wallet_response, wallet_observed_at) finished_at = clock() finished = _utc_timestamp(finished_at) if ( earn_finished_at.astimezone(timezone.utc) < spot_finished_at.astimezone(timezone.utc) or finished_at.astimezone(timezone.utc) < earn_finished_at.astimezone(timezone.utc) + or ( + wallet_observed_at is not None + and ( + wallet_observed_at.astimezone(timezone.utc) < earn_finished_at.astimezone(timezone.utc) + or finished_at.astimezone(timezone.utc) < wallet_observed_at.astimezone(timezone.utc) + ) + ) ): raise _fail("observation_time_invalid") @@ -325,6 +450,7 @@ def collect_account_facts( "scope": SCOPE, "completeness": "complete_for_scope", "assets": asset_rows, + "wallet_valuation": wallet_valuation, "uncovered_scopes": list(UNCOVERED_SCOPES), "no_order": True, "execution_authority_granted": False, @@ -340,7 +466,11 @@ def validate_account_facts_payload(payload: Any) -> dict[str, Any]: "earn_observed_at", "snapshot_atomic", "scope", "completeness", "assets", "uncovered_scopes", "no_order", "execution_authority_granted", } - if not isinstance(payload, Mapping) or set(payload) != required: + if ( + not isinstance(payload, Mapping) + or not required.issubset(payload) + or set(payload) - required - {"wallet_valuation"} + ): raise _fail("payload_invalid") if ( payload.get("schema_version") != SCHEMA_VERSION @@ -409,4 +539,15 @@ def validate_account_facts_payload(payload: Any) -> dict[str, Any]: context.prec = 100 if quantity != spot_free + spot_locked + flexible_earn: raise _fail("payload_invalid") + if "wallet_valuation" in payload: + _validate_wallet_valuation(payload["wallet_valuation"]) + wallet_valuation = payload["wallet_valuation"] + if wallet_valuation["status"] == "available": + wallet_observed_at = _parsed_timestamp(wallet_valuation["observed_at"]) + if not ( + observation_times["earn_observed_at"] + <= wallet_observed_at + <= observation_times["observed_finished_at"] + ): + raise _fail("payload_invalid") return dict(payload) diff --git a/scripts/read_binance_account_facts.py b/scripts/read_binance_account_facts.py index 96f265ea..d3b03d88 100644 --- a/scripts/read_binance_account_facts.py +++ b/scripts/read_binance_account_facts.py @@ -886,7 +886,7 @@ def read_account_facts(*, report_path: Path, output_path: Path, env: Mapping[str verify_current_source_from_env(env) # Client construction is network-silent: python-binance ping is disabled. - # The capability wrapper exposes only the two signed wallet GET endpoints. + # The capability wrapper exposes only the required signed read-only GETs. try: from binance.client import Client raw_client = Client( diff --git a/tests/test_binance_account_facts.py b/tests/test_binance_account_facts.py index 2a81b5cf..c47a0741 100644 --- a/tests/test_binance_account_facts.py +++ b/tests/test_binance_account_facts.py @@ -2,6 +2,9 @@ import hashlib import json +import subprocess +import sys +import textwrap from datetime import datetime, timedelta, timezone from pathlib import Path from urllib.error import URLError @@ -45,6 +48,13 @@ def get_simple_earn_flexible_product_position(self, *, current, size): self.calls.append(("earn", current, size)) return self.earn_pages[current - 1] + def _request_margin_api(self, method, path, **kwargs): + self.calls.append(("wallet", method, path, kwargs)) + return [ + {"activate": True, "balance": "12.25", "walletName": "SPOT"}, + {"activate": True, "balance": "2.75", "walletName": "FUNDING"}, + ] + def _uid_hash() -> str: from quant_platform_kit.common.broker_reconciliation import calculate_broker_observation_sha256 @@ -54,7 +64,7 @@ def _uid_hash() -> str: def _collect(client=None, times=None): raw = client or FakeBinance() - clock_values = iter(times or [START + timedelta(seconds=i) for i in (1, 2, 3)]) + clock_values = iter(times or [START + timedelta(seconds=i) for i in (1, 2, 3, 4)]) return collect_account_facts( ReadOnlyBinanceClient(raw), expected_account_scope_sha256=_uid_hash(), @@ -83,9 +93,173 @@ def test_collects_spot_and_all_flexible_earn_as_native_quantities(): validate_account_facts_payload(payload) +def test_wallet_valuation_is_separate_usdt_provider_total(): + fake = FakeBinance() + payload = _collect(fake) + assert payload["assets"][0]["quantity"] == "0.85" + assert payload["wallet_valuation"] == { + "status": "available", + "amount": "15", + "currency": "USDT", + "source": "GET /sapi/v1/asset/wallet/balance", + "scope": "provider_returned_wallet_rows", + "observed_at": "2026-10-02T10:00:03Z", + "wallet_count": 2, + } + assert [call[0] for call in fake.calls] == ["spot", "earn", "wallet"] + validate_account_facts_payload(payload) + + +@pytest.mark.parametrize( + ("rows", "reason"), + [ + (None, "wallet_response_invalid"), + ([], "wallet_response_invalid"), + ([{"activate": True, "balance": "1", "walletName": "SPOT", "unexpected": 1}], "wallet_row_invalid"), + ([{"activate": True, "balance": "1", "walletName": "SPOT"}] * 2, "wallet_duplicate_name"), + ([{"activate": False, "balance": "1", "walletName": "SPOT"}], "wallet_inactive_nonzero"), + ([{"activate": True, "balance": "-1", "walletName": "SPOT"}], "wallet_balance_invalid"), + ([{"activate": True, "balance": "NaN", "walletName": "SPOT"}], "wallet_balance_invalid"), + ([{"activate": True, "balance": "1e2", "walletName": "SPOT"}], "wallet_balance_invalid"), + ([{"activate": 1, "balance": "1", "walletName": "SPOT"}], "wallet_row_invalid"), + ], +) +def test_invalid_wallet_valuation_stays_unavailable_without_losing_quantities(rows, reason): + class WalletResponseBinance(FakeBinance): + def _request_margin_api(self, method, path, **kwargs): + self.calls.append(("wallet", method, path, kwargs)) + return rows + + payload = _collect(WalletResponseBinance()) + assert payload["assets"] + assert payload["wallet_valuation"] == { + "status": "unavailable", + "amount": None, + "currency": None, + "source": "GET /sapi/v1/asset/wallet/balance", + "scope": "provider_returned_wallet_rows", + "observed_at": None, + "wallet_count": None, + "reason_code": reason, + } + validate_account_facts_payload(payload) + + +def test_wallet_read_failure_does_not_discard_native_quantities(): + class WalletUnavailable(FakeBinance): + def _request_margin_api(self, method, path, **kwargs): + raise RuntimeError("synthetic-private-error") + + payload = _collect(WalletUnavailable()) + assert payload["assets"] + assert payload["wallet_valuation"]["reason_code"] == "wallet_read_failed" + assert "synthetic-private-error" not in json.dumps(payload) + validate_account_facts_payload(payload) + + +def test_inactive_zero_wallet_is_valid_and_zero_total_is_not_missing(): + class ZeroWallet(FakeBinance): + def _request_margin_api(self, method, path, **kwargs): + return [{"activate": False, "balance": "0.00000000", "walletName": "SPOT"}] + + payload = _collect(ZeroWallet()) + assert payload["wallet_valuation"]["status"] == "available" + assert payload["wallet_valuation"]["amount"] == "0" + assert payload["wallet_valuation"]["wallet_count"] == 1 + validate_account_facts_payload(payload) + + +def test_wallet_decimal_sum_preserves_precision_and_rejects_excessive_rows(): + class PreciseWallet(FakeBinance): + def _request_margin_api(self, method, path, **kwargs): + return [ + {"activate": True, "balance": "0.123456789012345678901234567890", "walletName": "A"}, + {"activate": True, "balance": "0.000000000000000000000000000001", "walletName": "B"}, + ] + + payload = _collect(PreciseWallet()) + assert payload["wallet_valuation"]["amount"] == "0.123456789012345678901234567891" + + class TooManyWallets(FakeBinance): + def _request_margin_api(self, method, path, **kwargs): + return [ + {"activate": True, "balance": "0", "walletName": f"WALLET-{index}"} + for index in range(33) + ] + + overflow = _collect(TooManyWallets()) + assert overflow["wallet_valuation"]["status"] == "unavailable" + assert overflow["wallet_valuation"]["reason_code"] == "wallet_response_invalid" + + +def test_wallet_valuation_payload_rejects_unrecognized_failure_reason(): + payload = _collect() + payload["wallet_valuation"] = { + "status": "unavailable", + "amount": None, + "currency": None, + "source": "GET /sapi/v1/asset/wallet/balance", + "scope": "provider_returned_wallet_rows", + "observed_at": None, + "wallet_count": None, + "reason_code": "synthetic-unrecognized", + } + with pytest.raises(AccountFactsUnavailable, match="payload_invalid"): + validate_account_facts_payload(payload) + + +def test_wallet_request_uses_locked_sdk_signed_get_once_without_redirect(): + probe = textwrap.dedent( + """ + import requests + from binance.client import Client + from application.account_facts import ReadOnlyBinanceClient + + raw_client = Client( + "synthetic-key", "synthetic-secret", requests_params={"timeout": 15}, ping=False + ) + captured = [] + + def fake_get(uri, **kwargs): + captured.append((uri, kwargs)) + response = requests.Response() + response.status_code = 200 + response.url = uri + response.encoding = "utf-8" + response._content = b'[{"activate":true,"balance":"1.25","walletName":"SPOT"}]' + return response + + raw_client.session.get = fake_get + response = ReadOnlyBinanceClient(raw_client).get_wallet_valuation_usdt() + assert response == [{"activate": True, "balance": "1.25", "walletName": "SPOT"}] + assert len(captured) == 1 + uri, kwargs = captured[0] + assert uri == "https://api.binance.com/sapi/v1/asset/wallet/balance" + assert kwargs["timeout"] == 15 + assert kwargs["allow_redirects"] is False + params = kwargs["params"] + assert "quoteAsset=USDT" in params + assert "needBalanceDetail=false" in params + assert "timestamp=" in params and "signature=" in params + print("wallet-wire-ok") + """ + ) + result = subprocess.run( + [sys.executable, "-c", probe], + cwd=Path(__file__).resolve().parents[1], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, "isolated locked-SDK wallet wire probe failed" + assert result.stdout.strip() == "wallet-wire-ok" + + def test_synthetic_contract_fixture_matches_exact_producer_json(): fixture_path = Path(__file__).parent / "fixtures" / "binance_account_facts.v1.synthetic.json" - payload = _collect(times=[START + timedelta(seconds=i) for i in (1, 2, 3)]) + payload = _collect(times=[START + timedelta(seconds=i) for i in (1, 2, 3, 4)]) + payload.pop("wallet_valuation") + payload["observed_finished_at"] = "2026-10-02T10:00:03Z" encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False) + "\n" assert encoded == fixture_path.read_text(encoding="utf-8") @@ -607,7 +781,8 @@ def test_payload_validator_rejects_reordered_observations(): def test_zero_earn_positions_is_a_complete_empty_page(): client = FakeBinance(earn_pages=[{"rows": [], "total": 0}]) payload = _collect(client) - assert client.calls == [("spot",), ("earn", 1, 100)] + assert [call[0] for call in client.calls] == ["spot", "earn", "wallet"] + assert payload["wallet_valuation"]["status"] == "available" assert all(row["flexible_earn"] == "0" for row in payload["assets"])