Skip to content

Commit 13de5f2

Browse files
authored
Merge pull request #438 from QuantStrategyLab/fix/ibkr-stable-cash-reconciliation
fix: exclude dynamic valuation tags from IBKR cash evidence
2 parents 38aa210 + 967c707 commit 13de5f2

3 files changed

Lines changed: 99 additions & 5 deletions

File tree

application/broker_reconciliation.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ class IBKRReconciliationReadError(RuntimeError):
3939
"local_execution_ledger_sha256",
4040
)
4141

42+
# These are actual cash-balance tags, in the same order as the execution
43+
# snapshot's cash selector. Margin capacity and mark-to-market account tags
44+
# (for example AvailableFunds and NetLiquidation) must never enter a recovery
45+
# digest: they legitimately move while no cash, order, or position changes.
46+
_CASH_BALANCE_TAG_PRIORITY = (
47+
"$LEDGER-CashBalance",
48+
"$LEDGER-TotalCashBalance",
49+
"CashBalance",
50+
"TotalCashBalance",
51+
"SettledCash",
52+
)
53+
4254

4355
def _text(value: object) -> str:
4456
return str(value or "").strip()
@@ -125,11 +137,16 @@ def _normalise_cash_balance(value: Mapping[str, object], *, selected_account_ids
125137
for tag, number in value.items()
126138
if _text(tag) not in {"account_id", "currency"}
127139
}
128-
return {
129-
"account": account_id,
130-
"currency": _text(value.get("currency")).upper(),
131-
"tags": dict(sorted(numeric_tags.items())),
132-
}
140+
for tag in _CASH_BALANCE_TAG_PRIORITY:
141+
if tag in numeric_tags:
142+
return {
143+
"account": account_id,
144+
"currency": _text(value.get("currency")).upper(),
145+
"tags": {tag: numeric_tags[tag]},
146+
}
147+
raise IBKRReconciliationReadError(
148+
"IBKR reconciliation is missing a stable cash-balance tag."
149+
)
133150

134151

135152
def _open_trade_account(trade: Any) -> object:

docs/ibkr_reconciliation_baseline_enrollment.zh-CN.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,7 @@ Scheduler 任务,再由既有的最小权限 Scheduler 身份调用冻结服
104104
同一目标至少应在相隔一分钟的两次手动运行中得到候选,才能交给
105105
`build_reconciliation_baseline_candidate.py`。工作流的成功只说明读取和收据格式正常;
106106
候选仍可能因为未配置预期摘要或账本差异而正确保持阻断。
107+
108+
其中现金摘要只选择结算/账面现金标签(例如 `CashBalance`),不会把随市价变化的
109+
`NetLiquidation` 或保证金可用额计入。这样没有现金、订单或仓位变化的账户不会因为正常
110+
估值波动而被误判为基线漂移;没有可靠现金标签时仍会失败关闭。

tests/test_broker_reconciliation.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,79 @@ def fetch_portfolio_snapshot(_ib, **kwargs):
120120
assert observations.open_orders[0]["account"] == "U123"
121121

122122

123+
def test_cash_reconciliation_ignores_dynamic_margin_and_valuation_tags() -> None:
124+
def fetch_snapshot(_ib, *, dynamic_net_liquidation: float, dynamic_available_funds: float, **_kwargs):
125+
return SimpleNamespace(
126+
positions=(),
127+
metadata={
128+
"cash_balances": (
129+
{
130+
"account_id": "U123",
131+
"currency": "USD",
132+
"CashBalance": 123.45,
133+
"AvailableFunds": dynamic_available_funds,
134+
"NetLiquidation": dynamic_net_liquidation,
135+
},
136+
),
137+
"option_positions": (),
138+
},
139+
)
140+
141+
ib = _IB()
142+
first = collect_read_only_reconciliation_observations(
143+
ib,
144+
account_ids=("U123",),
145+
fetch_portfolio_snapshot=lambda *args, **kwargs: fetch_snapshot(
146+
*args,
147+
**kwargs,
148+
dynamic_net_liquidation=1_000.0,
149+
dynamic_available_funds=700.0,
150+
),
151+
market_currency="USD",
152+
cash_only_execution=True,
153+
)
154+
second = collect_read_only_reconciliation_observations(
155+
ib,
156+
account_ids=("U123",),
157+
fetch_portfolio_snapshot=lambda *args, **kwargs: fetch_snapshot(
158+
*args,
159+
**kwargs,
160+
dynamic_net_liquidation=1_050.0,
161+
dynamic_available_funds=750.0,
162+
),
163+
market_currency="USD",
164+
cash_only_execution=True,
165+
)
166+
167+
assert first.cash == second.cash == (
168+
{"account": "U123", "currency": "USD", "tags": {"CashBalance": 123.45}},
169+
)
170+
171+
172+
def test_cash_reconciliation_fails_closed_without_a_cash_balance_tag() -> None:
173+
with pytest.raises(IBKRReconciliationReadError, match="stable cash-balance tag"):
174+
collect_read_only_reconciliation_observations(
175+
_IB(),
176+
account_ids=("U123",),
177+
fetch_portfolio_snapshot=lambda *_args, **_kwargs: SimpleNamespace(
178+
positions=(),
179+
metadata={
180+
"cash_balances": (
181+
{
182+
"account_id": "U123",
183+
"currency": "USD",
184+
"AvailableFunds": 700.0,
185+
"NetLiquidation": 1_000.0,
186+
},
187+
),
188+
"option_positions": (),
189+
},
190+
),
191+
market_currency="USD",
192+
cash_only_execution=True,
193+
)
194+
195+
123196
def test_missing_read_only_order_surface_fails_closed() -> None:
124197
class MissingOpenOrderReader(_IB):
125198
reqAllOpenOrders = None

0 commit comments

Comments
 (0)