diff --git a/application/ibkr_flex_ledger.py b/application/ibkr_flex_ledger.py
index 15d8cd4c..a3885275 100644
--- a/application/ibkr_flex_ledger.py
+++ b/application/ibkr_flex_ledger.py
@@ -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",
@@ -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,
diff --git a/application/ibkr_flex_source.py b/application/ibkr_flex_source.py
index 55c88e48..8612f3e9 100644
--- a/application/ibkr_flex_source.py
+++ b/application/ibkr_flex_source.py
@@ -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}
@@ -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
diff --git a/docs/ibkr-flex-ledger.zh-CN.md b/docs/ibkr-flex-ledger.zh-CN.md
index 4fb140a9..55cdd896 100644
--- a/docs/ibkr-flex-ledger.zh-CN.md
+++ b/docs/ibkr-flex-ledger.zh-CN.md
@@ -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` 时仍不请求。
diff --git a/scripts/diagnose_ibkr_flex_ledger.py b/scripts/diagnose_ibkr_flex_ledger.py
index 12e97c94..6238295c 100644
--- a/scripts/diagnose_ibkr_flex_ledger.py
+++ b/scripts/diagnose_ibkr_flex_ledger.py
@@ -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"])
@@ -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.
diff --git a/tests/test_flex_configured_coverage.py b/tests/test_flex_configured_coverage.py
index 83c66c06..4924b3b4 100644
--- a/tests/test_flex_configured_coverage.py
+++ b/tests/test_flex_configured_coverage.py
@@ -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''
+ )
+ report = REPORT.replace(
+ b'', b'' + 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
@@ -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):