From a278550f6b7303e3e04e231ebf9306920ff63782 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:40:59 +0800 Subject: [PATCH 1/2] fix(binance): retain valid dividend surface diagnosis Co-Authored-By: Codex --- scripts/migrate_daily_accounting_state.py | 92 ++++++++++++++++++----- tests/test_earn_forward_diagnosis.py | 65 ++++++++++++++++ 2 files changed, 138 insertions(+), 19 deletions(-) diff --git a/scripts/migrate_daily_accounting_state.py b/scripts/migrate_daily_accounting_state.py index 0bded51e..0adf165f 100644 --- a/scripts/migrate_daily_accounting_state.py +++ b/scripts/migrate_daily_accounting_state.py @@ -1046,6 +1046,47 @@ def _direction(value): _BNB_DIAGNOSTIC_QUANTUM = Decimal("0.00000001") +def _validate_bnb_dividend_rows(rows, *, start_ms, end_ms): + if not isinstance(rows, list): + return None + row_keys = set() + direction_values = set() + direction_missing = False + total = Decimal(0) + try: + for row in rows: + if not isinstance(row, Mapping) or row.get("asset") != "BNB": + return None + div_time = row.get("divTime") + if type(div_time) is not int or not start_ms <= div_time <= end_ms: + return None + row_id = row.get("id") + tran_id = row.get("tranId") + if type(row_id) is not int or row_id < 0 or type(tran_id) is not int or tran_id < 0: + return None + key = (row_id, tran_id, div_time) + if key in row_keys: + return None + row_keys.add(key) + total += _diagnosis_decimal(row.get("amount"), signed=False) + direction = row.get("direction") + if type(direction) is int: + direction_values.add(direction) + else: + direction_missing = True + except (MigrationBlocked, TypeError, ValueError, InvalidOperation): + return None + return { + "count": len(rows), + "total": total, + "direction_summary": { + "integer_values": sorted(direction_values), + "missing": direction_missing, + "mixed": len(direction_values) > 1, + }, + } + + def _summarize_bnb_wallet_activity(report, *, residual, start, end): """Compare private wallet rows to BNB residual without exposing source data.""" private_rows = report.get("_private_rows") if isinstance(report, Mapping) else None @@ -1057,6 +1098,8 @@ def _summarize_bnb_wallet_activity(report, *, residual, start, end): "source_failure_stage": None, "source_response_shape": None, "dividend_count": None, + "dividend_surface_complete": False, + "dividend_direction_summary": {"integer_values": [], "missing": False, "mixed": False}, "dust_record_count": None, "dust_bnb_detail_count": None, "dust_non_bnb_target_count": None, @@ -1099,6 +1142,26 @@ def _summarize_bnb_wallet_activity(report, *, residual, start, end): ) if type(shape.get(key)) is bool or type(shape.get(key)) is int } + surface_diagnostics = report.get("surface_diagnostics") + dividend_shape = ( + surface_diagnostics.get("bnb_dividends") + if isinstance(surface_diagnostics, Mapping) else None + ) + if ( + isinstance(dividend_shape, Mapping) + and dividend_shape.get("window_complete") is True + and isinstance(private_rows, Mapping) + ): + validated = _validate_bnb_dividend_rows( + private_rows.get("bnb_dividends"), + start_ms=int(start.timestamp() * 1000), + end_ms=int(end.timestamp() * 1000), + ) + if validated is not None: + summary["dividend_count"] = validated["count"] + summary["dividend_surface_complete"] = True + summary["dividend_residual_matches"] = validated["total"] == residual + summary["dividend_direction_summary"] = validated["direction_summary"] return summary summary["status"] = "UNVERIFIED" if ( @@ -1112,27 +1175,20 @@ def _summarize_bnb_wallet_activity(report, *, residual, start, end): start_ms = int(start.timestamp() * 1000) end_ms = int(end.timestamp() * 1000) - dividend_total = Decimal(0) dust_transfer_total = Decimal(0) dust_after_fee_total = Decimal(0) - dividend_keys = set() dust_keys = set() try: - for row in private_rows["bnb_dividends"]: - if not isinstance(row, Mapping) or row.get("asset") != "BNB": - return summary - div_time = row.get("divTime") - if type(div_time) is not int or not start_ms <= div_time <= end_ms: - return summary - row_id = row.get("id") - tran_id = row.get("tranId") - if type(row_id) is not int or row_id < 0 or type(tran_id) is not int or tran_id < 0: - return summary - key = (row_id, tran_id, div_time) - if key in dividend_keys: - return summary - dividend_keys.add(key) - dividend_total += _diagnosis_decimal(row.get("amount"), signed=False) + validated_dividends = _validate_bnb_dividend_rows( + private_rows["bnb_dividends"], start_ms=start_ms, end_ms=end_ms, + ) + if validated_dividends is None: + return summary + dividend_total = validated_dividends["total"] + summary["dividend_count"] = validated_dividends["count"] + summary["dividend_surface_complete"] = True + summary["dividend_residual_matches"] = dividend_total == residual + summary["dividend_direction_summary"] = validated_dividends["direction_summary"] for record in private_rows["spot_dust_conversions"]: if not isinstance(record, Mapping): @@ -1191,9 +1247,7 @@ def _summarize_bnb_wallet_activity(report, *, residual, start, end): if detail_transfer_total != total_transfer or detail_fee_total != total_fee: return summary - summary["dividend_count"] = len(private_rows["bnb_dividends"]) summary["dust_record_count"] = len(private_rows["spot_dust_conversions"]) - summary["dividend_residual_matches"] = dividend_total == residual summary["dust_transfer_residual_matches"] = dust_transfer_total == residual summary["dust_after_fee_residual_matches"] = dust_after_fee_total == residual combined_transfer = dividend_total + dust_transfer_total diff --git a/tests/test_earn_forward_diagnosis.py b/tests/test_earn_forward_diagnosis.py index c01dc27c..fa4ae5c6 100644 --- a/tests/test_earn_forward_diagnosis.py +++ b/tests/test_earn_forward_diagnosis.py @@ -440,6 +440,71 @@ def test_bnb_wallet_summary_preserves_visible_rows_without_claiming_complete(): assert result["dust_transfer_residual_matches"] is None +def test_bnb_wallet_summary_validates_dividend_surface_when_dust_is_incomplete(): + from decimal import Decimal + from scripts import migrate_daily_accounting_state as migration + + stamp = int((NOW - timedelta(hours=1)).timestamp() * 1000) + result = migration._summarize_bnb_wallet_activity( + { + "requested_surfaces_complete": False, + "reason_code": "bnb_wallet_history_unverified", + "failed_surface": "spot_dust_conversions", + "failure_stage": "response_validation", + "counts": {"bnb_dividends": 1, "spot_dust_conversions": 1}, + "surface_diagnostics": { + "bnb_dividends": {"window_complete": True}, + "spot_dust_conversions": {"window_complete": False}, + }, + "_private_rows": { + "bnb_dividends": [{ + "id": 1, "tranId": 2, "asset": "BNB", "amount": "0.1", + "divTime": stamp, "direction": 1, "enInfo": "private", + }], + "spot_dust_conversions": [], + }, + }, + residual=Decimal("0.1"), start=NOW-timedelta(hours=2), end=NOW, + ) + + assert result["complete"] is False + assert result["dividend_surface_complete"] is True + assert result["dividend_residual_matches"] is True + assert result["dividend_net_semantics_verified"] is False + assert result["dust_transfer_residual_matches"] is None + assert result["combined_transfer_residual_matches"] is None + assert result["dividend_direction_summary"] == { + "integer_values": [1], "missing": False, "mixed": False, + } + assert "private" not in json.dumps(result) + + +def test_bnb_wallet_summary_direction_is_integer_only_and_marks_missing_or_mixed(): + from decimal import Decimal + from scripts import migrate_daily_accounting_state as migration + + stamp = int((NOW - timedelta(hours=1)).timestamp() * 1000) + result = migration._summarize_bnb_wallet_activity( + { + "requested_surfaces_complete": True, + "_private_rows": { + "bnb_dividends": [ + {"id": 1, "tranId": 2, "asset": "BNB", "amount": "0.1", "divTime": stamp, "direction": 1}, + {"id": 3, "tranId": 4, "asset": "BNB", "amount": "0.2", "divTime": stamp, "direction": 2}, + {"id": 5, "tranId": 6, "asset": "BNB", "amount": "0.0", "divTime": stamp}, + ], + "spot_dust_conversions": [], + }, + }, + residual=Decimal("0.3"), start=NOW-timedelta(hours=2), end=NOW, + ) + + assert result["dividend_direction_summary"] == { + "integer_values": [1, 2], "missing": True, "mixed": True, + } + assert result["dividend_net_semantics_verified"] is False + + def test_unknown_order_state_returns_restricted_diagnostic(monkeypatch): from scripts import migrate_daily_accounting_state as migration From 8450a49030598067f0ea01082763629bdcc8609f Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:44:45 +0800 Subject: [PATCH 2/2] test(binance): expose partial dividend diagnosis Co-Authored-By: Codex --- scripts/migrate_daily_accounting_state.py | 4 ++ tests/test_earn_forward_diagnosis.py | 68 +++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/scripts/migrate_daily_accounting_state.py b/scripts/migrate_daily_accounting_state.py index 0adf165f..c90ff39e 100644 --- a/scripts/migrate_daily_accounting_state.py +++ b/scripts/migrate_daily_accounting_state.py @@ -1606,6 +1606,8 @@ def _sample_residual_after_realtime(sample): bnb_wallet_activity = { "status": "NOT_CHECKED", "complete": False, + "dividend_surface_complete": False, + "dividend_direction_summary": {"integer_values": [], "missing": False, "mixed": False}, "source_reason_code": None, "source_failed_surface": None, "source_failure_stage": None, @@ -1748,6 +1750,8 @@ def _sample_residual_after_realtime(sample): asset_results["BNB"].update({ "wallet_activity_status": bnb_wallet_activity["status"], "wallet_activity_complete": bnb_wallet_activity["complete"], + "wallet_dividend_surface_complete": bnb_wallet_activity["dividend_surface_complete"], + "wallet_dividend_direction_summary": bnb_wallet_activity["dividend_direction_summary"], "wallet_dividend_record_count": bnb_wallet_activity["dividend_count"], "wallet_dust_record_count": bnb_wallet_activity["dust_record_count"], "wallet_dust_bnb_detail_count": bnb_wallet_activity["dust_bnb_detail_count"], diff --git a/tests/test_earn_forward_diagnosis.py b/tests/test_earn_forward_diagnosis.py index fa4ae5c6..2637a0a1 100644 --- a/tests/test_earn_forward_diagnosis.py +++ b/tests/test_earn_forward_diagnosis.py @@ -505,6 +505,74 @@ def test_bnb_wallet_summary_direction_is_integer_only_and_marks_missing_or_mixed assert result["dividend_net_semantics_verified"] is False +def test_diagnose_earn_forward_exposes_partial_dividend_surface_result(monkeypatch): + from scripts import migrate_daily_accounting_state as migration + + checkpoint_value = checkpoint( + observed_at="2026-09-13T09:00:00+00:00", quantity="1.1", reward="0", + ) + checkpoint_value["assets"]["BNB"] = { + "spot_free": "1", "spot_locked": "0", "products": {}, "quantity": "1", + } + current = copy.deepcopy(checkpoint_value) + current["observed_at"] = "2026-09-13T10:00:00+00:00" + current["assets"]["BNB"]["spot_free"] = "1.1" + current["assets"]["BNB"]["quantity"] = "1.1" + refs = source(checkpoint_value=checkpoint_value) + refs["ledger_ref"].snapshot.value["earn_accounted_net_changes"]["BNB"] = "0" + history = { + "history_complete_for_requested_surfaces": True, + "history_counts": {"earn_rewards": 1}, + "reward_quantity_checks": { + asset: { + "delta_matches_bonus": False, + "delta_matches_realtime": False, + "delta_matches_visible_total": False, + "reward_counts": {"BONUS": 0, "REALTIME": 0}, + } + for asset in ("USDT", "BTC", "BNB") + }, + } + observations = SimpleNamespace( + account_scope={"account_uid": "123"}, recent_executions=(), open_orders=(), + ) + install_read_stubs( + monkeypatch, migration, refs, current=current, history=history, + observations=observations, + ) + monkeypatch.setattr(migration, "_symbols_from_env", lambda: ("BTCUSDT", "BNBUSDT")) + + stamp = int((NOW - timedelta(hours=1)).timestamp() * 1000) + + class Client: + def _request_margin_api(self, method, path, **_kwargs): + assert method == "get" + if path == "asset/assetDividend": + return { + "rows": [{ + "id": 1, "tranId": 2, "asset": "BNB", "amount": "0.1", + "divTime": stamp, "direction": 1, + }], + "total": 1, + } + return {"userAssetDribblets": []} + + result = migration.diagnose_earn_forward( + refs, client=Client(), expected={"account_scope_sha256": SCOPE}, now=NOW, + ) + bnb = result["assets"]["BNB"] + + assert bnb["wallet_activity_complete"] is False + assert bnb["wallet_dividend_surface_complete"] is True + assert bnb["wallet_dividend_residual_matches"] is True + assert bnb["wallet_dividend_direction_summary"] == { + "integer_values": [1], "missing": False, "mixed": False, + } + assert bnb["wallet_dividend_net_semantics_verified"] is False + assert bnb["wallet_dust_transfer_residual_matches"] is None + assert bnb["wallet_combined_transfer_residual_matches"] is None + + def test_unknown_order_state_returns_restricted_diagnostic(monkeypatch): from scripts import migrate_daily_accounting_state as migration