From 967c707f648f2152aee0bd337c95a2f26ac97d6e Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:55:30 +0800 Subject: [PATCH] fix: stabilize IBKR reconciliation cash evidence Co-Authored-By: Codex --- application/broker_reconciliation.py | 27 +++++-- ...econciliation_baseline_enrollment.zh-CN.md | 4 + tests/test_broker_reconciliation.py | 73 +++++++++++++++++++ 3 files changed, 99 insertions(+), 5 deletions(-) diff --git a/application/broker_reconciliation.py b/application/broker_reconciliation.py index 229f0f6..e026827 100644 --- a/application/broker_reconciliation.py +++ b/application/broker_reconciliation.py @@ -39,6 +39,18 @@ class IBKRReconciliationReadError(RuntimeError): "local_execution_ledger_sha256", ) +# These are actual cash-balance tags, in the same order as the execution +# snapshot's cash selector. Margin capacity and mark-to-market account tags +# (for example AvailableFunds and NetLiquidation) must never enter a recovery +# digest: they legitimately move while no cash, order, or position changes. +_CASH_BALANCE_TAG_PRIORITY = ( + "$LEDGER-CashBalance", + "$LEDGER-TotalCashBalance", + "CashBalance", + "TotalCashBalance", + "SettledCash", +) + def _text(value: object) -> str: return str(value or "").strip() @@ -125,11 +137,16 @@ def _normalise_cash_balance(value: Mapping[str, object], *, selected_account_ids for tag, number in value.items() if _text(tag) not in {"account_id", "currency"} } - return { - "account": account_id, - "currency": _text(value.get("currency")).upper(), - "tags": dict(sorted(numeric_tags.items())), - } + for tag in _CASH_BALANCE_TAG_PRIORITY: + if tag in numeric_tags: + return { + "account": account_id, + "currency": _text(value.get("currency")).upper(), + "tags": {tag: numeric_tags[tag]}, + } + raise IBKRReconciliationReadError( + "IBKR reconciliation is missing a stable cash-balance tag." + ) def _open_trade_account(trade: Any) -> object: diff --git a/docs/ibkr_reconciliation_baseline_enrollment.zh-CN.md b/docs/ibkr_reconciliation_baseline_enrollment.zh-CN.md index 8107137..dc00360 100644 --- a/docs/ibkr_reconciliation_baseline_enrollment.zh-CN.md +++ b/docs/ibkr_reconciliation_baseline_enrollment.zh-CN.md @@ -104,3 +104,7 @@ Scheduler 任务,再由既有的最小权限 Scheduler 身份调用冻结服 同一目标至少应在相隔一分钟的两次手动运行中得到候选,才能交给 `build_reconciliation_baseline_candidate.py`。工作流的成功只说明读取和收据格式正常; 候选仍可能因为未配置预期摘要或账本差异而正确保持阻断。 + +其中现金摘要只选择结算/账面现金标签(例如 `CashBalance`),不会把随市价变化的 +`NetLiquidation` 或保证金可用额计入。这样没有现金、订单或仓位变化的账户不会因为正常 +估值波动而被误判为基线漂移;没有可靠现金标签时仍会失败关闭。 diff --git a/tests/test_broker_reconciliation.py b/tests/test_broker_reconciliation.py index e595d33..ee9cba4 100644 --- a/tests/test_broker_reconciliation.py +++ b/tests/test_broker_reconciliation.py @@ -120,6 +120,79 @@ def fetch_portfolio_snapshot(_ib, **kwargs): assert observations.open_orders[0]["account"] == "U123" +def test_cash_reconciliation_ignores_dynamic_margin_and_valuation_tags() -> None: + def fetch_snapshot(_ib, *, dynamic_net_liquidation: float, dynamic_available_funds: float, **_kwargs): + return SimpleNamespace( + positions=(), + metadata={ + "cash_balances": ( + { + "account_id": "U123", + "currency": "USD", + "CashBalance": 123.45, + "AvailableFunds": dynamic_available_funds, + "NetLiquidation": dynamic_net_liquidation, + }, + ), + "option_positions": (), + }, + ) + + ib = _IB() + first = collect_read_only_reconciliation_observations( + ib, + account_ids=("U123",), + fetch_portfolio_snapshot=lambda *args, **kwargs: fetch_snapshot( + *args, + **kwargs, + dynamic_net_liquidation=1_000.0, + dynamic_available_funds=700.0, + ), + market_currency="USD", + cash_only_execution=True, + ) + second = collect_read_only_reconciliation_observations( + ib, + account_ids=("U123",), + fetch_portfolio_snapshot=lambda *args, **kwargs: fetch_snapshot( + *args, + **kwargs, + dynamic_net_liquidation=1_050.0, + dynamic_available_funds=750.0, + ), + market_currency="USD", + cash_only_execution=True, + ) + + assert first.cash == second.cash == ( + {"account": "U123", "currency": "USD", "tags": {"CashBalance": 123.45}}, + ) + + +def test_cash_reconciliation_fails_closed_without_a_cash_balance_tag() -> None: + with pytest.raises(IBKRReconciliationReadError, match="stable cash-balance tag"): + collect_read_only_reconciliation_observations( + _IB(), + account_ids=("U123",), + fetch_portfolio_snapshot=lambda *_args, **_kwargs: SimpleNamespace( + positions=(), + metadata={ + "cash_balances": ( + { + "account_id": "U123", + "currency": "USD", + "AvailableFunds": 700.0, + "NetLiquidation": 1_000.0, + }, + ), + "option_positions": (), + }, + ), + market_currency="USD", + cash_only_execution=True, + ) + + def test_missing_read_only_order_surface_fails_closed() -> None: class MissingOpenOrderReader(_IB): reqAllOpenOrders = None