From ae33b0cffdd2238002efa0036fd5e2b27c0c16be Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 12 Nov 2026 06:28:10 +0800 Subject: [PATCH] fix: Verify Flex cash coverage after unrelated scope failures Co-Authored-By: Codex --- application/ibkr_flex_ledger.py | 6 ++- application/ibkr_flex_source.py | 52 +++++++++++++++++++++----- docs/ibkr-flex-ledger.zh-CN.md | 3 ++ scripts/diagnose_ibkr_flex_ledger.py | 39 +++++++++++++++++++ tests/test_flex_configured_coverage.py | 29 ++++++++++++++ 5 files changed, 117 insertions(+), 12 deletions(-) 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):