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
49 changes: 48 additions & 1 deletion application/broker_reconciliation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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]] = []
Expand Down Expand Up @@ -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),
Expand Down
120 changes: 118 additions & 2 deletions tests/test_broker_reconciliation.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import json
from dataclasses import replace
from datetime import datetime, timezone
from types import SimpleNamespace

Expand All @@ -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():
Expand Down Expand Up @@ -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"},
Expand All @@ -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