diff --git a/docs/digest-candidates-wiring.zh-CN.md b/docs/digest-candidates-wiring.zh-CN.md index 1fa7459a..aa37ba38 100644 --- a/docs/digest-candidates-wiring.zh-CN.md +++ b/docs/digest-candidates-wiring.zh-CN.md @@ -10,6 +10,9 @@ GitHub workflow 成功结论只是**存在性**辅助证据;成交/订单数未知时必须显示「未知」,不得写成 0。 +IBKR 的 USD 期末已结算现金从各账户的私有 Flex 摘要对象读取。日报核对账户、目标与报表日期, +并显示实际报表日期。现金摘要缺失时显示未知,交易和成交覆盖仍按原候选记录计算。 + ## JSON Schema 归档 正式合同文件:[`schemas/qsl-digest-candidates.v1.schema.json`](../schemas/qsl-digest-candidates.v1.schema.json)(`schema_version` = `qsl.digest_candidates.v1`)。本文件示例与平台 producer 文档必须与该 schema 对齐;未知成交数保持 `null` + `field_status.counts_unknown`。 diff --git a/python/scripts/account_daily_digest.py b/python/scripts/account_daily_digest.py index a6bfc2a0..253b50ac 100644 --- a/python/scripts/account_daily_digest.py +++ b/python/scripts/account_daily_digest.py @@ -303,6 +303,11 @@ def strategy_for(record): asset_line = ('💰 总资产:' if zh else '💰 Total assets: ') + equity if stamp_label: asset_line += ' · ' + stamp_label + cash_lines = [] + if platform == 'ibkr': + cash = account_fact.get('settled_cash') + value = f"USD {Decimal(cash['amount']):,f} · {cash['statement_date']}" if cash else unknown + cash_lines.append(('期末已结算现金:' if zh else 'Period-end settled cash: ') + value) holding_label = '📦 持仓:' if zh else '📦 Holdings: ' if not holdings: holding_label += ('空仓' if zh else 'No holdings') if complete else unknown @@ -363,7 +368,7 @@ def strategy_for(record): state = '⚠️ 昨日交易情况未确认' if zh else '⚠️ Trading activity unconfirmed' if not full_scope and any(count is not None for count in counts.values()): state += '(已核验策略范围)' if zh else ' (verified strategy scope)' - blocks.append((header, [state, ('|' if zh else ' | ').join(count_lines), filled_orders, asset_line, holding_label], + blocks.append((header, [state, ('|' if zh else ' | ').join(count_lines), filled_orders, asset_line, *cash_lines, holding_label], details, holding_lines)) lines = [('📊 昨日账户日报|' if zh else '📊 Daily account report | ') + payload.business_day, '🕒 ' + _window_text(payload.business_day, locale)] diff --git a/python/scripts/cloud_run_daily_digest.py b/python/scripts/cloud_run_daily_digest.py index c08850cd..6790053e 100644 --- a/python/scripts/cloud_run_daily_digest.py +++ b/python/scripts/cloud_run_daily_digest.py @@ -252,9 +252,43 @@ def _sources(configuration, store, day, end, *, now, monotonic): except (ValueError, TypeError, KeyError, AttributeError): outcome = 'invalid' outcomes.append(outcome) + for binding in configuration.expected: + if binding['platform_id'] != 'ibkr': + continue + timeout = min(20, budget_end - monotonic(), (end - now()).total_seconds()) + if timeout <= 0: + break + try: + cash = store.candidate(f"ibkr/flex-cash/{binding['candidate_slot']}.json", timeout) + if cash is not None: + stale_observations.append(_ibkr_settled_cash(cash, binding, day)) + except (StoreUnavailable, ValueError, TypeError, KeyError, AttributeError): + continue return rows, outcomes, stale_observations +@dataclass(frozen=True) +class _SettledCashObservation: + account_uid: str + amount: str + statement_date: str + + +def _ibkr_settled_cash(raw, binding, day): + if (not isinstance(raw, dict) + or set(raw) != {'schema_version', 'target_id', 'opaque_account_uid', 'statement_date', 'currency', 'amount'} + or raw['schema_version'] != 'ibkr_digest_settled_cash.v1' + or raw['currency'] != 'USD' + or any(raw[name] != binding[name] for name in ('target_id', 'opaque_account_uid')) + or not isinstance(raw['amount'], str) or len(raw['amount']) > 64 + or not re.fullmatch(r'-?[0-9]+(?:\.[0-9]+)?', raw['amount'])): + raise ValueError('digest_ibkr_settled_cash_invalid') + statement_day = date.fromisoformat(raw['statement_date']).isoformat() + if statement_day != raw['statement_date'] or statement_day > day: + raise ValueError('digest_ibkr_settled_cash_date_invalid') + return _SettledCashObservation(binding['opaque_account_uid'], raw['amount'], statement_day) + + def _report(configuration, rows, outcomes, day, platform_config, *, stale_observations=()): config = deepcopy(platform_config or aggregator.load_platform_config()) config['notifications']['quant_sentinel']['daily_digest'].setdefault('aggregator', {})['expected_targets'] = configuration.expected @@ -273,6 +307,11 @@ def _report(configuration, rows, outcomes, day, platform_config, *, stale_observ covered = sum(value in {'present', 'not_run', 'snapshot_only'} for value in outcomes) coverage = 'complete' if covered == len(outcomes) else 'partial' if covered else 'unavailable' facts = collect_account_facts([*rows, *stale_observations], configuration.expected, day) + for observation in stale_observations: + if isinstance(observation, _SettledCashObservation): + facts['ibkr', observation.account_uid]['settled_cash'] = { + 'amount': observation.amount, 'statement_date': observation.statement_date, + } if coverage == 'complete' and (payload.notification_summary or {}).get('counts_unknown'): coverage = 'partial' platforms = dict.fromkeys((*adapter.PLATFORMS, *config.get('platforms', {}))) diff --git a/python/tests/test_cloud_run_daily_digest.py b/python/tests/test_cloud_run_daily_digest.py index aa8145fb..e912af34 100644 --- a/python/tests/test_cloud_run_daily_digest.py +++ b/python/tests/test_cloud_run_daily_digest.py @@ -220,7 +220,7 @@ def test_account_snapshot_keeps_independently_verified_fields(self): for part in excluded: self.assertNotIn(part, self.sent[0]) - def test_ibkr_account_report_uses_only_the_existing_candidate(self): + def test_ibkr_account_report_reads_cash_without_changing_run_coverage(self): from dataclasses import replace binding = {**EXPECTED[0], 'platform_id': 'ibkr'} configuration = replace(self.enable(), expected=[binding]) @@ -231,11 +231,15 @@ def test_ibkr_account_report_uses_only_the_existing_candidate(self): row = {**ROW, 'platform_id': 'ibkr', 'fill_count': None, 'account_daily_facts': {'snapshots': [snapshot]}} row.pop('notification_event') - self.store = MemoryStore({key: {'runs': [row]}}) + cash_key = f"ibkr/flex-cash/{binding['candidate_slot']}.json" + cash = {'schema_version': 'ibkr_digest_settled_cash.v1', 'currency': 'USD', 'amount': '0.00', + 'statement_date': '2026-10-14', 'target_id': binding['target_id'], + 'opaque_account_uid': binding['opaque_account_uid']} + self.store = MemoryStore({key: {'runs': [row]}, cash_key: cash}) read = self.store.candidate def ordinary_candidate(requested, timeout): - self.assertEqual(requested, key) + self.assertIn(requested, (key, cash_key)) return read(requested, timeout) self.store.candidate = ordinary_candidate @@ -247,10 +251,21 @@ def ordinary_candidate(requested, timeout): self.assertEqual(self.run_request(configuration=configuration, headers=headers)[1]['delivery'], 'confirmed') self.assertEqual(len(self.sent), 1) self.assertIn('总资产:USD 250.00', self.sent[0]) + self.assertIn('期末已结算现金:USD 0.00 · 2026-10-14', self.sent[0]) self.assertIn('TQQQ 3股', self.sent[0]) self.assertIn('成交回报 未知', self.sent[0]) self.assertNotIn('成交回报 0笔', self.sent[0]) + def test_ibkr_cash_rejects_wrong_identity_future_date_and_non_decimal(self): + binding = {**EXPECTED[0], 'platform_id': 'ibkr'} + cash = {'schema_version': 'ibkr_digest_settled_cash.v1', 'currency': 'USD', 'amount': '-12.34', + 'statement_date': '2026-10-15', 'target_id': binding['target_id'], + 'opaque_account_uid': binding['opaque_account_uid']} + for change in ({'opaque_account_uid': 'different'}, {'target_id': 'different'}, + {'statement_date': '2026-10-16'}, {'amount': '1e4'}, {'currency': 'HKD'}): + with self.subTest(change=change), self.assertRaises(ValueError): + app._ibkr_settled_cash({**cash, **change}, binding, '2026-10-15') + def test_locale_variants_are_normalized_at_the_production_boundary(self): from dataclasses import replace for locale, expected in (("zh", "zh"), ("zh-CN", "zh"), (" ZH_hans ", "zh"),