diff --git a/application/broker_reconciliation.py b/application/broker_reconciliation.py index 7f2bbcb..b3efe5c 100644 --- a/application/broker_reconciliation.py +++ b/application/broker_reconciliation.py @@ -14,6 +14,7 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import datetime, timezone +from decimal import Decimal, InvalidOperation from typing import Any from quant_platform_kit.common.broker_reconciliation import ( @@ -85,6 +86,50 @@ def _canonical_records(records: list[Mapping[str, object]]) -> tuple[Mapping[str ) +def _position_quantities(payload: object) -> list[dict[str, str]]: + """Bind all SDK items' identities/quantities, not their changing valuations. + + Only the verified items/symbol/quantity shape is supported. Do not filter + unmanaged holdings, infer missing quantities, merge duplicate symbols, or + round quantities to an assumed instrument precision. + """ + + payload = _json_value(payload, surface="positions") + try: + if not isinstance(payload, Mapping) or payload.get("error"): + raise ValueError + rows = payload.get("items") + if not isinstance(rows, list): + raise ValueError + quantities = [] + symbols = set() + for row in rows: + if not isinstance(row, Mapping): + raise ValueError + symbol, value = row.get("symbol"), row.get("quantity") + if not isinstance(symbol, str) or not symbol.strip(): + raise ValueError + symbol = symbol.strip().upper() + if symbol in symbols: + raise ValueError + if isinstance(value, bool) or not isinstance(value, (str, int, float)): + raise ValueError + quantity = Decimal(str(value)) + if not quantity.is_finite(): + raise ValueError + # Decimal.normalize() can round under the current decimal context. + text = format(quantity, "f") + if "." in text: + text = text.rstrip("0").rstrip(".") + quantities.append({"symbol": symbol, "quantity": "0" if quantity == 0 else text}) + symbols.add(symbol) + return sorted(quantities, key=lambda row: row["symbol"]) + except (InvalidOperation, ValueError) as exc: + raise FirstradeReconciliationUnavailable( + "Firstrade reconciliation received incomplete position facts." + ) from None + + @dataclass(frozen=True) class FirstradeReconciliationObservations: """Sensitive in-memory observations. Never serialize this object.""" @@ -182,6 +227,7 @@ def collect_read_only_reconciliation_observations( raise FirstradeReconciliationUnavailable("Firstrade reconciliation received incomplete balances.") if not isinstance(positions, Mapping): raise FirstradeReconciliationUnavailable("Firstrade reconciliation received incomplete positions.") + _position_quantities(positions) if not isinstance(orders, list) or any(not isinstance(order, Mapping) for order in orders): raise FirstradeReconciliationUnavailable("Firstrade reconciliation received incomplete orders.") open_orders: list[Mapping[str, object]] = [] @@ -252,7 +298,8 @@ def build_reconciliation_candidate( raise FirstradeReconciliationUnavailable("Firstrade reconciliation runtime target is incomplete.") baseline_id, baseline_target_sha256, runtime_target_sha256 = _continuity_fields(runtime_target) digests = { - "positions_sha256": calculate_broker_observation_sha256(observations.positions), + # Existing expected raw-payload digests intentionally do not migrate here. + "positions_sha256": calculate_broker_observation_sha256(_position_quantities(observations.positions)), "cash_sha256": calculate_broker_observation_sha256(observations.cash), "open_orders_sha256": calculate_broker_observation_sha256(observations.open_orders), "recent_executions_sha256": calculate_broker_observation_sha256(observations.recent_executions), diff --git a/tests/test_broker_reconciliation.py b/tests/test_broker_reconciliation.py index e4ad758..316ad08 100644 --- a/tests/test_broker_reconciliation.py +++ b/tests/test_broker_reconciliation.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json +from dataclasses import replace from datetime import datetime, timezone from types import SimpleNamespace @@ -11,8 +13,12 @@ build_reconciliation_candidate, collect_read_only_reconciliation_observations, collect_broker_reconciliation_evidence, + validate_reconciliation_candidate, +) +from quant_platform_kit.common.broker_reconciliation import ( + build_broker_reconciliation_evidence, + calculate_broker_observation_sha256, ) -from quant_platform_kit.common.broker_reconciliation import build_broker_reconciliation_evidence def _evidence(): @@ -133,7 +139,7 @@ def test_candidate_with_no_immutable_baseline_is_redacted_and_remains_blocked(): observations = FirstradeReconciliationObservations( account_scope={"account_id": "account-sensitive-001"}, account_identity_match=True, - positions=({"symbol": "SPY", "quantity": "1"},), + positions={"items": [{"symbol": "SPY", "quantity": "1"}]}, cash={"cash_balance": "100.25"}, open_orders=({"order_id": "order-sensitive-001", "status": "WORKING"},), recent_executions={"availability": "unavailable"}, @@ -156,3 +162,113 @@ def test_candidate_with_no_immutable_baseline_is_redacted_and_remains_blocked(): serialized = str(payload) for raw in ("account-sensitive-001", "order-sensitive-001", "100.25"): assert raw not in serialized + + +def _observations_with_positions(monkeypatch, positions): + monkeypatch.setattr(_ReadOnlyClient, "get_positions", lambda *_: positions) + return collect_read_only_reconciliation_observations( + _ReadOnlyClient(), requested_account="account-sensitive-001" + ) + + +def _candidate(observations, expected_positions=None): + # Synthetic expected accounting facts, never a production baseline rewrite. + expected = { + "account_scope_sha256": "1" * 64, + "positions_sha256": calculate_broker_observation_sha256( + expected_positions if expected_positions is not None else [{"symbol": "SPY", "quantity": "1"}] + ), + "cash_sha256": "3" * 64, + "open_orders_sha256": "4" * 64, + "recent_executions_sha256": "5" * 64, + "local_execution_ledger_sha256": "7" * 64, + } + return build_reconciliation_candidate( + observations=observations, + runtime_target=_runtime_target(), + project_id=None, + ledger_digest_reader=lambda: ("7" * 64, 0), + observed_at=datetime(2026, 9, 5, tzinfo=timezone.utc), + env_reader=lambda *_: json.dumps(expected), + ) + + +def test_valuation_changes_leave_position_accounting_equal_but_keep_raw_data(monkeypatch): + raw = {"items": [{"symbol": "SPY", "quantity": "1", "price": "500", "market_value": "500", "profit_loss": "10"}]} + observations = _observations_with_positions(monkeypatch, raw) + first = _candidate(observations) + changed = {"items": [{**raw["items"][0], "price": "501", "market_value": "501", "profit_loss": "11"}]} + current = _observations_with_positions(monkeypatch, changed) + second = _candidate(current) + + assert first.evidence.positions_match and second.evidence.positions_match + assert first.evidence.positions_sha256 == second.evidence.positions_sha256 + assert observations.positions == raw and current.positions == changed + assert validate_reconciliation_candidate(second) == second.to_safe_dict() + assert not second.evidence.cash_match + assert not second.recent_executions_available and not second.permits_active_lkg + + +@pytest.mark.parametrize("row", [ + {"symbol": "IBIT", "quantity": "1"}, + {"symbol": "SPY", "quantity": "1.00000000000000000000000000001"}, + {"symbol": "SPY", "quantity": "0"}, +]) +def test_symbol_or_exact_quantity_changes_are_detected(monkeypatch, row): + observations = _observations_with_positions(monkeypatch, {"items": [row]}) + assert not _candidate(observations).evidence.positions_match + + +def test_position_projection_keeps_unmanaged_holdings_and_ignores_row_order(monkeypatch): + expected = [{"symbol": "IBIT", "quantity": "2"}, {"symbol": "SPY", "quantity": "1"}] + observations = _observations_with_positions(monkeypatch, {"items": list(reversed(expected))}) + assert _candidate(observations, expected).evidence.positions_match + assert not _candidate(observations, [expected[1]]).evidence.positions_match + + +@pytest.mark.parametrize("quantity", [1, 1.0, "1.000", "1e0"]) +def test_quantity_formatting_is_exactly_normalized_without_rounding(monkeypatch, quantity): + observations = _observations_with_positions(monkeypatch, {"items": [{"symbol": "SPY", "quantity": quantity}]}) + assert _candidate(observations).evidence.positions_match + + +@pytest.mark.parametrize("symbol", ["spy", " SPY "]) +def test_position_symbol_formatting_is_normalized(monkeypatch, symbol): + observations = _observations_with_positions(monkeypatch, {"items": [{"symbol": symbol, "quantity": "1"}]}) + assert _candidate(observations).evidence.positions_match + + +def test_explicit_empty_positions_are_valid_without_proving_recovery(monkeypatch): + observations = _observations_with_positions(monkeypatch, {"items": []}) + candidate = _candidate(observations, []) + assert candidate.evidence.positions_match + assert not candidate.permits_active_lkg + + +@pytest.mark.parametrize("surface", ["collector", "candidate"]) +@pytest.mark.parametrize("positions", [ + {}, [], {"positions": []}, {"items": None}, {"items": {}}, {"items": [None]}, + {"error": "synthetic-denial", "items": []}, + {"items": [{"symbol": "SPY"}]}, + {"items": [{"quantity": "1"}]}, + {"items": [{"symbol": "", "quantity": "1"}]}, + {"items": [{"symbol": 1, "quantity": "1"}]}, + {"items": [{"symbol": "SPY", "quantity": "1"}, {"symbol": "SPY", "quantity": "2"}]}, + {"items": [{"symbol": "SPY", "quantity": "1"}, {"symbol": " spy ", "quantity": "2"}]}, + *({"items": [{"symbol": "SPY", "quantity": value}]} for value in [None, True, {}, "", "NaN", "Infinity", float("nan"), float("inf")]), +]) +def test_invalid_position_facts_are_never_empty_or_candidate_evidence(monkeypatch, surface, positions): + if surface == "candidate": + observations = _observations_with_positions(monkeypatch, {"items": []}) + observations = replace(observations, positions=positions) + with pytest.raises(FirstradeReconciliationUnavailable): + if surface == "collector": + _observations_with_positions(monkeypatch, positions) + else: + _candidate(observations) + + +def test_legacy_full_payload_digest_is_not_reinterpreted_as_accounting_baseline(monkeypatch): + raw = {"items": [{"symbol": "SPY", "quantity": "1"}]} + observations = _observations_with_positions(monkeypatch, raw) + assert not _candidate(observations, raw).evidence.positions_match