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
6 changes: 4 additions & 2 deletions application/ibkr_flex_ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@
"endingSettledCash_missing_or_invalid"})


def _period_cash_facts(statement: ElementTree.Element, *, source_ref: str, period: Mapping[str, Any]) -> dict[str, Any]:
def build_flex_period_cash_facts(
statement: ElementTree.Element, *, source_ref: str, period: Mapping[str, Any]
) -> dict[str, Any]:
"""Private native period-end cash, never a current spending balance."""
account = statement.get("accountId", "").strip()
result = {"schema_version": "ibkr.flex_period_cash.v1", "status": "incomplete",
Expand Down Expand Up @@ -638,7 +640,7 @@ def build_flex_ledger(
"cashflow_source_diagnostic": _cashflow_source_diagnostic(statement),
"nav_source_diagnostic": _nav_source_diagnostic(statement),
"cash_report_source_diagnostic": _cash_report_source_diagnostic(statement),
"period_cash_facts": _period_cash_facts(statement, source_ref=report.content_sha256, period=period),
"period_cash_facts": build_flex_period_cash_facts(statement, source_ref=report.content_sha256, period=period),
"account_ids": [account_id],
"currency": declared_currency,
"account_base_currency": base_currency,
Expand Down
52 changes: 42 additions & 10 deletions application/ibkr_flex_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,16 +480,23 @@ def diagnose_configured_flex_coverage(
ledger = None
if statement is not None:
# Reuse Activity-report, unique-account and cross-account row checks.
ledger = build_flex_ledger(report, expected_account_ids=(account,),
allowed_report_account_ids=allowed)
native_rows = set(CONFIGURED_COVERAGE_SECTIONS) | {
name for name in CONFIGURED_COVERAGE_SECTIONS.values() if name is not None}
if any(row.get("accountId", "").strip() not in {"", account}
for row in statement.iter() if row.tag in native_rows):
error = FlexSourceError("IBKR Flex statement row account scope mismatch",
endpoint="GetStatement", category="scope")
error.safe_diagnostic["nested_account_mismatch"] = True
raise error
try:
ledger = build_flex_ledger(report, expected_account_ids=(account,),
allowed_report_account_ids=allowed)
native_rows = set(CONFIGURED_COVERAGE_SECTIONS) | {
name for name in CONFIGURED_COVERAGE_SECTIONS.values() if name is not None}
if any(row.get("accountId", "").strip() not in {"", account}
for row in statement.iter() if row.tag in native_rows):
error = FlexSourceError("IBKR Flex statement row account scope mismatch",
endpoint="GetStatement", category="scope")
error.safe_diagnostic["nested_account_mismatch"] = True
raise error
except FlexSourceError as error:
if error.safe_diagnostic.get("nested_account_mismatch") is True:
error.safe_diagnostic["cash_report_accounts"] = _configured_cash_accounts(
report, statements, configured_account_ids,
)
raise
period = {key: ledger["period"][key] for key in ("from", "to")}
if period["from"] and period["to"] and period["from"] > period["to"]:
period = {"from": None, "to": None}
Expand All @@ -514,3 +521,28 @@ def diagnose_configured_flex_coverage(
return {"schema_version": "ibkr_flex_configured_coverage.v1", "status": "observed_source_shape",
"source_ref": report.content_sha256, "period": accounts["primary"]["period"],
"coverage": "not_evaluated", "accounts": accounts}


def _configured_cash_accounts(report, statements, configured_account_ids):
"""Check only CashReport after the report-level account scope has been verified."""
from application.ibkr_flex_executions import _day
from application.ibkr_flex_ledger import build_flex_period_cash_facts

accounts = {}
for slot in CONFIGURED_COVERAGE_SLOTS:
account = configured_account_ids[slot]
statement = statements.get(account)
period = {"from": None, "to": None}
facts = {"status": "incomplete", "missing": ["statement_missing"]}
if statement is not None:
period = {key: _day(statement.get(key + "Date")) for key in period}
if period["from"] and period["to"] and period["from"] > period["to"]:
period = {"from": None, "to": None}
if any(row.get("accountId", "").strip() not in {"", account}
for section in statement.findall("CashReport") for row in section.iter()):
facts["missing"] = ["cash_row_account_unverified"]
else:
cash = build_flex_period_cash_facts(statement, source_ref=report.content_sha256, period=period)
facts = {key: cash[key] for key in ("status", "missing")}
accounts[slot] = {"statement_present": statement is not None, "period": period, "period_cash": facts}
return accounts
3 changes: 3 additions & 0 deletions docs/ibkr-flex-ledger.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ null;存在的 section 按已识别原生记录计数,可为 0。未知子
不证明收益可算、数据完整或账户已发布。该路径不调用业务投影 POST,不保存 XML、
领取句柄或原生账户 ID,也不改变原期间收益发布器及调度。

报表账户集合通过校验、但某条记录的账号不一致时,完整诊断仍失败。错误回执可另带
`cash_report_accounts`,逐户核对现金段账号、期间和原生 USD 行,只报告固定槽、日期和期末现金可用状态。

继续使用原 `scripts.diagnose_ibkr_flex_ledger`,不经过期间收益发布器,也不要求 QRS
target、source binding 或同步令牌。无参数调用保持原来的一次生成、一次领取;默认
`IBKR_FLEX_DIAGNOSTIC_ENABLED` 未精确设为 `true` 时仍不请求。
Expand Down
39 changes: 39 additions & 0 deletions scripts/diagnose_ibkr_flex_ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,37 @@ def _validated_cash_report(value: object) -> dict[str, Any]:
}


def _validated_cash_report_accounts(value: object) -> dict[str, Any] | None:
if not isinstance(value, dict) or set(value) != set(CONFIGURED_COVERAGE_SLOTS):
return None
accounts = {}
for slot in CONFIGURED_COVERAGE_SLOTS:
row = value[slot]
if not isinstance(row, dict) or type(row.get("statement_present")) is not bool:
return None
present, period, facts = row["statement_present"], row.get("period"), row.get("period_cash")
try:
safe_period = {key: date.fromisoformat(period[key]).isoformat() if period[key] is not None else None
for key in ("from", "to")}
except (KeyError, TypeError, ValueError):
return None
if (safe_period != period or (period["from"] and period["to"] and period["from"] > period["to"])
or (not present and any(period.values())) or not isinstance(facts, dict)
or facts.get("status") not in ("available_period_end", "incomplete")):
return None
missing = facts.get("missing")
allowed = CASH_REPORT_MISSING_CODES if present else {"statement_missing"}
if (not isinstance(missing, list)
or any(not isinstance(code, str) or code not in allowed for code in missing)
or (facts["status"] == "available_period_end") != (not missing)
or (not present and missing != ["statement_missing"])
or (not missing and not all(period.values()))):
return None
accounts[slot] = {"statement_present": present, "period": safe_period,
"period_cash": {"status": facts["status"], "missing": sorted(set(missing))}}
return accounts


def _validated_cashflow_report(value: object) -> dict[str, Any]:
"""Rebuild fixed public field/format counts; never forward dynamic XML keys."""
invalid = _empty_report(status="error", missing=["flex_cashflow_diagnostic_result_invalid"])
Expand Down Expand Up @@ -588,6 +619,14 @@ def main(argv: Sequence[str] = ()) -> int:
except FlexSourceError as error:
report = _empty_report(status="error", missing=["flex_import_failed"])
report.update(_safe_source_failure(error.safe_diagnostic))
if coverage and report.get("nested_account_mismatch") is True:
accounts = _validated_cash_report_accounts(error.safe_diagnostic.get("cash_report_accounts"))
if accounts is not None and (
expected_period is None or all(
not row["statement_present"] or row["period"] == expected_period for row in accounts.values()
)
):
report["cash_report_accounts"] = accounts
except FlexExecutionsError as error:
# Only these literal codes belong to this pure diagnostic. No str(error),
# traceback, class name or native value may enter the public receipt.
Expand Down
29 changes: 29 additions & 0 deletions tests/test_flex_configured_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,33 @@ def test_corporate_actions_container_and_row_cannot_cross_account_scope(monkeypa
assert 'DU000000' not in text


def test_scope_failure_preserves_only_independently_verified_cash_sections(monkeypatch, capsys):
cash = (
b'<CashReport><CashReportCurrency accountId="DU0000002" model="" currency="USD" '
b'fromDate="20260101" toDate="20260131" endingCash="7654.9876" endingSettledCash="0"/></CashReport>'
)
report = REPORT.replace(
b'<CashTransactions/>', b'<CashTransactions><CashTransaction accountId="DU0000003"/></CashTransactions>' + cash
)
status, result, text, session = run(monkeypatch, capsys, report)
assert status == 1 and result['nested_account_mismatch']
accounts = result['cash_report_accounts']
assert accounts['additional-2'] == {
'statement_present': True,
'period': {'from': '2026-01-01', 'to': '2026-01-31'},
'period_cash': {'status': 'available_period_end', 'missing': []},
}
assert accounts['additional-1']['period_cash']['missing'] == ['statement_missing']
assert [name for name, _ in session.calls] == ['SendRequest', 'GetStatement']
assert '7654.9876' not in text and 'DU000000' not in text

wrong_account = cash.replace(b'DU0000002', b'DU0000003')
_, result, _, _ = run(monkeypatch, capsys, report.replace(cash, wrong_account))
assert result['cash_report_accounts']['additional-2']['period_cash'] == {
'status': 'incomplete', 'missing': ['cash_row_account_unverified'],
}


@pytest.mark.parametrize('change', ['unknown', 'duplicate', 'primary_absent', 'row_mismatch', 'wrong_type'])
def test_report_scope_and_type_never_relaxed(monkeypatch, capsys, change):
report = REPORT
Expand All @@ -119,6 +146,8 @@ def test_report_scope_and_type_never_relaxed(monkeypatch, capsys, change):
assert status == 1 and out['missing'] == ['flex_import_failed']
assert [name for name, _ in session.calls] == ['SendRequest', 'GetStatement']
assert 'DU000000' not in text and 'PRIVATE_CANARY' not in text
if change in {'unknown', 'duplicate', 'primary_absent', 'wrong_type'}:
assert 'cash_report_accounts' not in out


def test_pending_only_collects_original_request(monkeypatch, capsys):
Expand Down
Loading