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
27 changes: 22 additions & 5 deletions application/broker_reconciliation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions docs/ibkr_reconciliation_baseline_enrollment.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,7 @@ Scheduler 任务,再由既有的最小权限 Scheduler 身份调用冻结服
同一目标至少应在相隔一分钟的两次手动运行中得到候选,才能交给
`build_reconciliation_baseline_candidate.py`。工作流的成功只说明读取和收据格式正常;
候选仍可能因为未配置预期摘要或账本差异而正确保持阻断。

其中现金摘要只选择结算/账面现金标签(例如 `CashBalance`),不会把随市价变化的
`NetLiquidation` 或保证金可用额计入。这样没有现金、订单或仓位变化的账户不会因为正常
估值波动而被误判为基线漂移;没有可靠现金标签时仍会失败关闭。
73 changes: 73 additions & 0 deletions tests/test_broker_reconciliation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down