diff --git a/docs/unified-daily-notification.md b/docs/unified-daily-notification.md index c72a5291..08f6ebb9 100644 --- a/docs/unified-daily-notification.md +++ b/docs/unified-daily-notification.md @@ -78,18 +78,24 @@ of the five sections. “Yesterday” is the existing completed reporting window with the end excluded for execution events. The report groups bound strategies under the same platform and account, retains configured safe labels, and shows available assets and positions even for verified idle accounts. Source -gaps are noted beside the affected account. No-run reports establish only that -the specified strategies did not run. A zero count is scoped to verified -observations. Distinct executions are deduplicated and need their own timestamp -to be attributed to the reporting window; a settlement update is not a fill time. +gaps and evidence failures are noted beside the affected account. Platforms +without an approved binding are listed separately. The management console +summarizes platform data. No-run reports establish only that the specified +strategies did not run. A zero count is scoped to verified observations. +The report separates order submissions, fill reports and orders with fills. +Several execution records can refer to one order. The count of distinct orders +with fills is an observed minimum when the execution inventory is incomplete. +Executions need their own timestamp for window attribution. A settlement update +does not establish the fill time. Candidate runs may include the optional `account_daily_facts` object for order, fill, and account snapshot observations. Existing candidates without this field remain valid; its presence alone does not establish account-wide coverage. The first release reads the existing bound candidates only. It reports timed fills found in those candidates. A fill stored solely under another business day or in a separate supplemental object is outside the observed scope. -Assets and positions come from one bound observation; an earlier snapshot is -dated and partial positions are labelled as such. Missing assets or positions +Assets and positions come from one bound observation. An earlier snapshot is +dated, legacy observations without a snapshot time are labelled, and partial +positions are labelled as such. Missing assets or positions do not suppress the other verified facts from the same observation. Unavailable positions and confirmed empty positions remain distinct. The single-message budget preserves account summaries and states how many positions are shown and omitted. diff --git a/python/scripts/account_daily_digest.py b/python/scripts/account_daily_digest.py index 81d1b8c6..e9e64826 100644 --- a/python/scripts/account_daily_digest.py +++ b/python/scripts/account_daily_digest.py @@ -7,12 +7,21 @@ from decimal import Decimal from types import SimpleNamespace -from notification_snapshot.unified_contract import SGT, sgt_business_window from account_daily_facts import SIMULATION_MODES from daily_digest_notify import normalize_locale from display_names import broker_account_identity, display_name, safe_display_text -from unified_daily_digest import (_account_identity_label, _execution_blocked, _execution_unconfirmed, _known_count, - _run_display_contexts, _units, _verified_no_trade, _window_text) +from notification_snapshot.unified_contract import SGT, sgt_business_window +from unified_daily_digest import ( + _account_identity_label, + _evidence_attention_text, + _execution_blocked, + _execution_unconfirmed, + _known_count, + _run_display_contexts, + _units, + _verified_no_trade, + _window_text, +) def _number(value): @@ -70,7 +79,15 @@ def _order_status(order): return str(order.get('status') or '').strip().lower() -def render_account_daily(payload, expected, outcomes, *, facts=None, max_units=3850): +def _count_value(count, observed, zh): + if count is not None: + return f'{count}笔' if zh else str(count) + if observed: + return f'已见 {observed} 笔,总数未知' if zh else f'{observed} observed, total unknown' + return '未知' if zh else 'unknown' + + +def render_account_daily(payload, expected, outcomes, *, facts=None, known_platforms=(), max_units=3850): """Render supplied raw binding outcomes and optional per-account evidence. ``facts`` is keyed by (platform_id, opaque_account_uid). Order and fill IDs @@ -162,10 +179,7 @@ def render_account_daily(payload, expected, outcomes, *, facts=None, max_units=3 else: count = None counts[name] = count - if count is None: - value = (f'已见 {len(records)} 笔,总数未知' if zh else f'{len(records)} observed, total unknown') if records else unknown - else: - value = f'{count}笔' if zh else str(count) + value = _count_value(count, len(records), zh) count_lines.append(f'{label} {value}' if zh else f'{label}: {value}') if any(_execution_blocked(row) for row in rows): details.append('执行已拦截' if zh else 'Execution blocked') @@ -173,6 +187,11 @@ def render_account_daily(payload, expected, outcomes, *, facts=None, max_units=3 details.append('执行异常' if zh else 'Execution issue') if any(_execution_unconfirmed(row) for row in rows): details.append('订单最终状态待确认' if zh else 'Final order state unconfirmed') + details.extend(dict.fromkeys( + _evidence_attention_text(row, locale) for row in rows + if row.evidence_persistence_status == 'persistence_failed' or row.evidence_delivery_status == 'delivery_failed' + )) + def strategy_for(record): value = record.get('strategy') fallback = '策略未知' if zh else 'strategy unknown' @@ -232,7 +251,8 @@ def strategy_for(record): observed_equity, currency, observed_holdings, scope = next(iter(observations)) observed = _money(observed_equity, currency, unknown) if observed != unknown: - stamp_label = f'已报告资产 {observed},快照时间未知' if zh else f'Reported assets {observed}, snapshot time unknown' + stamp_label = (f'来源报告 {observed},快照时间未知' if zh else + f'Source reported {observed}, snapshot time unknown') elif observed_holdings: stamp_label = '持仓快照时间未知' if zh else 'Holding snapshot time unknown' holdings = [{'symbol': h.symbol, 'quantity': h.quantity, @@ -251,6 +271,14 @@ def strategy_for(record): ] # Every binding must have a matching run, not merely an outcome marker. orders, fills = account_fact.get('orders') or [], account_fact.get('fills') or [] + filled_order_ids = {fill['order_id'] for fill in fills if fill.get('order_id')} + filled_order_count = None + if counts['fill'] == 0: + filled_order_count = 0 + elif account_fact.get('fills_complete') is True and all(fill.get('order_id') for fill in fills): + filled_order_count = len(filled_order_ids) + filled_orders = ('成交订单 ' if zh else 'Orders with fills: ') + _count_value( + filled_order_count, len(filled_order_ids), zh) if counts['order'] is not None and counts['order'] > len(orders): details.append('部分委托明细未取得' if zh else 'Some order details are unavailable') if counts['fill'] is not None and counts['fill'] > len(fills): @@ -272,7 +300,7 @@ def strategy_for(record): if mixed_fill_window: details.append('委托累计成交跨统计窗口;本期仅列实际发生的成交' if zh else 'Order fills span reporting windows; only fills in this window are listed') - if partial and not legacy_fill_window_unreliable: + if partial and filled and not legacy_fill_window_unreliable: state = '✅ 部分成交' if zh else '✅ Partially filled' elif filled: state = '✅ 有交易' if zh else '✅ Trades recorded' @@ -288,7 +316,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), asset_line, holding_label], + blocks.append((header, [state, ('|' if zh else ' | ').join(count_lines), filled_orders, asset_line, holding_label], details, holding_lines)) lines = [('📊 昨日账户日报|' if zh else '📊 Daily account report | ') + payload.business_day, '🕒 ' + _window_text(payload.business_day, locale)] @@ -296,6 +324,14 @@ def strategy_for(record): lines.append('😴 全部已核验账户无交易(限已核验范围)' if zh else '😴 No trades in all verified accounts (within verified scope)') if not groups: lines.append('已绑定账户未知' if zh else 'Bound accounts unknown') + bound_platforms = {platform for platform, _ in groups} + unbound = [display_name('platforms', platform, locale) for platform in known_platforms + if platform != 'qrs' and platform not in bound_platforms] + if unbound: + lines.append(('未接入日报来源:' + '、'.join(unbound)) if zh else + ('Platforms without a digest source: ' + ', '.join(unbound))) + if 'qrs' in known_platforms and 'qrs' not in bound_platforms: + lines.append('管理站汇总平台数据' if zh else 'The management console summarizes platform data') # Reserve space for each fitting account header before adding details. def suffix(n): return (f'另有 {n} 项省略(持仓展示 {n} 项、省略 {n} 项)' if zh else @@ -310,13 +346,13 @@ def suffix(n): omitted += 1 + len(core) + len(details) + len(holdings) omitted_holdings += len(holdings) for index, (header, core, details, holdings) in enumerate(reserved): - lines.extend((header, *core[:2])) + lines.extend((header, *core[:3])) later = [line for h, c, _, _ in reserved[index + 1:] for line in [h, *c]] for group, is_holding in ((details, False), (holdings, True)): if is_holding: - lines.extend(core[2:]) + lines.extend(core[3:]) for detail in group: - remaining_core = [] if is_holding else core[2:] + remaining_core = [] if is_holding else core[3:] if _units('\n'.join(lines + [detail] + remaining_core + later + [suffix(upper_bound)])) <= max_units: lines.append(detail) else: diff --git a/python/scripts/cloud_run_daily_digest.py b/python/scripts/cloud_run_daily_digest.py index 4f9faf65..f9139e02 100644 --- a/python/scripts/cloud_run_daily_digest.py +++ b/python/scripts/cloud_run_daily_digest.py @@ -202,7 +202,8 @@ def _report(configuration, rows, outcomes, day, platform_config, *, stale_observ facts = collect_account_facts([*rows, *stale_observations], configuration.expected, day) if coverage == 'complete' and (payload.notification_summary or {}).get('counts_unknown'): coverage = 'partial' - text = render_account_daily(payload, configuration.expected, outcomes, facts=facts) + platforms = dict.fromkeys((*adapter.PLATFORMS, *config.get('platforms', {}))) + text = render_account_daily(payload, configuration.expected, outcomes, facts=facts, known_platforms=platforms) return text, coverage diff --git a/python/tests/fixtures/daily-caller-en.txt b/python/tests/fixtures/daily-caller-en.txt index f94dc0a3..8c38920c 100644 --- a/python/tests/fixtures/daily-caller-en.txt +++ b/python/tests/fixtures/daily-caller-en.txt @@ -1,7 +1,10 @@ 📊 Daily account report | 2026-10-15 🕒 Reporting window: 2026-10-15 06:00 – 2026-10-16 06:00 (UTC+8) +Platforms without a digest source: IBKR, LongBridge, Binance, Firstrade +The management console summarizes platform data 🏦 Schwab · Account · Unnamed strategy 😴 No trades (verified strategy scope) Orders submitted: 0 | Fill reports: 0 +Orders with fills: 0 💰 Total assets: unknown 📦 Holdings: unknown diff --git a/python/tests/fixtures/daily-caller-zh.txt b/python/tests/fixtures/daily-caller-zh.txt index 024d0e18..431566cd 100644 --- a/python/tests/fixtures/daily-caller-zh.txt +++ b/python/tests/fixtures/daily-caller-zh.txt @@ -1,7 +1,10 @@ 📊 昨日账户日报|2026-10-15 🕒 统计区间:2026-10-15 06:00 – 2026-10-16 06:00 (UTC+8) +未接入日报来源:IBKR、LongBridge、Binance、Firstrade +管理站汇总平台数据 🏦 Schwab · 账户 · 未命名策略 😴 无交易(已核验策略范围) 提交订单 0笔|成交回报 0笔 +成交订单 0笔 💰 总资产:未知 📦 持仓:未知 diff --git a/python/tests/test_cloud_run_daily_digest.py b/python/tests/test_cloud_run_daily_digest.py index 893960ad..4f2b508e 100644 --- a/python/tests/test_cloud_run_daily_digest.py +++ b/python/tests/test_cloud_run_daily_digest.py @@ -63,15 +63,68 @@ def send(self, text): self.sent.append(text) return 123 - def run_request(self, *, preview=False, headers=None, configuration=None, sender=None): + def run_request(self, *, preview=False, headers=None, configuration=None, sender=None, platform_config=None): return app.execute(headers or HEADERS, configuration or self.configuration, self.store, sender or self.send, preview=preview, now=lambda: self.now, - platform_config=config(EXPECTED)) + platform_config=platform_config or config(EXPECTED)) def enable(self): from dataclasses import replace return replace(self.configuration, enabled=True) + def test_report_lists_unbound_platforms_without_reading_them(self): + platform_config = {**config(EXPECTED), 'platforms': {'qmt': {}}} + read = self.store.candidate + + def only_bound_candidate(key, timeout): + self.assertEqual(key, 'schwab/2026-10-15/synthetic-slot.json') + return read(key, timeout) + + self.store.candidate = only_bound_candidate + self.run_request(configuration=self.enable(), platform_config=platform_config) + text = self.sent[0] + self.assertIn('未接入日报来源:IBKR、LongBridge、Binance、Firstrade、QMT', text) + self.assertIn('管理站汇总平台数据', text) + self.assertEqual(text.count('🏦 '), 1) + + def test_filled_orders_count_distinct_orders_and_preserve_unknown_totals(self): + key = next(iter(self.store.rows)) + fill = {'order_id': 'synthetic-order', 'occurred_at': '2026-10-15T22:00:00+08:00'} + fills = [{**fill, 'id': 'synthetic-fill-a'}, {**fill, 'id': 'synthetic-fill-b'}] + row = {**ROW, 'order_count': 1, 'fill_count': 2} + row.pop('notification_event') + for records, count in ((fills, '已见 1 笔,总数未知'), (fills[:1], '已见 1 笔,总数未知'), ([], '未知')): + with self.subTest(records=len(records)): + self.store = MemoryStore({key: {'runs': [ + {**row, 'account_daily_facts': {'fills': records}}, + ]}}) + self.sent = [] + self.run_request(configuration=self.enable()) + text = self.sent[0] + self.assertIn('成交回报 2笔', text) + self.assertIn(f'成交订单 {count}', text) + self.assertNotIn('synthetic-order', text) + + def test_report_keeps_execution_and_evidence_failures_distinct(self): + key = next(iter(self.store.rows)) + row = {**ROW, 'execution_status': 'blocked', 'evidence_persistence_status': 'persistence_failed', + 'evidence_delivery_status': 'delivery_failed'} + self.store = MemoryStore({key: {'runs': [row]}}) + self.run_request(configuration=self.enable()) + text = self.sent[0] + self.assertIn('执行已拦截', text) + self.assertIn('证据留存失败', text) + self.assertIn('证据传递失败', text) + + def test_legacy_reported_assets_retain_unknown_snapshot_time(self): + key = next(iter(self.store.rows)) + self.store = MemoryStore({key: {'runs': [ + {**ROW, 'equity': 250, 'equity_currency': 'USD', 'as_of': NOW.isoformat()}, + ]}}) + self.run_request(configuration=self.enable()) + self.assertIn('总资产:未知 · 来源报告 USD 250.00,快照时间未知', self.sent[0]) + self.assertIn('持仓:未知', self.sent[0]) + def test_account_snapshot_keeps_independently_verified_fields(self): position = {'symbol': 'TQQQ', 'asset_type': 'etf', 'quantity': 3} snapshot = {'as_of': '2026-10-16T05:10:00+08:00', 'equity': 250,