Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 77 additions & 19 deletions scripts/migrate_daily_accounting_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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 (
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1552,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,
Expand Down Expand Up @@ -1694,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"],
Expand Down
133 changes: 133 additions & 0 deletions tests/test_earn_forward_diagnosis.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,139 @@ 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_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

Expand Down