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
18 changes: 12 additions & 6 deletions docs/unified-daily-notification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
64 changes: 50 additions & 14 deletions python/scripts/account_daily_digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -162,17 +179,19 @@ 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')
if any(row.status == 'alert' for row in rows):
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'
Expand Down Expand Up @@ -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,
Expand All @@ -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):
Expand All @@ -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'
Expand All @@ -288,14 +316,22 @@ 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)]
if all_idle:
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
Expand All @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion python/scripts/cloud_run_daily_digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
3 changes: 3 additions & 0 deletions python/tests/fixtures/daily-caller-en.txt
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions python/tests/fixtures/daily-caller-zh.txt
Original file line number Diff line number Diff line change
@@ -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笔
💰 总资产:未知
📦 持仓:未知
57 changes: 55 additions & 2 deletions python/tests/test_cloud_run_daily_digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading