From 95992da4d77252c4c751d66d29e02fdd5d4062ec Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:08:23 +0800 Subject: [PATCH] Bridge verified Binance BNB dividends into Earn accounting Co-Authored-By: Codex --- application/broker_reconciliation.py | 111 ++++++++++++++++++- application/earn_accrual.py | 5 + application/portfolio_service.py | 37 ++++++- application/rebased_recovery.py | 20 ++++ run_cycle_replay.py | 3 + runtime_support.py | 27 ++++- scripts/migrate_daily_accounting_state.py | 16 ++- scripts/validate_runtime_startup.py | 12 ++ tests/test_balance_flow_diagnostics.py | 18 +++ tests/test_bnb_dividend_bridge.py | 105 ++++++++++++++++++ tests/test_earn_forward_diagnosis.py | 2 +- tests/test_forward_earn_accounting.py | 56 +++++++++- tests/test_prospective_recovery_diagnosis.py | 12 +- tests/test_validate_runtime_startup.py | 27 +++++ 14 files changed, 435 insertions(+), 16 deletions(-) create mode 100644 tests/test_bnb_dividend_bridge.py diff --git a/application/broker_reconciliation.py b/application/broker_reconciliation.py index 04887f20..15e01424 100644 --- a/application/broker_reconciliation.py +++ b/application/broker_reconciliation.py @@ -39,6 +39,7 @@ _EXTERNAL_CASH_FLOW_PAGE_SIZE = 1000 _EXTERNAL_CASH_FLOW_MAX_RECORDS = 256 _EXTERNAL_CASH_FLOW_CURSOR_VERSION = 1 +_BNB_DIVIDEND_PAGE_SIZE = 500 class BinanceReconciliationReadError(RuntimeError): @@ -288,6 +289,98 @@ def collect_spot_usdt_external_cash_flows( } +def collect_bnb_dividend_quantity( + client: Any, *, start: datetime, end: datetime +) -> dict[str, object]: + """Read one complete BNB dividend window for Earn quantity conservation. + + This is a narrow platform contract: only positive BNB rows with integer + ``direction == 1`` are accepted. The rows stay in memory and the returned + identities are only for same-window stability and duplicate detection. + """ + if ( + not isinstance(start, datetime) + or not isinstance(end, datetime) + or start.tzinfo is None + or end.tzinfo is None + or not start < end + ): + raise ValueError("bnb_dividend_window_invalid") + start_ms = int(start.astimezone(timezone.utc).timestamp() * 1000) + end_ms = int(end.astimezone(timezone.utc).timestamp() * 1000) + try: + response = client._request_margin_api( + "get", + "asset/assetDividend", + signed=True, + data={"asset": "BNB", "startTime": start_ms, "endTime": end_ms, + "limit": _BNB_DIVIDEND_PAGE_SIZE}, + ) + except Exception: + raise ValueError("bnb_dividend_read_failed") from None + if not isinstance(response, Mapping): + raise ValueError("bnb_dividend_response_invalid") + rows = response.get("rows") + total = response.get("total") + total_is_decimal_string = ( + isinstance(total, str) and total.isascii() and total.isdecimal() and len(total) <= 10 + ) + count = int(total) if type(total) is int or total_is_decimal_string else None + if ( + not isinstance(rows, list) + or count is None + or count < 0 + or count != len(rows) + or count >= _BNB_DIVIDEND_PAGE_SIZE + ): + raise ValueError("bnb_dividend_history_incomplete") + quantity = Decimal(0) + identities = [] + direction_values = set() + with localcontext() as context: + context.prec = 100 + for row in rows: + if not isinstance(row, Mapping): + raise ValueError("bnb_dividend_row_invalid") + identity = (row.get("id"), row.get("tranId"), row.get("divTime")) + if ( + type(identity[0]) is not int + or identity[0] < 0 + or type(identity[1]) is not int + or identity[1] < 0 + or type(identity[2]) is not int + or not start_ms < identity[2] <= end_ms + or row.get("asset") != "BNB" + or type(row.get("direction")) is not int + or row.get("direction") != 1 + or identity in identities + ): + raise ValueError("bnb_dividend_row_invalid") + amount = row.get("amount") + if not isinstance(amount, str) or not amount or len(amount) > 80: + raise ValueError("bnb_dividend_row_invalid") + try: + decimal_amount = Decimal(amount) + except DecimalException: + raise ValueError("bnb_dividend_row_invalid") from None + if ( + not decimal_amount.is_finite() + or decimal_amount <= 0 + or abs(decimal_amount) > Decimal("1e30") + or decimal_amount.as_tuple().exponent < -30 + ): + raise ValueError("bnb_dividend_row_invalid") + quantity += decimal_amount + identities.append(identity) + direction_values.add(1) + return { + "quantity": quantity, + "record_count": len(identities), + "identities": tuple(identities), + "direction_values": tuple(sorted(direction_values)), + } + + def _canonical_records(records: Sequence[Mapping[str, object]]) -> tuple[dict[str, object], ...]: return tuple(sorted((dict(item) for item in records), key=lambda item: json.dumps(item, sort_keys=True))) @@ -903,9 +996,23 @@ def diagnose_bnb_wallet_activity( "total_valid": count is not None and count >= 0, "rows_readable": rows_readable, "visible_row_count": visible_row_count, + "empty_missing_total_exempt": ( + name == "spot_dust_conversions" + and isinstance(response, Mapping) + and "total" not in response + and rows == [] + ), "window_complete": ( - count is not None and count >= 0 and shape["total_matches_rows"] - and count < limit and valid_rows + ( + name == "spot_dust_conversions" + and isinstance(response, Mapping) + and "total" not in response + and rows == [] + ) + or ( + count is not None and count >= 0 and shape["total_matches_rows"] + and count < limit and valid_rows + ) ), }) shape["row_time_and_asset_valid"] = valid_rows diff --git a/application/earn_accrual.py b/application/earn_accrual.py index 6e5fad37..dddf1cff 100644 --- a/application/earn_accrual.py +++ b/application/earn_accrual.py @@ -213,6 +213,7 @@ def prepare_forward_earn_state(state, current, cash_flows): if cash_flows['new_unsupported_deposit_count'] or cash_flows['new_or_changed_withdrawal_count']: raise ValueError('earn_cash_flow_unsupported') principal = _amount(cash_flows['new_deposit_principal_usdt']) + dividend_quantity = _amount(cash_flows.get('bnb_dividend_quantity', '0')) completed = cash_flows['new_deposit_completed_at'] if (not isinstance(completed, list) or len(completed) != cash_flows['new_confirmed_deposit_count'] or (principal == 0) != (not completed) @@ -220,6 +221,10 @@ def prepare_forward_earn_state(state, current, cash_flows): for t in completed)): raise ValueError('earn_cash_flow_time_unverified') verified['USDT'] += principal + if dividend_quantity: + if 'BNB' not in verified: + raise ValueError('earn_cash_flow_invalid') + verified['BNB'] += dividend_quantity compare_earn_checkpoints(previous, current, verified_net_changes={a: str(v) for a, v in verified.items()}) updated = copy.deepcopy(state) if principal and state.get('last_reset_date') == cutoff.date().isoformat(): diff --git a/application/portfolio_service.py b/application/portfolio_service.py index dcf74d8c..fc0f0228 100644 --- a/application/portfolio_service.py +++ b/application/portfolio_service.py @@ -92,7 +92,42 @@ def maybe_rebase_daily_state_for_balance_change( raise ValueError('earn_valuation_snapshot_mismatch') cash = collect_external_cash_flows_fn(runtime.client, now=_time(current['observed_at']), cursor=state.get('external_cash_flow_cursor')) - updated = prepare_forward_earn_state(state, current, cash) + try: + updated = prepare_forward_earn_state(state, current, cash) + except ValueError as exc: + if str(exc) != 'earn_quantity_change_unexplained': + raise + previous = state['earn_accrual_checkpoint'] + previous_bnb = previous.get('assets', {}).get('BNB') + current_bnb = current.get('assets', {}).get('BNB') + net_bnb = state.get('earn_accounted_net_changes', {}).get('BNB', '0') + if not isinstance(previous_bnb, dict) or not isinstance(current_bnb, dict): + raise + try: + reward_delta = sum( + Decimal(str(current_bnb['products'][product]['realtime_rewards'])) + - Decimal(str(previous_bnb['products'][product]['realtime_rewards'])) + for product in previous_bnb['products'] + ) + unexplained_bnb = ( + Decimal(str(current_bnb['quantity'])) + - Decimal(str(previous_bnb['quantity'])) + - reward_delta + - Decimal(str(net_bnb)) + ) + except (KeyError, TypeError, InvalidOperation): + raise + if unexplained_bnb <= 0: + raise + from application.broker_reconciliation import collect_bnb_dividend_quantity + dividend = collect_bnb_dividend_quantity( + runtime.client, + start=_time(previous['observed_at']), + end=_time(current['observed_at']), + ) + cash = dict(cash) + cash['bnb_dividend_quantity'] = format(dividend['quantity'], 'f') + updated = prepare_forward_earn_state(state, current, cash) except Exception as exc: reason_code = ( str(exc) diff --git a/application/rebased_recovery.py b/application/rebased_recovery.py index 61ae1f28..6c30e18f 100644 --- a/application/rebased_recovery.py +++ b/application/rebased_recovery.py @@ -21,6 +21,7 @@ ) from application.broker_reconciliation import ( + collect_bnb_dividend_quantity, collect_read_only_reconciliation_observations, collect_spot_usdt_external_cash_flows, diagnose_balance_flows, @@ -282,6 +283,12 @@ def _collect_prospective_rebase_private( ) except ValueError: raise ValueError("prospective_rebase_cash_flow_unverified") from None + try: + first_dividends = collect_bnb_dividend_quantity( + client, start=opening_at, end=observed_at + ) if "BNB" in assets else {"quantity": Decimal(0), "identities": ()} + except ValueError: + raise ValueError("prospective_rebase_dividend_unverified") from None final_at = (clock or (lambda: datetime.now(timezone.utc)))().astimezone(timezone.utc) if not observed_at < final_at <= observed_at + timedelta(minutes=2): @@ -302,8 +309,21 @@ def _collect_prospective_rebase_private( raise ValueError("prospective_rebase_checkpoint_unavailable") from None except ValueError: raise ValueError("prospective_rebase_cash_flow_unverified") from None + try: + second_dividends = collect_bnb_dividend_quantity( + client, start=opening_at, end=final_at + ) if "BNB" in assets else {"quantity": Decimal(0), "identities": ()} + except ValueError: + raise ValueError("prospective_rebase_dividend_unverified") from None if not _same_cash_flow_slice(first_flows, second_flows): raise ValueError("prospective_rebase_cash_flow_changed_during_read") + if ( + first_dividends.get("identities") != second_dividends.get("identities") + or first_dividends.get("quantity") != second_dividends.get("quantity") + ): + raise ValueError("prospective_rebase_dividend_changed_during_read") + first_flows = {**first_flows, "bnb_dividend_quantity": format(first_dividends["quantity"], "f")} + second_flows = {**second_flows, "bnb_dividend_quantity": format(second_dividends["quantity"], "f")} try: first_state = prepare_forward_earn_state(ledger, first, first_flows) diff --git a/run_cycle_replay.py b/run_cycle_replay.py index d26c7559..7a6ba308 100644 --- a/run_cycle_replay.py +++ b/run_cycle_replay.py @@ -102,8 +102,11 @@ def _request_margin_api(self, method: str, path: str, *, signed: bool, data: dic if method != "get" or not signed or path not in { "capital/deposit/hisrec", "capital/withdraw/history", + "asset/assetDividend", }: raise RuntimeError("unsupported replay margin read") + if path == "asset/assetDividend": + return {"total": 0, "rows": []} return [] def _record(self, method: str, payload: dict[str, Any]): diff --git a/runtime_support.py b/runtime_support.py index 88634b39..2a5a90cf 100644 --- a/runtime_support.py +++ b/runtime_support.py @@ -639,7 +639,8 @@ def reconcile_runtime_cash_effects(runtime, state): and submission.get("symbol") == getattr(runtime, "fuel_symbol", None)) if prospective: import copy - from application.earn_accrual import collect_earn_checkpoint, compare_earn_checkpoints + from application.earn_accrual import collect_earn_checkpoint, compare_earn_checkpoints, _amount, _time + from application.broker_reconciliation import collect_bnb_dividend_quantity try: pending_state = copy.deepcopy(state) if fuel_pending: @@ -648,8 +649,22 @@ def reconcile_runtime_cash_effects(runtime, state): current = collect_earn_checkpoint(runtime.client, assets=previous["assets"], observed_at=datetime.now(timezone.utc), expected_account_scope_sha256=previous["account_scope_sha256"]) - compare_earn_checkpoints(previous, current, - verified_net_changes=pending_state["earn_accounted_net_changes"]) + try: + compare_earn_checkpoints(previous, current, + verified_net_changes=pending_state["earn_accounted_net_changes"]) + except ValueError as exc: + if str(exc) != "earn_quantity_change_unexplained" or "BNB" not in previous["assets"]: + raise + dividend = collect_bnb_dividend_quantity( + runtime.client, + start=_time(previous["observed_at"]), + end=_time(current["observed_at"]), + ) + verified = dict(pending_state["earn_accounted_net_changes"]) + verified["BNB"] = str( + _amount(verified["BNB"], signed=True) + dividend["quantity"] + ) + compare_earn_checkpoints(previous, current, verified_net_changes=verified) observations = {a: float(current["assets"][a]["quantity"]) for a in assets | {"USDT"}} except Exception: raise ExecutionIntegrityError("cash_reconciliation_uncertain") from None @@ -711,11 +726,17 @@ def runtime_set_trade_state(runtime, report, state, *, reason): require_runtime_state_owner(runtime) if runtime.state_writer is None: raise StatePersistenceError("state_persistence_failed") + import copy + before_write = copy.deepcopy(state) try: persisted = runtime.state_writer(state) except Exception: + state.clear() + state.update(before_write) raise StatePersistenceError("state_persistence_failed") from None if persisted is not True: + state.clear() + state.update(before_write) raise StatePersistenceError("state_persistence_failed") runtime.trade_state = state runtime.pending_funds = [item for item in runtime.pending_funds if not _accounted_funds(runtime, state, reason, item)] diff --git a/scripts/migrate_daily_accounting_state.py b/scripts/migrate_daily_accounting_state.py index c90ff39e..b6436f18 100644 --- a/scripts/migrate_daily_accounting_state.py +++ b/scripts/migrate_daily_accounting_state.py @@ -1172,6 +1172,9 @@ def _summarize_bnb_wallet_activity(report, *, residual, start, end): return summary summary["dust_bnb_detail_count"] = 0 summary["dust_non_bnb_target_count"] = 0 + dust_diagnostics = report.get("surface_diagnostics") if isinstance(report, Mapping) else None + dust_shape = dust_diagnostics.get("spot_dust_conversions") if isinstance(dust_diagnostics, Mapping) else None + dust_surface_exempt = isinstance(dust_shape, Mapping) and dust_shape.get("empty_missing_total_exempt") is True start_ms = int(start.timestamp() * 1000) end_ms = int(end.timestamp() * 1000) @@ -1248,12 +1251,13 @@ def _summarize_bnb_wallet_activity(report, *, residual, start, end): return summary summary["dust_record_count"] = len(private_rows["spot_dust_conversions"]) - 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 - combined_after_fee = dividend_total + dust_after_fee_total - summary["combined_transfer_residual_matches"] = combined_transfer == residual - summary["combined_after_fee_residual_matches"] = combined_after_fee == residual + if not dust_surface_exempt: + 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 + combined_after_fee = dividend_total + dust_after_fee_total + summary["combined_transfer_residual_matches"] = combined_transfer == residual + summary["combined_after_fee_residual_matches"] = combined_after_fee == residual except (MigrationBlocked, TypeError, ValueError, InvalidOperation): return summary summary["status"] = "COMPLETE" diff --git a/scripts/validate_runtime_startup.py b/scripts/validate_runtime_startup.py index 1e7f36af..9e9e3bb5 100644 --- a/scripts/validate_runtime_startup.py +++ b/scripts/validate_runtime_startup.py @@ -195,6 +195,7 @@ class _ReadOnlyBroker: _MARGIN_READS = frozenset({ "capital/deposit/hisrec", "capital/withdraw/history", + "asset/assetDividend", }) def __init__(self, client, *, symbols): @@ -265,6 +266,17 @@ def get_historical_klines(self, symbol, interval, lookback): def _request_margin_api(self, method, path, *, signed, data): if method != "get" or signed is not True or path not in self._MARGIN_READS: raise RuntimeError("full_cycle_broker_request_forbidden") + if path == "asset/assetDividend": + if ( + set(data) != {"asset", "startTime", "endTime", "limit"} + or data.get("asset") != "BNB" + or type(data.get("startTime")) is not int + or type(data.get("endTime")) is not int + or not data["startTime"] < data["endTime"] + or data["endTime"] - data["startTime"] > 7 * 24 * 60 * 60 * 1000 + or data.get("limit") != 500 + ): + raise RuntimeError("full_cycle_broker_dividend_window_forbidden") return self._read("_request_margin_api", method, path, signed=signed, data=dict(data)) def __getattr__(self, _name): diff --git a/tests/test_balance_flow_diagnostics.py b/tests/test_balance_flow_diagnostics.py index 423dae9d..4317f836 100644 --- a/tests/test_balance_flow_diagnostics.py +++ b/tests/test_balance_flow_diagnostics.py @@ -365,6 +365,24 @@ def read(*a, **kw): assert diagnose_bnb_wallet_activity(c, start=NOW-timedelta(hours=2), end=NOW)['requested_surfaces_complete'] is False +def test_bnb_wallet_diagnostic_accepts_only_empty_missing_total_dust_response(): + from application.broker_reconciliation import diagnose_bnb_wallet_activity + + def read(_method, path, **_kwargs): + if path.endswith("assetDividend"): + return {"total": 0, "rows": []} + return {"userAssetDribblets": []} + + result = diagnose_bnb_wallet_activity( + SimpleNamespace(_request_margin_api=read), start=NOW-timedelta(hours=2), end=NOW, + ) + + assert result["requested_surfaces_complete"] is True + shape = result["surface_diagnostics"]["spot_dust_conversions"] + assert shape["total_valid"] is False + assert shape["window_complete"] is True + + def test_bnb_wallet_dribblet_keeps_readable_rows_when_total_is_missing(): from application.broker_reconciliation import diagnose_bnb_wallet_activity diff --git a/tests/test_bnb_dividend_bridge.py b/tests/test_bnb_dividend_bridge.py new file mode 100644 index 00000000..d10f88c2 --- /dev/null +++ b/tests/test_bnb_dividend_bridge.py @@ -0,0 +1,105 @@ +from copy import deepcopy +from datetime import datetime, timezone +from decimal import Decimal + +import pytest + +from application.broker_reconciliation import collect_bnb_dividend_quantity +from application.earn_accrual import prepare_forward_earn_state +from tests.test_forward_earn_accounting import materials + + +START = datetime(2026, 9, 12, 14, 0, tzinfo=timezone.utc) +END = datetime(2026, 9, 12, 14, 1, tzinfo=timezone.utc) + + +def _row(**changes): + row = { + "id": 7, + "tranId": 8, + "asset": "BNB", + "divTime": int(END.timestamp() * 1000), + "amount": "0.00000001", + "direction": 1, + } + row.update(changes) + return row + + +class Client: + def __init__(self, response): + self.response = response + + def _request_margin_api(self, method, path, **kwargs): + assert method == "get" + assert path == "asset/assetDividend" + assert kwargs["signed"] is True + return self.response + + +def test_dividend_bridge_accepts_right_boundary_and_returns_private_quantity(): + result = collect_bnb_dividend_quantity( + Client({"total": 1, "rows": [_row()]}), start=START, end=END + ) + + assert result["quantity"] == Decimal("0.00000001") + assert result["record_count"] == 1 + assert result["direction_values"] == (1,) + assert result["identities"] == ((7, 8, int(END.timestamp() * 1000)),) + + +@pytest.mark.parametrize( + "changes", + [ + {"divTime": int(START.timestamp() * 1000)}, + {"asset": "USDT"}, + {"direction": 2}, + {"direction": True}, + {"amount": "0"}, + {"id": 7, "tranId": 8}, + ], +) +def test_dividend_bridge_rejects_boundary_wrong_asset_direction_amount_and_duplicate(changes): + rows = [_row(**changes)] + if changes == {"id": 7, "tranId": 8}: + rows = [_row(), _row()] + with pytest.raises(ValueError, match="bnb_dividend"): + collect_bnb_dividend_quantity(Client({"total": len(rows), "rows": rows}), start=START, end=END) + + +@pytest.mark.parametrize( + "response", + [ + {"rows": [_row()]}, + {"total": 500, "rows": [_row()] * 500}, + {"total": 2, "rows": [_row()]}, + ], +) +def test_dividend_bridge_rejects_missing_or_incomplete_page(response): + with pytest.raises(ValueError, match="bnb_dividend"): + collect_bnb_dividend_quantity(Client(response), start=START, end=END) + + +def test_prepare_forward_earn_state_consumes_only_verified_dividend_quantity(): + state, current, cash = materials() + current["assets"]["BNB"]["products"]["BNB001"]["realtime_rewards"] = "0.1" + cash["bnb_dividend_quantity"] = "0.00000001" + + updated = prepare_forward_earn_state(state, current, cash) + + assert updated["earn_accounted_net_changes"] == {"USDT": "0", "BNB": "0"} + assert updated.get("daily_external_principal_usdt", 0) == 0 + + +def test_unmatched_dividend_quantity_does_not_consume_forward_state(): + state, current, cash = materials() + current["assets"]["BNB"]["products"]["BNB001"]["realtime_rewards"] = "0.1" + current["assets"]["BNB"]["products"]["BNB001"]["total"] = "2.00000002" + current["assets"]["BNB"]["quantity"] = "3.00000002" + cash["bnb_dividend_quantity"] = "0.00000001" + before = deepcopy(state) + + with pytest.raises(ValueError, match="earn_quantity_change_unexplained"): + prepare_forward_earn_state(state, current, cash) + + assert state == before diff --git a/tests/test_earn_forward_diagnosis.py b/tests/test_earn_forward_diagnosis.py index 2637a0a1..813011df 100644 --- a/tests/test_earn_forward_diagnosis.py +++ b/tests/test_earn_forward_diagnosis.py @@ -562,7 +562,7 @@ def _request_margin_api(self, method, path, **_kwargs): ) bnb = result["assets"]["BNB"] - assert bnb["wallet_activity_complete"] is False + assert bnb["wallet_activity_complete"] is True assert bnb["wallet_dividend_surface_complete"] is True assert bnb["wallet_dividend_residual_matches"] is True assert bnb["wallet_dividend_direction_summary"] == { diff --git a/tests/test_forward_earn_accounting.py b/tests/test_forward_earn_accounting.py index a0c4ea2c..54f1d73b 100644 --- a/tests/test_forward_earn_accounting.py +++ b/tests/test_forward_earn_accounting.py @@ -34,9 +34,9 @@ def materials(): return state, new, cash -def consume(state, new, cash, writer=None): +def consume(state, new, cash, writer=None, client=None): writes = [] - runtime = SimpleNamespace(client=object(), now_utc=NOW, earn_accrual_observation=new) + runtime = SimpleNamespace(client=client or object(), now_utc=NOW, earn_accrual_observation=new) snapshot = {a: round(float(r['quantity']), 8) for a, r in new['assets'].items()} result = maybe_rebase_daily_state_for_balance_change(state, runtime, {}, 1600.000005, 0, snapshot, [], collect_external_cash_flows_fn=lambda *a, **kw: cash, @@ -128,6 +128,27 @@ def test_deposit_and_interest_are_separate_in_same_window(): assert state['daily_equity_base'] == 1600 +def test_portfolio_prepare_consumes_verified_bnb_dividend_without_principal_or_extra_pnl(): + state, new, cash = materials() + new['assets']['BNB']['products']['BNB001']['realtime_rewards'] = '0.1' + + class Client: + def _request_margin_api(self, method, path, **kwargs): + assert method == 'get' and path == 'asset/assetDividend' + return {'total': 1, 'rows': [{ + 'id': 7, 'tranId': 8, 'asset': 'BNB', + 'divTime': int(NOW.timestamp() * 1000), + 'amount': '0.00000001', 'direction': 1, + }]} + + _, writes = consume(state, new, cash, client=Client()) + + assert len(writes) == 1 + assert state['earn_accrual_checkpoint'] == new + assert state.get('daily_external_principal_usdt', 0) == 0 + assert compute_daily_pnls(state, 1600.000005, 0)[0] == pytest.approx(0.000005 / 1600) + + def test_known_buy_and_bnb_fee_use_durable_fill_deltas(): state, new, cash = materials() state['earn_accounted_net_changes'] = {'USDT': '-10', 'BNB': '0.01999'} @@ -193,6 +214,37 @@ def test_cash_refresh_checks_income_and_fill_without_advancing_unread_flow_curso assert state['earn_accounted_net_changes'] == {'USDT': '0', 'BNB': '0'} +def test_cash_refresh_bridges_verified_bnb_dividend_without_advancing_checkpoint(monkeypatch): + from runtime_support import reconcile_runtime_cash_effects + + state, new, _cash = materials() + new['assets']['BNB']['products']['BNB001']['realtime_rewards'] = '0.1' + before_checkpoint = copy.deepcopy(state['earn_accrual_checkpoint']) + + class Client: + def _request_margin_api(self, method, path, **kwargs): + assert method == 'get' and path == 'asset/assetDividend' + return {'total': 1, 'rows': [{ + 'id': 7, 'tranId': 8, 'asset': 'BNB', + 'divTime': int(NOW.timestamp() * 1000), + 'amount': '0.00000001', 'direction': 1, + }]} + + monkeypatch.setattr('application.earn_accrual.collect_earn_checkpoint', lambda *a, **kw: new) + runtime = SimpleNamespace( + client=Client(), state_owner_held=True, state_owner_id='synthetic', + fuel_symbol='BNBUSDT', pending_funds=[{'asset': 'BNB', 'confirmed': True}], + ) + + reconcile_runtime_cash_effects(runtime, state) + + assert state['earn_accrual_checkpoint'] == before_checkpoint + assert runtime.cash_balance_observation['BNB'] == 3.00000001 + consume(state, new, _cash, client=runtime.client) + assert state['earn_accrual_checkpoint'] == new + assert state['earn_accounted_net_changes'] == {'USDT': '0', 'BNB': '0'} + + def test_market_valuation_uses_same_checkpoint_not_independent_balance_reads(monkeypatch): from market_snapshot_support import capture_market_snapshot state, new, _ = materials() diff --git a/tests/test_prospective_recovery_diagnosis.py b/tests/test_prospective_recovery_diagnosis.py index 1fdbc92f..b120f68a 100644 --- a/tests/test_prospective_recovery_diagnosis.py +++ b/tests/test_prospective_recovery_diagnosis.py @@ -82,6 +82,7 @@ def __init__(self, change=None): self.change = change self.earn_reads = 0 self.flow_reads = 0 + self.dividend_reads = 0 self.account_reads = 0 def get_account(self): @@ -103,7 +104,7 @@ def get_simple_earn_flexible_product_position(self, *, current, size): 3 - self.earn_reads if self.change == "counter_between_reads" else self.earn_reads ) reward = "0" if self.change == "counter" else f"0.1000000{increment}" - total = f"2.0000000{increment}" + total = f"2.0000000{increment + 1}" return { "total": 1, "rows": [{ @@ -138,6 +139,14 @@ def _request_margin_api(self, method, path, **kwargs): if self.change == "flow": return [{"id": "new-withdrawal", "status": 6}] return [] + if path == "asset/assetDividend": + self.dividend_reads += 1 + stamp = int((NOW - timedelta(minutes=1)).timestamp() * 1000) + dividend_id = 7 if self.change != "dividend_between_reads" or self.dividend_reads == 1 else 8 + return {"total": 1, "rows": [{ + "id": dividend_id, "tranId": 9, "asset": "BNB", + "divTime": stamp, "amount": "0.00000001", "direction": 1, + }]} raise AssertionError(f"unexpected path {path}") @@ -193,6 +202,7 @@ def test_prospective_diagnosis_accepts_income_growth_without_mutating_ledger(mon ({"change": "counter"}, "prospective_rebase_conservation_unverified"), ({"change": "counter_between_reads"}, "prospective_rebase_conservation_unverified"), ({"change": "flow"}, "prospective_rebase_conservation_unverified"), + ({"change": "dividend_between_reads"}, "prospective_rebase_dividend_changed_during_read"), ({"change": "order"}, "prospective_rebase_open_orders_present"), ({"change": "trade"}, "prospective_rebase_recent_executions_present"), ({"change": "final_spot"}, "prospective_rebase_spot_changed_during_read"), diff --git a/tests/test_validate_runtime_startup.py b/tests/test_validate_runtime_startup.py index c022ae78..eaa971ba 100644 --- a/tests/test_validate_runtime_startup.py +++ b/tests/test_validate_runtime_startup.py @@ -232,6 +232,33 @@ def get_asset_balance(self, *, asset): broker.get_asset_balance(asset="DOGE") +def test_full_cycle_broker_proxy_allows_only_bounded_bnb_dividend_get(): + from scripts.validate_runtime_startup import _ReadOnlyBroker + + class Client: + def __init__(self): + self.calls = [] + + def _request_margin_api(self, method, path, **kwargs): + self.calls.append((method, path, kwargs)) + return {"total": 0, "rows": []} + + client = Client() + broker = _ReadOnlyBroker(client, symbols={"BNBUSDT"}) + result = broker._request_margin_api( + "get", "asset/assetDividend", signed=True, + data={"asset": "BNB", "startTime": 1000, "endTime": 2000, "limit": 500}, + ) + + assert result == {"total": 0, "rows": []} + assert len(client.calls) == 1 + with pytest.raises(RuntimeError, match="dividend_window_forbidden"): + broker._request_margin_api( + "get", "asset/assetDividend", signed=True, + data={"asset": "USDT", "startTime": 1000, "endTime": 2000, "limit": 500}, + ) + + def test_full_cycle_broker_proxy_allows_main_qpk_btc_snapshot_window(): import main from scripts.validate_runtime_startup import _ReadOnlyBroker