From f054b1e0b75dbc1cf13474966d3bc8d8f6f50843 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:50:11 +0800 Subject: [PATCH 1/4] feat: Simplify the account daily digest Co-Authored-By: Codex --- docs/unified-daily-notification.md | 44 ++- python/Dockerfile.digest | 2 + python/scripts/account_daily_digest.py | 322 ++++++++++++++++++ python/scripts/account_daily_facts.py | 205 +++++++++++ python/scripts/cloud_run_daily_digest.py | 66 ++-- python/scripts/daily_digest_aggregator.py | 3 + python/scripts/unified_daily_digest.py | 21 +- python/tests/fixtures/daily-caller-en.txt | 28 +- python/tests/fixtures/daily-caller-zh.txt | 28 +- python/tests/test_cloud_run_daily_digest.py | 102 +++++- .../tests/test_daily_caller_presentation.py | 290 +++++++++++----- python/tests/test_daily_digest_v3.py | 4 +- python/tests/test_digest_config_labels.py | 4 +- .../tests/test_notification_consolidation.py | 5 +- 14 files changed, 910 insertions(+), 214 deletions(-) create mode 100644 python/scripts/account_daily_digest.py create mode 100644 python/scripts/account_daily_facts.py diff --git a/docs/unified-daily-notification.md b/docs/unified-daily-notification.md index 369cc9cb..72b9b5a5 100644 --- a/docs/unified-daily-notification.md +++ b/docs/unified-daily-notification.md @@ -53,7 +53,7 @@ recipient/date delivery reservation or permit a second daily send. ## Unified operator display -The consolidated renderer uses the shared QPK five-section display contract: +The legacy Actions preview and operator notification renderers use the shared QPK five-section display contract: Result, Scope, Evidence, Coverage and Next step. Chinese and English render the same facts. The pure helper is vendored under `notification_snapshot` with its own source manifest. The manifest records the upstream revision and hash, @@ -66,23 +66,37 @@ multiple accounts share the same failure text. Budget selection keeps account summaries before optional observations and never retains an observation after omitting its account context. -Every daily report lists IBKR, Schwab, Firstrade, LongBridge, Binance and the +The legacy consolidated preview lists IBKR, Schwab, Firstrade, LongBridge, Binance and the management console. An explicitly unbound source says it is not included and its run state is unknown. A missing inventory says coverage is unknown. This display does not bind a new source, collect another platform, or make unknown -orders/fills zero. Existing totals cover bound targets only. The central Cloud Run caller presents -an unbound Binance inventory as disabled under the standing owner OFF decision: -runs, orders and fills are N/A. This display-only decision does not change any -switch or collect data. A missing inventory remains unknown; a bound Binance -target retains its observed coverage rather than being hidden by the OFF label. -Other unbound platforms retain unknown run state. - -The central caller and its deadline fallback use the same five sections, -including missing-source and verified-idle reports. Coverage details stay inside -the Coverage section. The deadline fallback keeps trades and assets unknown and -retains the existing single reservation and no-follow-up behavior. - -For each actual source run, a supplied `source_run_id` may already be a full +orders/fills zero. Existing totals cover bound targets only. + +The scheduled Cloud Run account report uses a compact account layout instead +of the five sections. “Yesterday” is the existing completed reporting window: +06:00 Singapore time on the reported business day through 06:00 the next day, +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. +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 +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. + +The account report omits runtime links and technical evidence. The compact +deadline fallback keeps trades, assets and holdings unknown and retains the +existing single reservation and no-follow-up behavior. Other notification +types retain their existing layouts and delivery contracts. + +In the legacy consolidated preview, a supplied `source_run_id` may already be a full Actions run URL. The renderer accepts that URL only when it matches the platform's configured repository and the canonical HTTPS GitHub Actions run path; query and fragment are stripped by the shared URL helper. It never diff --git a/python/Dockerfile.digest b/python/Dockerfile.digest index c5b047da..a64af38d 100644 --- a/python/Dockerfile.digest +++ b/python/Dockerfile.digest @@ -5,6 +5,8 @@ COPY python/requirements-digest.txt /app/requirements-digest.txt RUN pip install --no-cache-dir -r /app/requirements-digest.txt COPY platform-config.json display-names.config.json /app/ COPY python/scripts/cloud_run_daily_digest.py \ + python/scripts/account_daily_digest.py \ + python/scripts/account_daily_facts.py \ python/scripts/daily_digest_delivery_store.py \ python/scripts/daily_digest_aggregator.py \ python/scripts/daily_digest_delivery_axes.py \ diff --git a/python/scripts/account_daily_digest.py b/python/scripts/account_daily_digest.py new file mode 100644 index 00000000..ce2b3482 --- /dev/null +++ b/python/scripts/account_daily_digest.py @@ -0,0 +1,322 @@ +"""Render a single, account-oriented daily digest from supplied observations.""" +from __future__ import annotations + +import math +import re +from datetime import date, datetime, time +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 (_execution_blocked, _execution_unconfirmed, _known_count, + _run_display_contexts, _units, _verified_no_trade, _window_text) + + +def _number(value): + return type(value) in (int, float) and math.isfinite(value) + + +def _value(value, unknown): + if not _number(value): + return unknown + text = format(Decimal(str(value)), ',f') + return text.rstrip('0').rstrip('.') if '.' in text else text + + +def _quantity(value, asset, zh, unknown): + text = _value(value, unknown) + unit = {'stock': ('股', ' shares'), 'equity': ('股', ' shares'), 'etf': ('股', ' shares'), + 'option': ('张', ' contracts'), 'future': ('张', ' contracts'), + 'bond': ('张', ' bonds')}.get(str(asset or '').lower(), ('', ''))[0 if zh else 1] + return text + (unit if text != unknown else '') + + +def _money(value, currency, unknown): + if not _number(value) or not isinstance(currency, str) or not re.fullmatch(r'[A-Z]{3}', currency): + return unknown + text = _value(value, unknown) + if '.' not in text: + text += '.00' + elif len(text.rsplit('.', 1)[1]) == 1: + text += '0' + return f'{currency} {text}' + + +def _symbol(value, unknown): + value = str(value or '').strip() + return value if re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.\-^/]{0,19}', value) else unknown + + +def render_account_daily(payload, expected, outcomes, *, facts=None, 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 + are never displayed; they are not needed to describe the supplied records. + """ + if max_units < 256: + raise ValueError('digest_budget_too_small') + locale = normalize_locale(payload.locale) + zh = locale == 'zh' + unknown = '未知' if zh else 'unknown' + window = sgt_business_window(datetime.combine(date.fromisoformat(payload.business_day), time(6), SGT)) + facts = facts or {} + groups = {} + for i, binding in enumerate(expected): + key = (binding.get('platform_id', ''), binding.get('opaque_account_uid', '')) + group = groups.setdefault(key, {'bindings': [], 'rows': []}) + group['bindings'].append((binding, outcomes[i] if i < len(outcomes) else 'missing')) + for row in payload.runs: + key = (row.platform_id, row.opaque_account_uid) + if key in groups and any(row.target_id == b.get('target_id') and row.strategy_profile == b.get('strategy_profile') + for b, _ in groups[key]['bindings']): + groups[key]['rows'].append(row) + + representatives = [] + for (platform, uid), group in groups.items(): + if group['rows']: + representatives.append(group['rows'][0]) + else: + binding = group['bindings'][0][0] + representatives.append(SimpleNamespace(platform_id=platform, opaque_account_uid=uid, + target_id=binding.get('target_id', ''), strategy_profile=binding.get('strategy_profile', ''), + account_label=binding.get('account_label', ''), account_alias=binding.get('account_alias', ''), + account_hint=binding.get('account_hint', ''), account_scope=binding.get('account_scope', ''), + execution_mode=binding.get('execution_mode', ''))) + contexts = _run_display_contexts(representatives, locale, runtime_accounts=True) + all_idle = bool(groups) + blocks = [] + outcome_text = { + 'missing': ('报告缺失', 'Report missing'), 'stale': ('报告过期', 'Report stale'), + 'not_run': ('未运行', 'Not run'), 'read_failed': ('报告读取失败', 'Report read failed'), + 'invalid': ('报告无效', 'Report invalid'), + } + for ((platform, uid), group), (platform_label, account, _) in zip(groups.items(), contexts): + rows, bindings = group['rows'], group['bindings'] + actual_rows = [row for row in rows if row.execution_mode.strip().lower() not in SIMULATION_MODES] + has_simulation = len(actual_rows) != len(rows) + rows = actual_rows + strategies = [] + for binding, _ in bindings: + fallback = '未命名策略' if zh else 'Unnamed strategy' + name = safe_display_text(display_name('strategies', binding.get('strategy_profile', ''), locale, fallback=fallback)) or fallback + if name not in strategies: + strategies.append(name) + explicit_label = next((b['account_label'] for b, _ in bindings if safe_display_text(b.get('account_label'))), '') + if explicit_label and explicit_label not in {uid, *(b.get('account_hint') for b, _ in bindings), + *(b.get('target_id') for b, _ in bindings)}: + account = explicit_label + elif account in {uid, *(b.get('target_id') for b, _ in bindings)}: + account = '账户' if zh else 'Account' + elif broker_account_identity(platform, account): + account = ('账户' if zh else 'Account') if platform in {'binance', 'longbridge'} else '***' + account[-2:] + header = '🏦 ' + ' · '.join((platform_label, account, ' / '.join(strategies))) + details = [] + for binding, outcome in bindings: + if outcome != 'present' and not (outcome == 'not_run' and len(bindings) == 1): + fallback = '未命名策略' if zh else 'Unnamed strategy' + strategy = safe_display_text(display_name('strategies', binding.get('strategy_profile', ''), locale, fallback=fallback)) or fallback + text = outcome_text.get(outcome, ('状态未知', 'Status unknown'))[0 if zh else 1] + details.append(f'{strategy}:{text}' if zh else f'{strategy}: {text}') + if has_simulation: + details.append('模拟记录未计入实际交易' if zh else 'Simulated records excluded from actual trades') + account_fact = facts.get((platform, uid)) or {} + unknown_fill_window = account_fact.get('fills_window_unknown') is True + mixed_fill_window = account_fact.get('fills_window_mixed') is True + legacy_fill_window_unreliable = unknown_fill_window or mixed_fill_window + all_present = all(outcome == 'present' for _, outcome in bindings) + bound_rows = all(any(r.target_id == b.get('target_id') and r.strategy_profile == b.get('strategy_profile') for r in rows) + for b, _ in bindings) + verified_idle_rows = (all_present and bound_rows and bool(rows) and not legacy_fill_window_unreliable + and not account_fact.get('additional_fills') + and all(_verified_no_trade(row) for row in rows)) + counts = {} + count_lines = [] + for name, label in (('order', '提交订单' if zh else 'Orders submitted'), ('fill', '成交回报' if zh else 'Fill reports')): + plural = name + 's' + records = account_fact.get(plural) or [] + if account_fact.get(plural + '_complete') is True: + count = len(records) + elif verified_idle_rows and not records: + count = 0 + elif (all_present and bound_rows and len(rows) == 1 and _known_count(rows[0], name) + and getattr(rows[0], name + '_count') >= len(records) + and not (name == 'fill' and (legacy_fill_window_unreliable or account_fact.get('additional_fills') is True))): + count = getattr(rows[0], name + '_count') + 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) + 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') + def strategy_for(record): + value = record.get('strategy') + fallback = '策略未知' if zh else 'strategy unknown' + return safe_display_text(display_name('strategies', value, locale, fallback=fallback)) if value else fallback + + for order in account_fact.get('orders') or []: + if not isinstance(order, dict): + continue + status = str(order.get('status') or '').lower() + statuses = {'open': ('未完成', 'open'), 'cancelled': ('已取消', 'cancelled'), + 'canceled': ('已取消', 'cancelled'), 'rejected': ('已拒绝', 'rejected'), + 'expired': ('已过期', 'expired'), 'filled': ('已成交', 'filled'), + 'partially_filled': ('部分成交', 'partially filled'), 'pending': ('待确认', 'pending'), + 'submitted': ('已提交', 'submitted')} + status = statuses.get(status, (unknown, unknown))[0 if zh else 1] + side = {'buy': ('买入', 'buy'), 'sell': ('卖出', 'sell')}.get(str(order.get('side') or '').lower(), (unknown, unknown))[0 if zh else 1] + line = f"{'委托' if zh else 'Order'}: {side} {_symbol(order.get('symbol'), unknown)} {_quantity(order.get('quantity'), order.get('asset_type'), zh, unknown)} · {status}" + for field, label in (('filled_quantity', '累计成交' if zh else 'filled to date'), + ('remaining_quantity', '剩余' if zh else 'remaining')): + if field in order: + line += f" · {label} {_quantity(order[field], order.get('asset_type'), zh, unknown)}" + if order.get('price') is not None: + line += f" · {_money(order['price'], order.get('currency'), unknown)}" + if order.get('average_fill_price') is not None: + price = _money(order['average_fill_price'], order.get('currency'), unknown) + if price == unknown: + price = _value(order['average_fill_price'], unknown) + ('(币种未知)' if zh else ' (currency unknown)') + line += (' · 成交均价 ' if zh else ' · average fill price ') + price + details.append(line + ' · ' + strategy_for(order)) + for fill in account_fact.get('fills') or []: + if not isinstance(fill, dict): + continue + side = {'buy': ('买入', 'buy'), 'sell': ('卖出', 'sell')}.get(str(fill.get('side') or '').lower(), (unknown, unknown))[0 if zh else 1] + details.append(f"{side} {_symbol(fill.get('symbol'), unknown)} {_quantity(fill.get('quantity'), fill.get('asset_type'), zh, unknown)} @ {_money(fill.get('price'), fill.get('currency'), unknown)} · {strategy_for(fill)}") + snapshots = [] + for item in account_fact.get('snapshots') or []: + if not isinstance(item, dict): + continue + try: + stamp = datetime.fromisoformat(str(item.get('as_of') or '').replace('Z', '+00:00')) + if stamp.tzinfo is not None and stamp <= window.end: + snapshots.append((stamp, item)) + except ValueError: + pass + complete, holdings, equity, stamp_label = False, [], unknown, '' + if snapshots: + stamp, snapshot = max(snapshots, key=lambda pair: pair[0]) + stamp_label = ('较早快照 ' if zh else 'Earlier snapshot ') if stamp < window.start else ('快照 ' if zh else 'Snapshot ') + stamp_label += stamp.astimezone(SGT).strftime('%Y-%m-%d %H:%M UTC+8') + equity = _money(snapshot.get('equity'), snapshot.get('equity_currency'), unknown) + holdings = snapshot.get('holdings') or [] + complete = snapshot.get('holdings_complete') is True + else: + observations = {(row.equity, row.equity_currency, row.holdings, row.holdings_scope) + for row in rows if row.equity is not None or row.holdings} + if len(observations) == 1: + 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' + elif observed_holdings: + stamp_label = '持仓快照时间未知' if zh else 'Holding snapshot time unknown' + holdings = [{'symbol': h.symbol, 'quantity': h.quantity, + 'asset_type': 'stock' if scope == 'stocks_only' else None} for h in observed_holdings] + asset_line = ('💰 总资产:' if zh else '💰 Total assets: ') + equity + if stamp_label: + asset_line += ' · ' + stamp_label + holding_label = '📦 持仓:' if zh else '📦 Holdings: ' + if not holdings: + holding_label += ('空仓' if zh else 'No holdings') if complete else unknown + elif not complete: + holding_label += '仅列已核实持仓' if zh else 'Verified positions only' + holding_lines = [ + f"{_symbol(h.get('symbol'), unknown)} {_quantity(h.get('quantity'), h.get('asset_type'), zh, unknown)}" + for h in holdings + ] + # Every binding must have a matching run, not merely an outcome marker. + orders, fills = account_fact.get('orders') or [], account_fact.get('fills') or [] + 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): + details.append('部分成交明细未取得' if zh else 'Some fill details are unavailable') + account_idle = verified_idle_rows and not orders and not fills + full_scope = all(account_fact.get(name + '_complete') is True for name in ('orders', 'fills')) + all_idle = all_idle and account_idle and full_scope + filled = bool(fills) or (not legacy_fill_window_unreliable + and any(_known_count(row, 'fill') and row.fill_count > 0 for row in rows)) + pending = bool(orders) or any(_known_count(row, 'order') and row.order_count > 0 for row in rows) + partial = any(o.get('status') == 'partially_filled' or + (_number(o.get('filled_quantity')) and _number(o.get('quantity')) + and 0 < o['filled_quantity'] < o['quantity']) for o in orders) + terminal = orders and all(o.get('status') in {'cancelled', 'canceled', 'rejected', 'expired'} for o in orders) + if unknown_fill_window: + details.append('结算含已确认成交,发生时段未核实' if zh else + 'Settlement confirms fills; reporting window unverified') + 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: + state = '✅ 部分成交' if zh else '✅ Partially filled' + elif filled: + state = '✅ 有交易' if zh else '✅ Trades recorded' + elif terminal: + state = '委托已结束,详见状态' if zh else 'Orders closed; statuses below' + elif pending: + state = '⏳ 已提交,成交待确认' if zh else '⏳ Submitted, fills unconfirmed' + elif account_idle: + state = '😴 无交易' if zh else '😴 No trades' + elif all(outcome == 'not_run' for _, outcome in bindings): + state = '策略未运行,账户交易未知' if zh else 'Strategy did not run; account trades unknown' + else: + 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], + 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') + # Reserve space for each fitting account header before adding details. + def suffix(n): + return (f'另有 {n} 项省略(持仓展示 {n} 项、省略 {n} 项)' if zh else + f'{n} items omitted (holdings: {n} shown, {n} omitted)') + reserved, omitted, omitted_holdings = [], 0, 0 + upper_bound = sum(1 + len(core) + len(details) + len(holdings) for _, core, details, holdings in blocks) + for header, core, details, holdings in blocks: + prior = [line for h, c, _, _ in reserved for line in [h, *c]] + if _units('\n'.join(lines + prior + [header, *core, suffix(upper_bound)])) <= max_units: + reserved.append((header, core, details, holdings)) + else: + 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])) + 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:]) + for detail in group: + remaining_core = [] if is_holding else core[2:] + if _units('\n'.join(lines + [detail] + remaining_core + later + [suffix(upper_bound)])) <= max_units: + lines.append(detail) + else: + omitted += 1 + omitted_holdings += is_holding + if omitted: + shown = sum(len(holdings) for _, _, _, holdings in blocks) - omitted_holdings + ending = (f'(持仓展示 {shown} 项、省略 {omitted_holdings} 项)' if zh else + f' (holdings: {shown} shown, {omitted_holdings} omitted)') if omitted_holdings else '' + lines.append((f'另有 {omitted} 项省略' if zh else f'{omitted} items omitted') + ending) + return '\n'.join(lines) + + +def render_account_daily_unavailable(day, locale): + zh = normalize_locale(locale) == 'zh' + title = ('📊 昨日账户日报|' if zh else '📊 Daily account report | ') + day + detail = '交易、资产和持仓无法确认' if zh else 'Trades, assets and holdings could not be confirmed' + return '\n'.join((title, '🕒 ' + _window_text(day, 'zh' if zh else 'en'), detail)) diff --git a/python/scripts/account_daily_facts.py b/python/scripts/account_daily_facts.py new file mode 100644 index 00000000..434d2e17 --- /dev/null +++ b/python/scripts/account_daily_facts.py @@ -0,0 +1,205 @@ +"""Project bound candidate account observations without inferring coverage.""" +from __future__ import annotations + +from collections.abc import Mapping +from datetime import date, datetime, time, timedelta, timezone +from math import isfinite + +from notification_snapshot.unified_contract import SGT, sgt_business_window + +SIMULATION_MODES = frozenset({'dry_run', 'paper', 'shadow', 'plan_only'}) + + +def _text(value): + return value.strip() if isinstance(value, str) and value.strip() else None + + +def _number(value, *, positive=False): + if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value): + return None + if positive and value <= 0: + return None + return value + + +def _stamp(value): + if not isinstance(value, str): + return None + try: + result = datetime.fromisoformat(value.replace('Z', '+00:00')) + return result if result.tzinfo is not None and result.utcoffset() is not None else None + except ValueError: + return None + + +def _currency(value): + value = _text(value) + return value.upper() if value and len(value) == 3 and value.isascii() and value.isalpha() else None + + +def _execution(raw, row, kind, day): + if not isinstance(raw, Mapping): + return None + stamp = _stamp(raw.get('occurred_at')) + interval = None + if stamp is None and raw.get('occurred_at') is not None: + return None + if stamp is None and kind == 'orders': + start = _stamp(raw.get('submission_window_start')) + finish = _stamp(raw.get('submission_window_end')) + if (start is not None and finish is not None and start <= finish + and sgt_business_window(start).business_day == day + and sgt_business_window(finish).business_day == day): + interval = (start, finish) + if stamp is None and interval is None: + return None + if stamp is not None and sgt_business_window(stamp).business_day != day: + return None + identity = _text(raw.get('id')) + if identity is None: + return None + result = {'id': identity} + fields = ('symbol', 'side', 'strategy', 'asset_type', 'status') if kind == 'orders' else ( + 'order_id', 'symbol', 'side', 'strategy', 'asset_type') + for name in fields: + if name in raw and raw[name] is not None: + value = _text(raw[name]) + if value is None: + return None + result[name] = value + else: + result[name] = None + for name in (('quantity', 'filled_quantity', 'remaining_quantity', 'price', 'average_fill_price') if kind == 'orders' + else ('quantity', 'price')): + if name in raw and raw[name] is not None: + value = _number(raw[name]) + if value is None or value < 0 or (name == 'quantity' and value == 0): + return None + result[name] = value + else: + result[name] = None + if raw.get('currency') is not None: + result['currency'] = _currency(raw['currency']) + if result['currency'] is None: + return None + else: + result['currency'] = None + if stamp is not None: + result['occurred_at'] = (stamp.astimezone(timezone.utc) if kind == 'fills' else stamp).isoformat() + else: + result['submission_window_start'] = interval[0].isoformat() + result['submission_window_end'] = interval[1].isoformat() + return result + + +def _snapshot(raw, end): + if not isinstance(raw, Mapping): + return None + stamp = _stamp(raw.get('as_of')) + if stamp is None or stamp > end: + return None + currency = _currency(raw.get('equity_currency')) + equity = _number(raw.get('equity')) if raw.get('equity') is not None else None + if currency is None: + equity = None + holdings = raw.get('holdings') + complete = (isinstance(holdings, list) and raw.get('holdings_scope') == 'account' + and raw.get('holdings_complete') is True) + projected = [] + for item in holdings if isinstance(holdings, list) else (): + if not isinstance(item, Mapping): + complete = False + continue + symbol, asset = _text(item.get('symbol')), _text(item.get('asset_type')) + quantity = _number(item.get('quantity')) + if symbol is None or asset is None or quantity is None: + complete = False + continue + projected.append({'symbol': symbol, 'quantity': quantity, 'asset_type': asset}) + if equity is None and not projected and not complete: + return None + return {'as_of': stamp.isoformat(), 'equity': equity, 'equity_currency': currency, + 'holdings': projected, 'holdings_complete': complete} + + +def _add_unique(records, conflicts, key, value): + if key in conflicts: + return + prior = records.get(key) + if prior and 'strategy' in value and {k: v for k, v in prior.items() if k != 'strategy'} == { + k: v for k, v in value.items() if k != 'strategy' + }: + records[key] = {**prior, 'strategy': prior['strategy'] if prior['strategy'] == value['strategy'] else None} + elif prior and prior != value: + records.pop(key) + conflicts.add(key) + else: + records[key] = value + + +def collect_account_facts(rows, expected, business_day): + """Return verified observations for bound accounts, with unknown coverage. + + Current projection attests strategy observations only. Neither per-run + counts nor a producer's boolean proves the entire broker account window. + An empty list and false completeness flag do not assert zero activity. + """ + day = date.fromisoformat(business_day) + end = datetime.combine(day + timedelta(days=1), time(6), SGT) + identity = ('platform_id', 'opaque_account_uid', 'target_id', 'strategy_profile') + allowed = {tuple(binding[name] for name in identity) for binding in expected} + accounts = {(binding['platform_id'], binding['opaque_account_uid']) for binding in expected} + records = {key: {kind: {} for kind in ('orders', 'fills', 'snapshots')} for key in accounts} + conflicts = {key: {kind: set() for kind in ('orders', 'fills', 'snapshots')} for key in accounts} + current_fills = {key: {} for key in accounts} + current_fill_conflicts = {key: set() for key in accounts} + unverified_fill_windows = set() + mixed_fill_windows = set() + for row in rows: + if not isinstance(row, Mapping) or tuple(row.get(name) for name in identity) not in allowed: + continue + if str(row.get('execution_mode') or '').strip().lower() in SIMULATION_MODES: + continue + try: + row_day = date.fromisoformat(row.get('business_day', '')) + except (TypeError, ValueError): + continue + if row_day > day: + continue + facts = row.get('account_daily_facts') + if not isinstance(facts, Mapping): + continue + account = (row['platform_id'], row['opaque_account_uid']) + if row_day == day and facts.get('fills_window_unknown') is True: + unverified_fill_windows.add(account) + if row_day == day and facts.get('fills_window_mixed') is True: + mixed_fill_windows.add(account) + # An already-read older run may contain a later, independently dated + # fill. Its submission and run counts still belong to the older day. + for kind in ('orders', 'fills') if row_day == day else ('fills',): + source = facts.get(kind) + if not isinstance(source, list): + continue + for raw in source: + value = _execution(raw, row, kind, day) + if value is not None: + _add_unique(records[account][kind], conflicts[account][kind], value['id'], value) + if kind == 'fills' and row_day == day: + _add_unique(current_fills[account], current_fill_conflicts[account], value['id'], value) + source = facts.get('snapshots') + if isinstance(source, list): + for raw in source: + value = _snapshot(raw, end) + if value is not None and (row_day == day or _stamp(value['as_of']) < end - timedelta(days=1)): + key = _stamp(value['as_of']).timestamp() + _add_unique(records[account]['snapshots'], conflicts[account]['snapshots'], key, value) + additional_fills = {account for account in accounts + if conflicts[account]['fills'] + or any(current_fills[account].get(key) != value + for key, value in records[account]['fills'].items())} + return {account: {**{kind: list(values.values()) for kind, values in data.items()}, + 'orders_complete': False, 'fills_complete': False, + 'additional_fills': account in additional_fills, + 'fills_window_unknown': account in unverified_fill_windows, + 'fills_window_mixed': account in mixed_fill_windows} + for account, data in records.items()} diff --git a/python/scripts/cloud_run_daily_digest.py b/python/scripts/cloud_run_daily_digest.py index 2a0efdcc..07585287 100644 --- a/python/scripts/cloud_run_daily_digest.py +++ b/python/scripts/cloud_run_daily_digest.py @@ -19,9 +19,10 @@ import daily_digest_aggregator as aggregator import merge_digest_candidates as candidate_contract import notification_candidate_adapter as adapter +from account_daily_digest import render_account_daily, render_account_daily_unavailable +from account_daily_facts import collect_account_facts from daily_digest_delivery_store import GCSDeliveryStore, StoreUnavailable, location -from display_names import configured_account_label, display_name -from unified_daily_digest import _safe_display_text, _render_consolidated as render +from display_names import configured_account_label, safe_display_text as _safe_display_text SGT = ZoneInfo('Asia/Singapore') IDENTITY = adapter.IDENTITY[:4] @@ -34,7 +35,11 @@ def project_expected_target_label(binding, account_option): result.pop('account_label', None) label = configured_account_label({key: account_option.get(key) for key in ('label', 'key', 'target_name')}) if label: - result['account_label'] = label + configured = account_option.get('label') + result['account_label'] = ( + configured if isinstance(configured, str) and len(configured) <= 70 + and configured.isprintable() and _safe_display_text(configured) == label else label + ) return result @@ -61,7 +66,7 @@ def __post_init__(self): # on the binding. Normalize them at the actual environment intake. projected = project_expected_target_label(row, row) if row.get('account_label'): - projected['account_label'] = _safe_display_text(row['account_label']) + projected['account_label'] = row['account_label'] expected.append(projected) object.__setattr__(self, 'expected', expected) platforms = {row['platform_id'] for row in self.expected} @@ -135,12 +140,8 @@ def report_slot(headers, configuration, now): return report_date, (report_date - timedelta(days=1)).isoformat(), stamp, local.minute == 30 -def _units(text): - return len(text.encode('utf-16-le')) // 2 - - def _sources(configuration, store, day, end, *, now, monotonic): - rows, outcomes = [], [] + rows, outcomes, stale_observations = [], [], [] budget_end = monotonic() + min(120, max(0, (end - now()).total_seconds())) for binding in configuration.expected: timeout = min(20, budget_end - monotonic(), (end - now()).total_seconds()) @@ -159,6 +160,8 @@ def _sources(configuration, store, day, end, *, now, monotonic): fresh, stale = adapter.partition(items, day) if stale: outcome = 'stale' + stale_observations.extend(stale) + rows.extend(fresh) elif fresh: outcome = 'present' if any(item['actually_ran'] for item in fresh) else 'not_run' rows.extend(fresh) @@ -169,10 +172,10 @@ def _sources(configuration, store, day, end, *, now, monotonic): except (ValueError, TypeError, KeyError, AttributeError): outcome = 'invalid' outcomes.append(outcome) - return rows, outcomes + return rows, outcomes, stale_observations -def _report(configuration, rows, outcomes, day, platform_config): +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 failure_reasons = {'read_failed': 'candidate_store_read_failed', @@ -185,40 +188,14 @@ def _report(configuration, rows, outcomes, day, platform_config): path.write_text(json.dumps({'runs': rows}), encoding='utf-8') payload, _, _ = aggregator.build_digest_payload( business_day=day, locale=configuration.locale, candidates_path=path, - include_github=False, config=config, candidate_failures=failures, + include_github=False, config=config, candidate_failures=failures, render_text=False, ) covered = sum(value in {'present', 'not_run'} 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) if coverage == 'complete' and (payload.notification_summary or {}).get('counts_unknown'): coverage = 'partial' - zh = configuration.locale == 'zh' - labels = {'missing': '未找到当日报告' if zh else 'Report missing', - 'read_failed': '报告读取失败' if zh else 'Report read failed', - 'invalid': '报告无效或身份不匹配' if zh else 'Report invalid or identity mismatch', - 'stale': '报告业务日过期' if zh else 'Report business day stale', - 'not_run': '报告注明未运行;不推断成交统计' if zh else 'Explicitly did not run; no inferred fills'} - details = [] - for binding, outcome in zip(configuration.expected, outcomes): - if outcome == 'present': - continue - platform = _safe_display_text(display_name('platforms', binding['platform_id'], configuration.locale)) - strategy = _safe_display_text(display_name('strategies', binding['strategy_profile'], configuration.locale, - fallback='未命名策略' if zh else 'Unnamed strategy')) - separator = ':' if zh else ': ' - details.append(f'• {platform} / {strategy}{separator}{labels[outcome]}') - coverage_label = {'complete': '完整', 'partial': '部分', 'unavailable': '不可用'}[coverage] if zh else coverage - trailer = [f'数据覆盖:{coverage_label}' if zh else f'Data coverage: {coverage_label}'] - for index, detail in enumerate(details): - if _units('\n'.join(trailer + [detail])) > 1200: - trailer.append(f'另有 {len(details) - index} 项缺口' if zh else f'{len(details) - index} more gaps') - break - trailer.append(detail) - # Presentation-only standing owner OFF, applied solely to an unbound - # Binance inventory. A contradictory binding retains its real coverage. - text = render(payload, max_units=3850, known_disabled_platforms={"binance"}, - source_repositories={key: value.get("repository") - for key, value in config.get("platforms", {}).items()}, - coverage_details=trailer) + text = render_account_daily(payload, configuration.expected, outcomes, facts=facts) return text, coverage @@ -260,14 +237,13 @@ def execute(headers, configuration, store, send, *, preview=False, now=utc_now, text, coverage = '', 'unavailable' else: read_end = now() + timedelta(seconds=120) if preview else stop - rows, outcomes = _sources(configuration, store, day, read_end, now=now, monotonic=monotonic) - text, coverage = _report(configuration, rows, outcomes, day, platform_config) + rows, outcomes, stale = _sources( + configuration, store, day, read_end, now=now, monotonic=monotonic) + text, coverage = _report(configuration, rows, outcomes, day, platform_config, stale_observations=stale) # Recheck after intake/rendering: wall-clock expiry cannot be extended by retries. late = late or now() + timedelta(seconds=30) >= deadline_at if late and not preview: - from daily_digest_deadline import failure_text - text = failure_text({'DIGEST_REPORT_DATE': report_date.isoformat(), 'DIGEST_BUSINESS_DAY': day}, - is_late=True, locale=configuration.locale) + text = render_account_daily_unavailable(day, configuration.locale) safe.update(coverage=coverage, late=late) if preview: return 200, {**safe, 'delivery': 'preview'} diff --git a/python/scripts/daily_digest_aggregator.py b/python/scripts/daily_digest_aggregator.py index 16acec72..310a9826 100644 --- a/python/scripts/daily_digest_aggregator.py +++ b/python/scripts/daily_digest_aggregator.py @@ -904,6 +904,7 @@ def build_digest_payload( layout: str | None = None, config: Mapping[str, Any] | None = None, candidate_failures: Sequence[Mapping[str, str]] | None = None, + render_text: bool = True, ) -> tuple[digest.DailyDigestInput, str, CollectResult]: config = config if config is not None else load_platform_config(config_path) contract = digest_contract(config) @@ -933,6 +934,8 @@ def build_digest_payload( window=digest_window_for(day), notification_summary=collected.notification_summary, ) + if not render_text: + return payload, "", collected from unified_daily_digest import enabled, render text = (render(payload, source_repositories={key: value.get("repository") for key, value in config.get("platforms", {}).items()}) diff --git a/python/scripts/unified_daily_digest.py b/python/scripts/unified_daily_digest.py index ddf61340..517eff2a 100644 --- a/python/scripts/unified_daily_digest.py +++ b/python/scripts/unified_daily_digest.py @@ -270,7 +270,13 @@ def _render_consolidated(payload: DailyDigestInput, *, max_units: int, summary = payload.notification_summary or {} coverage = payload.evidence_coverage complete = bool(coverage and coverage.is_complete_for_verified_idle and not summary.get("counts_scope_unknown", summary.get("counts_unknown"))) - verified_idle = complete and all(_verified_no_trade(row) for row in payload.runs) + sources = summary.get("source_inventory", {}) + expected_sources = [source for source in sources.values() if source["expected_targets"]] + verified_no_run = (complete and not payload.runs and not summary.get("run_count_unknown") + and bool(expected_sources) and all( + source["covered_targets"] == source["expected_targets"] and source["runs"] == 0 + for source in expected_sources)) + verified_idle = complete and bool(payload.runs) and all(_verified_no_trade(row) for row in payload.runs) lines = ["📊 " + (f"每日交易摘要 · {payload.business_day}" if zh else f"Daily trading digest · {payload.business_day}")] lines.append("🕒 " + _window_text(payload.business_day, locale)) lines.append(("窗口内运行次数未知" if zh else "Run count in window unknown") if summary.get("run_count_unknown") else @@ -278,14 +284,13 @@ def _render_consolidated(payload: DailyDigestInput, *, max_units: int, counts = [] for name, label in (("order", "订单" if zh else "Orders"), ("fill", "成交" if zh else "Fills")): values = [getattr(row, name + "_count") for row in payload.runs] - known = complete and all(_known_count(row, name) for row in payload.runs) + known = complete and bool(payload.runs) and all(_known_count(row, name) for row in payload.runs) value = str(sum(values)) if known else ("全窗口统计未知" if zh else "whole-window count unknown") counts.append(f"{label}:{value}" if zh else f"{label}: {value}") confirmation = (" · 最终状态待确认" if zh else " · final state unconfirmed") if any( _execution_unconfirmed(row) for row in payload.runs) else "" lines.append(" · ".join(counts) + confirmation) inventory = [] - sources = summary.get("source_inventory", {}) platforms = dict.fromkeys(("ibkr", "schwab", "firstrade", "longbridge", "binance", "qrs", *sources)) for platform in platforms: source = sources.get(platform) @@ -411,12 +416,16 @@ def _render_consolidated(payload: DailyDigestInput, *, max_units: int, required_diagnostics.append(link_status) evidence.extend(links) optional_evidence.extend(() for _ in links) - needs_review = (not complete or any(priority for priority, _, _, _ in blocks) or bool(immediate) + needs_review = (not complete or (not payload.runs and not verified_no_run) + or any(priority for priority, _, _, _ in blocks) or bool(immediate) or any(not _known_count(row, name) for row in payload.runs for name in ("order", "fill"))) result = [("证据需核对" if needs_review else "已收录窗口记录") if zh else ("Evidence needs review" if needs_review else "Window observations recorded"), *lines[2:]] - if verified_idle: - result.insert(0, "😴 已核验目标无交易" if zh else "😴 No trades in verified targets") + if verified_no_run: + result.insert(0, "已核验范围本窗口未运行" if zh else "No runs in the verified scope") + elif verified_idle: + result.insert(0, "😴 已核验目标无交易(限已收录运行)" if zh else + "😴 No trades in verified targets (included runs only)") return render_operator_message( title=lines[0], locale=locale, result=result, scope=[lines[1]], evidence=evidence, coverage=coverage_lines, diff --git a/python/tests/fixtures/daily-caller-en.txt b/python/tests/fixtures/daily-caller-en.txt index adae94b0..f94dc0a3 100644 --- a/python/tests/fixtures/daily-caller-en.txt +++ b/python/tests/fixtures/daily-caller-en.txt @@ -1,21 +1,7 @@ -📊 Daily trading digest · -[Result] -😴 No trades in verified targets -Window observations recorded -Observed runs in window: 1 -Orders: 0 · Fills: 0 -[Scope] -🕒 Reporting window: 06:00 – 06:00 (UTC+8) -[Evidence] -Run link: unavailable -[Coverage] -Bound targets; missing is unknown -IBKR: not included in this digest; run state unknown -Schwab: Included runs: 1; expected target coverage 1/1 -Firstrade: not included in this digest; run state unknown -LongBridge: not included in this digest; run state unknown -Binance: disabled; runs/orders/fills N/A -Management console: not included in this digest; run state unknown -Data coverage: complete -[Next step] -No action required; await the next reporting window. +📊 Daily account report | 2026-10-15 +🕒 Reporting window: 2026-10-15 06:00 – 2026-10-16 06:00 (UTC+8) +🏦 Schwab · Account · Unnamed strategy +😴 No trades (verified strategy scope) +Orders submitted: 0 | Fill reports: 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 7f3aeca4..024d0e18 100644 --- a/python/tests/fixtures/daily-caller-zh.txt +++ b/python/tests/fixtures/daily-caller-zh.txt @@ -1,21 +1,7 @@ -📊 每日交易摘要 · -【结果】 -😴 已核验目标无交易 -已收录窗口记录 -已收录运行 1 次 -订单:0 · 成交:0 -【范围】 -🕒 统计区间: 06:00 – 06:00 (UTC+8) -【证据】 -运行链接:不可用 -【覆盖】 -统计范围:已绑定目标;缺失统计保留未知 -IBKR:未纳入本期汇总,运行状态未知 -Schwab:已收录 1 次运行,目标覆盖 1/1 -Firstrade:未纳入本期汇总,运行状态未知 -LongBridge:未纳入本期汇总,运行状态未知 -Binance:已停用;运行/订单/成交不适用 -管理站:未纳入本期汇总,运行状态未知 -数据覆盖:完整 -【下一步】 -无需操作;等待下一统计窗口。 +📊 昨日账户日报|2026-10-15 +🕒 统计区间:2026-10-15 06:00 – 2026-10-16 06:00 (UTC+8) +🏦 Schwab · 账户 · 未命名策略 +😴 无交易(已核验策略范围) +提交订单 0笔|成交回报 0笔 +💰 总资产:未知 +📦 持仓:未知 diff --git a/python/tests/test_cloud_run_daily_digest.py b/python/tests/test_cloud_run_daily_digest.py index 7952c970..893960ad 100644 --- a/python/tests/test_cloud_run_daily_digest.py +++ b/python/tests/test_cloud_run_daily_digest.py @@ -72,6 +72,61 @@ def enable(self): from dataclasses import replace return replace(self.configuration, enabled=True) + 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, + 'equity_currency': 'USD', 'holdings_scope': 'account', + 'holdings_complete': True, 'holdings': [position]} + cases = ( + ({'equity_currency': None}, ('总资产:未知', 'TQQQ 3股'), ('USD 250.00',)), + ({'holdings': None}, ('总资产:USD 250.00', '持仓:未知'), ('空仓',)), + ({'holdings': [position, {'symbol': 'BOXX'}]}, + ('总资产:USD 250.00', '仅列已核实持仓', 'TQQQ 3股'), ('空仓',)), + ) + for changes, included, excluded in cases: + with self.subTest(changes=changes): + key = 'schwab/2026-10-15/synthetic-slot.json' + row = {**ROW, 'account_daily_facts': {'snapshots': [{**snapshot, **changes}]}} + self.store, self.sent = MemoryStore({key: {'runs': [row]}}), [] + status, result = self.run_request(configuration=self.enable()) + self.assertEqual((status, result['delivery']), (200, 'accepted')) + self.assertIn('2026-10-16 05:10 UTC+8', self.sent[0]) + for part in included: + self.assertIn(part, self.sent[0]) + for part in excluded: + self.assertNotIn(part, self.sent[0]) + + def test_ibkr_account_report_uses_only_the_existing_candidate(self): + from dataclasses import replace + binding = {**EXPECTED[0], 'platform_id': 'ibkr'} + configuration = replace(self.enable(), expected=[binding]) + key = f"ibkr/{ROW['business_day']}/{binding['candidate_slot']}.json" + snapshot = {'as_of': (NOW - timedelta(hours=2)).isoformat(), + 'equity': 250, 'equity_currency': 'USD', + 'holdings': [{'symbol': 'TQQQ', 'quantity': 3, 'asset_type': 'etf'}]} + row = {**ROW, 'platform_id': 'ibkr', 'fill_count': None, + 'account_daily_facts': {'snapshots': [snapshot]}} + row.pop('notification_event') + self.store = MemoryStore({key: {'runs': [row]}}) + read = self.store.candidate + + def ordinary_candidate(requested, timeout): + self.assertEqual(requested, key) + return read(requested, timeout) + + self.store.candidate = ordinary_candidate + self.assertEqual(self.run_request(configuration=configuration)[1]['delivery'], 'accepted') + self.assertEqual(self.run_request(configuration=configuration)[1]['delivery'], 'duplicate') + self.now = NOW + timedelta(minutes=30) + headers = {**HEADERS, 'X-CloudScheduler-ScheduleTime': self.now.isoformat()} + self.store.candidate = lambda *args: self.fail('deadline check read financial inputs') + 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('TQQQ 3股', self.sent[0]) + self.assertIn('成交回报 未知', self.sent[0]) + self.assertNotIn('成交回报 0笔', self.sent[0]) + 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"), @@ -139,7 +194,7 @@ def test_sdk_reads_do_not_require_an_actions_candidate_status_variable(self): with patch.dict(os.environ, {'DIGEST_CANDIDATES_GCS_URI': 'gs://synthetic/candidates'}): _, result = self.run_request(configuration=self.enable()) self.assertEqual(result['coverage'], 'complete') - self.assertIn('😴 已核验目标无交易', self.sent[0]) + self.assertIn('😴 无交易(已核验策略范围)', self.sent[0]) self.assertNotIn('覆盖不完整', self.sent[0]) def test_disabled_real_delivery_does_not_read_or_reserve(self): @@ -179,11 +234,11 @@ def test_one_missing_target_keeps_valid_target_without_a_fake_global_zero(self): configuration = replace(self.enable(), expected=[*EXPECTED, another]) _, result = self.run_request(configuration=configuration) self.assertEqual((result['coverage'], result['delivery']), ('partial', 'accepted')) - self.assertIn('全窗口统计未知', self.sent[0]) - self.assertIn('未找到当日报告', self.sent[0]) - self.assertNotIn('Schwab·', self.sent[0]) - self.assertIn('Binance:已停用;运行/订单/成交不适用', self.sent[0]) - self.assertNotIn('Binance:运行 0', self.sent[0]) + self.assertIn('成交回报 未知', self.sent[0]) + self.assertIn('报告缺失', self.sent[0]) + self.assertIn('成交回报 0笔', self.sent[0]) + self.assertEqual(self.sent[0].count('🏦 Schwab · '), 2) + self.assertNotIn('全部已核验账户无交易', self.sent[0]) def test_invalid_or_unreadable_target_does_not_discard_another_platform(self): from dataclasses import replace @@ -221,9 +276,28 @@ def test_explicit_non_run_is_distinct_from_missing_without_inventing_its_cause(s self.store.rows[next(iter(self.store.rows))] = {'runs': [idle]} _, result = self.run_request(configuration=self.enable()) self.assertEqual(result['coverage'], 'complete') - self.assertIn('😴 已核验目标无交易', self.sent[0]) + self.assertIn('策略未运行,账户交易未知', self.sent[0]) + self.assertIn('未运行', self.sent[0]) + self.assertNotIn('无交易', self.sent[0]) + self.assertNotIn('成交回报 0笔', self.sent[0]) self.assertNotIn('休市', self.sent[0]) + def test_partial_non_run_reports_do_not_establish_window_outcome(self): + from dataclasses import replace + idle = {**ROW, 'actually_ran': False} + for key in ('notification_event', 'source_run_id'): + idle.pop(key, None) + self.store.rows[next(iter(self.store.rows))] = {'runs': [idle]} + missing = {**EXPECTED[0], 'target_id': 'fixture-missing', 'candidate_slot': 'fixture-missing'} + _, result = self.run_request(configuration=replace(self.enable(), expected=[*EXPECTED, missing])) + self.assertEqual(result['coverage'], 'partial') + text = self.sent[0] + self.assertIn('未运行', text) + self.assertIn('报告缺失', text) + self.assertIn('成交回报 未知', text) + self.assertNotIn('全部已核验账户无交易', text) + self.assertNotIn('无交易', text) + def test_retry_keeps_original_report_date_and_rejects_another_day(self): self.now = NOW + timedelta(days=1) status, result = self.run_request(configuration=self.enable()) @@ -322,7 +396,7 @@ def test_after_cutoff_sends_one_late_notice_without_reading_candidates(self): for _ in range(2): self.run_request(configuration=self.enable()) self.assertEqual(len(self.sent), 1) - self.assertIn('late', self.sent[0]) + self.assertIn('交易、资产和持仓无法确认', self.sent[0]) def test_different_language_or_release_does_not_bypass_daily_reservation(self): from dataclasses import replace @@ -372,9 +446,9 @@ def test_stdout_contains_no_message_or_private_source_values(self): with redirect_stdout(stream): self.run_request(configuration=self.enable()) self.assertNotIn(ROW['opaque_account_uid'], stream.getvalue()) - self.assertNotIn('每日交易摘要', stream.getvalue()) + self.assertNotIn('昨日账户日报', stream.getvalue()) - def test_five_platform_private_ids_reach_only_mock_transport(self): + def test_five_platform_raw_account_ids_are_not_rendered(self): from contextlib import redirect_stdout, redirect_stderr from dataclasses import replace identities = {'ibkr': 'U0000001', 'schwab': '00000002', 'firstrade': '00000003', @@ -395,9 +469,9 @@ def test_five_platform_private_ids_reach_only_mock_transport(self): self.assertEqual((code, result['delivery']), (200, 'accepted')) public = output.getvalue() + errors.getvalue() + json.dumps(result) + json.dumps(self.store.records) for identity in identities.values(): - self.assertIn('·' + identity, self.sent[0]) + self.assertNotIn(identity, self.sent[0]) self.assertNotIn(identity, public) - self.assertEqual(self.sent[0].count('📝 提交1笔/成交1笔'), 5) + self.assertEqual(self.sent[0].count('提交订单 1笔|成交回报 1笔'), 5) def test_approved_private_binding_hint_is_applied_to_its_exact_target(self): from dataclasses import replace @@ -406,7 +480,7 @@ def test_approved_private_binding_hint_is_applied_to_its_exact_target(self): self.store = MemoryStore({'schwab/2026-10-15/synthetic-slot.json': {'runs': [active]}}) configured = replace(self.enable(), expected=[{**EXPECTED[0], 'account_hint': '00000007'}]) self.run_request(configuration=configured) - self.assertIn('Schwab·***07', self.sent[0]) + self.assertIn('Schwab · ***07', self.sent[0]) self.assertNotIn('00000007', self.sent[0]) def test_binding_hint_cannot_replace_valid_producer_identity(self): @@ -419,7 +493,7 @@ def test_binding_hint_cannot_replace_valid_producer_identity(self): self.sent = [] configured = replace(self.enable(), expected=[{**EXPECTED[0], 'account_hint': hint}]) self.run_request(configuration=configured) - self.assertIn('Schwab·***08', self.sent[0]) + self.assertIn('Schwab · ***08', self.sent[0]) self.assertNotIn('00000008', self.sent[0]) self.assertNotIn('·' + hint, self.sent[0]) diff --git a/python/tests/test_daily_caller_presentation.py b/python/tests/test_daily_caller_presentation.py index 6062ad72..d3bbf413 100644 --- a/python/tests/test_daily_caller_presentation.py +++ b/python/tests/test_daily_caller_presentation.py @@ -1,16 +1,35 @@ -"""Source-only caller regressions; no network, credentials or real sends.""" +"""Exercise the authenticated caller with synthetic protected candidates.""" from dataclasses import replace -import unittest -import re from pathlib import Path +import unittest +import cloud_run_daily_digest as app +from account_daily_digest import render_account_daily +from daily_digest_notify import DailyDigestInput, DigestRunEntry import test_cloud_run_daily_digest as caller_fixture -from test_cloud_run_daily_digest import EXPECTED, ROW +from test_cloud_run_daily_digest import EXPECTED, MemoryStore, ROW from test_daily_digest_ux import config -from test_notification_consolidation import candidate -import cloud_run_daily_digest as app -from notification_snapshot.presentation import SECTION_NAMES -from unified_daily_digest import _render_consolidated + +DAY = ROW["business_day"] +TIME = "2026-10-16T05:10:00+08:00" +NO_REPORT_TECH = ("【结果】", "【覆盖】", "[Result]", "[Coverage]", "https://", "运行链接") + + +def record(kind, **updates): + return {"id": kind, "symbol": "TQQQ", "side": "buy", "quantity": 3, "asset_type": "stock", + "currency": "USD", "occurred_at": TIME, **updates} + + +def with_facts(row, **facts): + result = {**row, "account_daily_facts": facts} + result.pop("notification_event", None) + return result + + +def snapshot(**updates): + return {"as_of": TIME, "equity": 50000, "equity_currency": "USD", "holdings_scope": "account", + "holdings_complete": True, "holdings": [{"symbol": "TQQQ", "quantity": 300, "asset_type": "stock"}], + **updates} class DailyCallerPresentationTests(unittest.TestCase): @@ -19,90 +38,187 @@ class DailyCallerPresentationTests(unittest.TestCase): send = caller_fixture.CloudRunDigestTests.send enable = caller_fixture.CloudRunDigestTests.enable - def assert_sections(self, text, locale="zh"): - marks = [f"【{name}】" if locale == "zh" else f"[{name}]" for name in SECTION_NAMES[locale]] - self.assertEqual([text.count(mark) for mark in marks], [1] * 5) - self.assertEqual(sorted(text.index(mark) for mark in marks), [text.index(mark) for mark in marks]) - self.assertLessEqual(app._units(text), 3900) + def report(self, rows, outcomes, *, locale="zh", expected=EXPECTED, stale=()): + configuration = replace(self.configuration, locale=locale, expected=expected) + text, coverage = app._report(configuration, rows, outcomes, DAY, config(expected), stale_observations=stale) + for token in NO_REPORT_TECH: + self.assertNotIn(token, text) + self.assertLessEqual(len(text.encode("utf-16-le")) // 2, 3850) + return text, coverage - def test_central_caller_preserves_sections_and_disabled_scope(self): - for locale in ("zh", "en"): + def test_one_order_three_distinct_fills_in_both_languages_through_real_sender(self): + fills = [record(f"fill-{i}", order_id="order", quantity=1, price=60 + i) for i in range(3)] + order = record("order", status="filled", filled_quantity=3, remaining_quantity=0) + item = with_facts({**ROW, "order_count": 1, "fill_count": 3, "source_run_id": "https://example.invalid/private"}, + orders=[order], fills=[*fills, fills[0]], snapshots=[snapshot()]) + for locale, header, order_count, fill_count, holding in ( + ("zh", "📊 昨日账户日报|", "提交订单 1笔", "成交回报 3笔", "TQQQ 300股"), + ("en", "📊 Daily account report | ", "Orders submitted: 1", "Fill reports: 3", "TQQQ 300 shares"), + ): with self.subTest(locale=locale): - text, coverage = app._report(replace(self.configuration, locale=locale), [ROW], - ["present"], ROW["business_day"], config(EXPECTED)) - self.assert_sections(text, locale) - self.assertIn("Binance:已停用" if locale == "zh" else "Binance: disabled", text) - self.assertIn("运行/订单/成交不适用" if locale == "zh" else "runs/orders/fills N/A", text) - self.assertIn("未纳入本期汇总,运行状态未知" if locale == "zh" else "not included in this digest; run state unknown", text) - self.assertIn("运行链接:不可用" if locale == "zh" else "Run link: unavailable", text) - - def test_central_caller_regression_snapshots(self): + self.store = MemoryStore({"schwab/2026-10-15/synthetic-slot.json": {"runs": [item]}}) + self.sent = [] + cfg = replace(self.enable(), locale=locale, expected=[{**EXPECTED[0], "account_label": "Desk Alpha"}]) + _, result = self.run_request(configuration=cfg) + self.assertEqual(result["delivery"], "accepted") + text = self.sent[0] + for part in (header, "🏦 Schwab · Desk Alpha · ", order_count, fill_count, holding, "USD 50,000.00"): + self.assertIn(part, text) + self.assertEqual(text.count("@ USD"), 3) + for token in NO_REPORT_TECH: + self.assertNotIn(token, text) + self.assertNotIn(item["opaque_account_uid"], text) + + def test_pending_partial_cancel_reject_and_expiry_follow_evidence(self): + for status, filled, expected in ( + ("open", None, "已提交,成交待确认"), + ("partially_filled", 1, "部分成交"), + ("cancelled", 1, "已取消"), + ("rejected", 0, "已拒绝"), + ("expired", 0, "已过期"), + ): + with self.subTest(status=status): + order = record("order", status=status, filled_quantity=filled, + remaining_quantity=None if filled is None else 3 - filled) + fill = [record("fill", quantity=1, price=61)] if filled else [] + item = with_facts({**ROW, "order_count": 1, "fill_count": filled}, + orders=[order], fills=fill) + text, _ = self.report([item], ["present"]) + self.assertIn(expected, text) + self.assertNotIn("😴 无交易", text) + if filled is None: + self.assertIn("成交回报 未知", text) + if filled: + self.assertIn("剩余 2股", text) + self.assertIn("USD 61.00", text) + if status in {"cancelled", "rejected", "expired"}: + self.assertNotIn("成交待确认", text) + + def test_no_trade_keeps_assets_and_explicit_empty_positions(self): + item = with_facts(ROW, snapshots=[snapshot(holdings=[])]) + text, coverage = self.report([item], ["present"]) + self.assertEqual(coverage, "complete") + for part in ("😴 无交易(已核验策略范围)", "提交订单 0笔", "成交回报 0笔", + "💰 总资产:USD 50,000.00", "📦 持仓:空仓"): + self.assertIn(part, text) + self.assertNotIn("全部已核验账户无交易", text) + + def test_simulation_does_not_establish_actual_account_trades(self): + item = with_facts({**ROW, "execution_mode": "dry_run", "fill_count": 1}, + fills=[record("simulated-fill", price=99)], snapshots=[snapshot()]) + text, _ = self.report([item], ["present"]) + self.assertIn("模拟记录未计入实际交易", text) + self.assertIn("成交回报 未知", text) + self.assertNotIn("✅ 有交易", text) + self.assertNotIn("USD 99.00", text) + + def test_all_accounts_no_trade_headline_requires_account_window_coverage(self): + binding = EXPECTED[0] + row = DigestRunEntry(**{key: binding[key] for key in app.IDENTITY}) + key = (binding["platform_id"], binding["opaque_account_uid"]) + facts = {key: {"orders_complete": True, "fills_complete": True}} + text = render_account_daily(DailyDigestInput(DAY, runs=(row,)), [binding], ["present"], facts=facts) + self.assertIn("全部已核验账户无交易", text) + self.assertIn("🏦 ", text) + + def test_account_daily_golden_output_in_both_languages(self): for locale in ("zh", "en"): - text, _ = app._report(replace(self.configuration, locale=locale), [ROW], - ["present"], ROW["business_day"], config(EXPECTED)) - normalized = re.sub(r"\d{4}-\d{2}-\d{2}", "", text) - snapshot = Path(__file__).parent / "fixtures" / f"daily-caller-{locale}.txt" - self.assertEqual(normalized + "\n", snapshot.read_text()) - - def test_disabled_claim_requires_inventory_and_no_bound_binance(self): - import daily_digest_notify as digest - for source in (None, dict(expected_targets=1, covered_targets=0, runs=0)): - inventory = {} if source is None else {"binance": source} - payload = digest.DailyDigestInput(business_day=ROW["business_day"], locale="en", - notification_summary={"source_inventory": inventory}) - text = _render_consolidated(payload, max_units=3900, known_disabled_platforms={"binance"}) - self.assertNotIn("Binance: disabled", text) - self.assertIn("source coverage unknown" if source is None else "expected target coverage 0/1", text) - - def test_run_link_is_supplied_bound_and_sanitized_or_unavailable(self): - repository = "example-org/example-repo" - url = f"https://github.com/{repository}/actions/runs/42" - cfg = config(EXPECTED) - cfg["platforms"] = {"schwab": {"repository": repository}} - for supplied, accepted in ((url + "?token=fake#private", True), - (url.replace(repository, "other/repo"), False), - (url.replace("github.com", "github.com.evil.test"), False), - (url.replace("https:", "http:"), False), - (url.replace("github.com", "user@github.com"), False), - (url + "/private", False), ("private-run-id", False)): - with self.subTest(accepted=accepted, supplied=supplied): - row = candidate(run=supplied) - text, _ = app._report(self.configuration, [row], ["present"], ROW["business_day"], cfg) - self.assert_sections(text) - self.assertNotIn("fake", text) - self.assertNotIn("private", text) - self.assertNotIn("evil.test", text) - self.assertIn(url if accepted else "运行链接:不可用", text) - - def test_late_fallback_keeps_sections_unknown_and_single_reservation(self): + with self.subTest(locale=locale): + text, _ = self.report([ROW], ["present"], locale=locale) + fixture = Path(__file__).parent / "fixtures" / f"daily-caller-{locale}.txt" + self.assertEqual(text + "\n", fixture.read_text()) + + def test_nonrun_missing_and_stale_have_separate_status_and_keep_known_snapshot(self): + idle = with_facts({**ROW, "actually_ran": False}, snapshots=[snapshot()]) + text, _ = self.report([idle], ["not_run"]) + self.assertIn("策略未运行,账户交易未知", text) + self.assertNotIn("无交易", text) + self.assertIn("USD 50,000.00", text) + stale = with_facts({**ROW, "business_day": "2026-10-14"}, + fills=[record("old", price=777, occurred_at="2026-10-15T05:10:00+08:00")], + snapshots=[snapshot(as_of="2026-10-14T05:10:00+08:00")]) + text, _ = self.report([], ["stale"], stale=[stale]) + self.assertIn("报告过期", text) + self.assertIn("较早快照 2026-10-14", text) + self.assertNotIn("777", text) + text, _ = self.report([], ["missing"]) + for part in ("报告缺失", "提交订单 未知", "成交回报 未知", "总资产:未知", "持仓:未知"): + self.assertIn(part, text) + self.assertNotIn("空仓", text) + + def test_two_accounts_and_multiple_strategies_preserve_one_snapshot_per_account(self): + second = {**EXPECTED[0], "target_id": "other-strategy", "strategy_profile": "tqqq_growth_income", + "candidate_slot": "other-strategy", "account_label": "Shared Desk"} + third = {**EXPECTED[0], "target_id": "other-account", "opaque_account_uid": "other-account", + "candidate_slot": "other-account", "account_label": "Second Desk"} + primary = {**EXPECTED[0], "account_label": "Shared Desk"} + fill = record("unique-fill", price=63) + one = with_facts({**ROW, "fill_count": 1}, fills=[{**fill, "strategy": ROW["strategy_profile"]}], + snapshots=[snapshot()]) + two = with_facts({**ROW, **{key: second[key] for key in app.IDENTITY}, "source_run_id": "other-run", "fill_count": 1}, + fills=[{**fill, "strategy": second["strategy_profile"]}], snapshots=[snapshot()]) + text, _ = self.report([one, two], ["present", "present", "missing"], expected=[primary, second, third]) + self.assertEqual(text.count("🏦 "), 2) + self.assertEqual(text.count("USD 50,000.00"), 1) + self.assertEqual(text.count("@ USD 63.00"), 1) + self.assertIn("策略未知", text) + self.assertIn("Second Desk", text) + + def test_budget_discloses_omitted_holdings_without_losing_totals(self): + hold = [{"symbol": f"TEST{i}", "quantity": i + 1, "asset_type": "option"} for i in range(70)] + binding = {**EXPECTED[0], "account_label": "Desk"} + facts = {(binding["platform_id"], binding["opaque_account_uid"]): {"snapshots": [snapshot(holdings=hold)]}} + text = render_account_daily(DailyDigestInput(DAY), [binding], ["not_run"], facts=facts, max_units=380) + self.assertLessEqual(len(text.encode("utf-16-le")) // 2, 380) + shown = sum(line.startswith("TEST") for line in text.splitlines()) + self.assertGreater(shown, 0) + self.assertIn(f"持仓展示 {shown} 项、省略 {len(hold) - shown} 项", text) + self.assertIn("张", text) + self.assertIn("USD 50,000.00", text) + + def test_cutoff_uses_same_report_title_and_reservation(self): self.now += app.timedelta(minutes=31) self.run_request(configuration=self.enable()) self.run_request(configuration=self.enable()) self.assertEqual(len(self.sent), 1) - self.assert_sections(self.sent[0]) - self.assertIn("成交与资产状态未知", self.sent[0]) - self.assertIn("运行链接:不可用", self.sent[0]) + self.assertIn("昨日账户日报", self.sent[0]) + self.assertIn("交易、资产和持仓无法确认", self.sent[0]) + for token in NO_REPORT_TECH: + self.assertNotIn(token, self.sent[0]) - def test_missing_all_sources_stays_five_sections_without_global_zero(self): - self.store.rows = {} - self.run_request(configuration=self.enable()) - self.assert_sections(self.sent[0]) - self.assertIn("全窗口统计未知", self.sent[0]) - self.assertNotIn("今日全部账号无交易", self.sent[0]) - - def test_nonrun_coverage_and_verified_zero_keep_different_scopes(self): - ft = candidate(platform_id="firstrade", actually_ran=False, notification_event=None) - ft.pop("notification_event") - expected = [{**{key: ft[key] for key in app.IDENTITY}, "candidate_slot": "fake-slot"}] - expected += EXPECTED + [{**EXPECTED[0], "platform_id": "longbridge"}] - configuration = replace(self.configuration, expected=expected) - text, _ = app._report(configuration, [ft, ROW], ["not_run", "present", "missing"], ROW["business_day"], config(expected)) - self.assertIn("已收录 0 次运行,目标覆盖 1/1", text) - self.assertIn("全窗口统计未知", text) - self.assertIn("Schwab:已收录 1 次运行,目标覆盖 1/1", text) - self.assertIn("报告注明未运行;不推断成交统计", text) - self.assert_sections(text) - - -if __name__ == "__main__": - unittest.main() + def test_settlement_without_fill_time_never_counts_as_yesterday_fill(self): + order = record("order", status="cancelled", filled_quantity=1, remaining_quantity=2, + average_fill_price=61) + row = with_facts({**ROW, "order_count": 1, "fill_count": 1}, orders=[order], + fills_window_unknown=True) + text, _ = self.report([row], ["present"]) + for part in ("已取消", "成交均价 USD 61.00", "成交回报 未知", "发生时段未核实"): + self.assertIn(part, text) + self.assertNotIn("✅ 有交易", text) + self.assertNotIn("✅ 部分成交", text) + + def test_cross_window_settlement_keeps_only_actual_in_window_fills(self): + current = record("before-boundary", price=61, occurred_at="2026-10-16T05:59:59+08:00") + later = record("after-boundary", price=62, occurred_at="2026-10-16T06:00:00+08:00") + row = with_facts({**ROW, "order_count": 1, "fill_count": 2}, orders=[record("order", status="filled")], + fills=[current, later], fills_window_mixed=True) + text, _ = self.report([row], ["present"]) + self.assertIn("✅ 有交易", text) + self.assertIn("成交回报 已见 1 笔,总数未知", text) + self.assertIn("USD 61.00", text) + self.assertNotIn("USD 62.00", text) + self.assertNotIn("成交回报 2笔", text) + self.assertIn("跨统计窗口", text) + self.assertNotIn("发生时段未核实", text) + + def test_dated_fill_on_already_read_older_run_does_not_count_its_submission(self): + row = with_facts({**ROW, "business_day": "2026-10-14", "order_count": 1, "fill_count": 1}, + orders=[record("old-order", status="filled", occurred_at="2026-10-15T05:00:00+08:00")], + fills=[record("new-fill", price=64, occurred_at="2026-10-15T06:00:00+08:00")]) + self.store = MemoryStore({"schwab/2026-10-15/synthetic-slot.json": {"runs": [row]}}) + _, result = self.run_request(configuration=self.enable()) + self.assertEqual(result["coverage"], "unavailable") + text = self.sent[0] + for part in ("报告过期", "提交订单 未知", "成交回报 已见 1 笔,总数未知", "USD 64.00"): + self.assertIn(part, text) + self.assertNotIn("提交订单 1笔", text) diff --git a/python/tests/test_daily_digest_v3.py b/python/tests/test_daily_digest_v3.py index bbb8cd64..a26ff5ee 100644 --- a/python/tests/test_daily_digest_v3.py +++ b/python/tests/test_daily_digest_v3.py @@ -37,7 +37,9 @@ def test_active_accounts_are_flat_and_idle_account_is_omitted(self): def test_verified_idle_keeps_scoped_summary_with_cycles_or_observations(self): item = row(order_count=0, fill_count=0, cycle_count=24, equity=1234, holdings=(DigestHolding('SYN', quantity=2),)) self.assertIn('😴 已核验目标无交易', render(payload(item))) - self.assertIn('😴 已核验目标无交易', render(payload())) + empty = render(payload()) + self.assertNotIn('已核验目标无交易', empty) + self.assertIn('全窗口统计未知', empty) blocked = render(payload(replace(item, status='alert', rebalance_conclusion='blocked'))) self.assertNotIn('已核验目标无交易', blocked) self.assertIn('已拦截', blocked) diff --git a/python/tests/test_digest_config_labels.py b/python/tests/test_digest_config_labels.py index cb7038b3..5a68452b 100644 --- a/python/tests/test_digest_config_labels.py +++ b/python/tests/test_digest_config_labels.py @@ -43,7 +43,7 @@ def test_environment_intake_and_projected_label_are_authoritative(self): self.assertEqual(configuration.expected[0]['account_label'], 'paper') text, _ = app._report(configuration, [candidate(kind='order_submitted', order_count=1)], ['present'], '2026-10-15', None) - self.assertIn('Schwab·paper', text) + self.assertIn('Schwab · paper', text) self.assertNotIn('Schwab·old', text) empty = app.Configuration([{**EXPECTED[0], 'account_label': ''}], JOB, 'synthetic', '2026-10-16') self.assertFalse(empty.expected[0].get('account_label')) @@ -65,7 +65,7 @@ def test_binding_projection_to_actual_digest_without_alias_map(self): text, coverage = app._report( app.Configuration(binding, JOB, 'synthetic', '2026-10-16', locale=locale), [item], ['present'], '2026-10-15', None) - self.assertIn('Schwab·paper', text) + self.assertIn('Schwab · paper', text) self.assertNotIn('spoofed', text) self.assertNotIn('00000099', text) self.assertEqual(coverage, 'partial') diff --git a/python/tests/test_notification_consolidation.py b/python/tests/test_notification_consolidation.py index 11fa5e30..10f558e2 100644 --- a/python/tests/test_notification_consolidation.py +++ b/python/tests/test_notification_consolidation.py @@ -148,7 +148,7 @@ def test_utf16_budget_rejects_unfitting_required_account_context_in_both_locales def test_zero_runs_and_verified_zero_trade_runs_keep_scoped_idle_summary(self): with patch.dict(os.environ, ON, clear=True): - self.assertIn("😴 已核验目标无交易", render(payload())) + self.assertNotIn("😴 已核验目标无交易", render(payload())) self.assertIn("😴 已核验目标无交易", render(payload(row()))) def test_localized_schwab_reason_is_run_scoped_and_does_not_infer_fills(self): @@ -309,7 +309,8 @@ def test_zero_runs_distinguishes_unknown_coverage_from_verified_idle(self): idle = candidate(actually_ran=False) idle.pop("notification_event") _, text, _, receipt = build([idle]) - self.assertIn("😴 已核验目标无交易", text) + self.assertIn("已核验范围本窗口未运行", text) + self.assertNotIn("无交易", text) self.assertEqual(receipt["evidence_status"], "verified_idle") def test_missing_expected_binding_is_visible_without_filling_unknown_counts(self): From b4219e15a90987bf9d43a62a5d4d6b4964079821 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:02:38 +0800 Subject: [PATCH 2/4] fix: Address account digest review findings Co-Authored-By: Codex --- docs/unified-daily-notification.md | 3 + python/scripts/account_daily_digest.py | 40 ++- python/scripts/account_daily_facts.py | 2 +- python/scripts/unified_daily_digest.py | 7 +- .../tests/test_account_daily_facts_schema.py | 48 ++++ .../tests/test_daily_caller_presentation.py | 34 +++ schemas/qsl-digest-candidates.v1.schema.json | 268 ++++++++++++++++++ 7 files changed, 386 insertions(+), 16 deletions(-) create mode 100644 python/tests/test_account_daily_facts_schema.py diff --git a/docs/unified-daily-notification.md b/docs/unified-daily-notification.md index 72b9b5a5..88da4e37 100644 --- a/docs/unified-daily-notification.md +++ b/docs/unified-daily-notification.md @@ -82,6 +82,9 @@ 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. +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. diff --git a/python/scripts/account_daily_digest.py b/python/scripts/account_daily_digest.py index ce2b3482..034c1650 100644 --- a/python/scripts/account_daily_digest.py +++ b/python/scripts/account_daily_digest.py @@ -11,7 +11,7 @@ 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 (_execution_blocked, _execution_unconfirmed, _known_count, +from unified_daily_digest import (_account_identity_label, _execution_blocked, _execution_unconfirmed, _known_count, _run_display_contexts, _units, _verified_no_trade, _window_text) @@ -50,6 +50,26 @@ def _symbol(value, unknown): return value if re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.\-^/]{0,19}', value) else unknown +def _account_label(row, bindings, locale): + private_keys = {row.opaque_account_uid, *(binding.get('target_id') for binding, _ in bindings), + *(binding.get('account_hint') for binding, _ in bindings)} + for binding, _ in bindings: + label = binding.get('account_label') + if (isinstance(label, str) and len(label) <= 70 and label.isprintable() + and safe_display_text(label) and label.strip() not in private_keys): + return label + account = _account_identity_label(row, locale) + if account in private_keys: + return '' + if broker_account_identity(row.platform_id, account): + return '' if row.platform_id in {'binance', 'longbridge'} else '***' + account[-2:] + return account + + +def _order_status(order): + return str(order.get('status') or '').strip().lower() + + def render_account_daily(payload, expected, outcomes, *, facts=None, max_units=3850): """Render supplied raw binding outcomes and optional per-account evidence. @@ -85,7 +105,9 @@ def render_account_daily(payload, expected, outcomes, *, facts=None, max_units=3 account_label=binding.get('account_label', ''), account_alias=binding.get('account_alias', ''), account_hint=binding.get('account_hint', ''), account_scope=binding.get('account_scope', ''), execution_mode=binding.get('execution_mode', ''))) - contexts = _run_display_contexts(representatives, locale, runtime_accounts=True) + account_labels = [_account_label(row, group['bindings'], locale) + for row, group in zip(representatives, groups.values(), strict=True)] + contexts = _run_display_contexts(representatives, locale, account_labels=account_labels) all_idle = bool(groups) blocks = [] outcome_text = { @@ -104,14 +126,6 @@ def render_account_daily(payload, expected, outcomes, *, facts=None, max_units=3 name = safe_display_text(display_name('strategies', binding.get('strategy_profile', ''), locale, fallback=fallback)) or fallback if name not in strategies: strategies.append(name) - explicit_label = next((b['account_label'] for b, _ in bindings if safe_display_text(b.get('account_label'))), '') - if explicit_label and explicit_label not in {uid, *(b.get('account_hint') for b, _ in bindings), - *(b.get('target_id') for b, _ in bindings)}: - account = explicit_label - elif account in {uid, *(b.get('target_id') for b, _ in bindings)}: - account = '账户' if zh else 'Account' - elif broker_account_identity(platform, account): - account = ('账户' if zh else 'Account') if platform in {'binance', 'longbridge'} else '***' + account[-2:] header = '🏦 ' + ' · '.join((platform_label, account, ' / '.join(strategies))) details = [] for binding, outcome in bindings: @@ -167,7 +181,7 @@ def strategy_for(record): for order in account_fact.get('orders') or []: if not isinstance(order, dict): continue - status = str(order.get('status') or '').lower() + status = _order_status(order) statuses = {'open': ('未完成', 'open'), 'cancelled': ('已取消', 'cancelled'), 'canceled': ('已取消', 'cancelled'), 'rejected': ('已拒绝', 'rejected'), 'expired': ('已过期', 'expired'), 'filled': ('已成交', 'filled'), @@ -247,10 +261,10 @@ def strategy_for(record): filled = bool(fills) or (not legacy_fill_window_unreliable and any(_known_count(row, 'fill') and row.fill_count > 0 for row in rows)) pending = bool(orders) or any(_known_count(row, 'order') and row.order_count > 0 for row in rows) - partial = any(o.get('status') == 'partially_filled' or + partial = any(_order_status(o) == 'partially_filled' or (_number(o.get('filled_quantity')) and _number(o.get('quantity')) and 0 < o['filled_quantity'] < o['quantity']) for o in orders) - terminal = orders and all(o.get('status') in {'cancelled', 'canceled', 'rejected', 'expired'} for o in orders) + terminal = orders and all(_order_status(o) in {'cancelled', 'canceled', 'rejected', 'expired'} for o in orders) if unknown_fill_window: details.append('结算含已确认成交,发生时段未核实' if zh else 'Settlement confirms fills; reporting window unverified') diff --git a/python/scripts/account_daily_facts.py b/python/scripts/account_daily_facts.py index 434d2e17..b6fdf443 100644 --- a/python/scripts/account_daily_facts.py +++ b/python/scripts/account_daily_facts.py @@ -118,7 +118,7 @@ def _snapshot(raw, end): projected.append({'symbol': symbol, 'quantity': quantity, 'asset_type': asset}) if equity is None and not projected and not complete: return None - return {'as_of': stamp.isoformat(), 'equity': equity, 'equity_currency': currency, + return {'as_of': stamp.astimezone(timezone.utc).isoformat(), 'equity': equity, 'equity_currency': currency, 'holdings': projected, 'holdings_complete': complete} diff --git a/python/scripts/unified_daily_digest.py b/python/scripts/unified_daily_digest.py index 517eff2a..5a666054 100644 --- a/python/scripts/unified_daily_digest.py +++ b/python/scripts/unified_daily_digest.py @@ -85,11 +85,14 @@ def render(payload: DailyDigestInput, *, max_units: int = 3900, source_repositor return "\n".join(lines) -def _run_display_contexts(rows, locale, *, runtime_accounts=False): +def _run_display_contexts(rows, locale, *, runtime_accounts=False, account_labels=None): """Resolve names with existing mappings; ordinals distinguish collisions.""" parts, accounts = [], {} for index, row in enumerate(rows): - account = _account_identity_label(row, locale) if runtime_accounts else _legacy_account_label(row, locale) + if account_labels is None: + account = _account_identity_label(row, locale) if runtime_accounts else _legacy_account_label(row, locale) + else: + account = account_labels[index] account = account or ("账户" if locale == "zh" else "Account") # These protected keys stay internal; display ordinals never replace them. identity = row.opaque_account_uid or row.target_id or row.account_hint or ("row", index) diff --git a/python/tests/test_account_daily_facts_schema.py b/python/tests/test_account_daily_facts_schema.py new file mode 100644 index 00000000..6ff3c92c --- /dev/null +++ b/python/tests/test_account_daily_facts_schema.py @@ -0,0 +1,48 @@ +"""Candidate contract accepts optional account observations and legacy rows.""" +import copy +import json +from pathlib import Path +import unittest + +try: + import jsonschema +except ImportError: + jsonschema = None + + +@unittest.skipIf(jsonschema is None, "jsonschema is not installed") +class AccountDailyFactsSchemaTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + path = Path(__file__).resolve().parents[2] / "schemas/qsl-digest-candidates.v1.schema.json" + schema = json.loads(path.read_text(encoding="utf-8")) + jsonschema.Draft202012Validator.check_schema(schema) + cls.validator = jsonschema.Draft202012Validator(schema, format_checker=jsonschema.FormatChecker()) + cls.legacy = {"schema_version": "qsl.digest_candidates.v1", "runs": [{"platform_id": "example", "strategy_profile": "demo", "actually_ran": True}]} + stamp = "2026-10-16T05:10:00+08:00" + cls.facts = { + "orders": [{"id": "order", "submission_window_start": stamp, "submission_window_end": stamp, + "quantity": 3, "filled_quantity": None, "currency": "USD"}], + "fills": [{"id": "fill", "order_id": "order", "occurred_at": stamp, "quantity": 1, "price": 60}], + "snapshots": [{"as_of": stamp, "equity": 50000, "equity_currency": "USD", + "holdings_scope": "account", "holdings_complete": True, + "holdings": [{"symbol": "TQQQ", "quantity": 300, "asset_type": "stock"}]}], + } + + def test_legacy_and_account_facts_validate(self): + self.validator.validate(self.legacy) + candidate = copy.deepcopy(self.legacy) + candidate["runs"][0]["account_daily_facts"] = self.facts + self.validator.validate(candidate) + + def test_malformed_facts_are_rejected(self): + for invalid in ({"fills": [{"id": "fill"}]}, {"orders": "not-an-array"}, + {"snapshots": [{"as_of": 123}]}, {"unexpected": True}): + with self.subTest(invalid=invalid): + candidate = copy.deepcopy(self.legacy) + candidate["runs"][0]["account_daily_facts"] = invalid + self.assertFalse(self.validator.is_valid(candidate)) + + +if __name__ == "__main__": + unittest.main() diff --git a/python/tests/test_daily_caller_presentation.py b/python/tests/test_daily_caller_presentation.py index d3bbf413..0663f68e 100644 --- a/python/tests/test_daily_caller_presentation.py +++ b/python/tests/test_daily_caller_presentation.py @@ -1,5 +1,6 @@ """Exercise the authenticated caller with synthetic protected candidates.""" from dataclasses import replace +from datetime import datetime, timezone from pathlib import Path import unittest @@ -103,6 +104,16 @@ def test_no_trade_keeps_assets_and_explicit_empty_positions(self): self.assertIn(part, text) self.assertNotIn("全部已核验账户无交易", text) + def test_mixed_case_terminal_status_agrees_with_order_details(self): + for status, detail in (("Cancelled", "cancelled"), ("REJECTED", "rejected"), ("Expired", "expired")): + with self.subTest(status=status): + order = record("closed-order", status=status, filled_quantity=0) + item = with_facts({**ROW, "order_count": 1, "fill_count": 0}, orders=[order]) + text, _ = self.report([item], ["present"], locale="en") + self.assertIn("Orders closed; statuses below", text) + self.assertIn(f" · {detail}", text) + self.assertNotIn("Submitted, fills unconfirmed", text) + def test_simulation_does_not_establish_actual_account_trades(self): item = with_facts({**ROW, "execution_mode": "dry_run", "fill_count": 1}, fills=[record("simulated-fill", price=99)], snapshots=[snapshot()]) @@ -164,6 +175,29 @@ def test_two_accounts_and_multiple_strategies_preserve_one_snapshot_per_account( self.assertIn("策略未知", text) self.assertIn("Second Desk", text) + def test_same_configured_label_keeps_accounts_distinguishable(self): + primary = {**EXPECTED[0], "account_label": "Desk Alpha"} + second = {**primary, "target_id": "other-target", "opaque_account_uid": "other-account", + "candidate_slot": "other-slot"} + third = {**second, "target_id": "third-target", "opaque_account_uid": "third-account", + "candidate_slot": "third-slot", "account_label": "Desk Alpha 1"} + text, _ = self.report([], ["missing"] * 3, locale="en", expected=[primary, second, third]) + self.assertIn("🏦 Schwab · Desk Alpha 2 · ", text) + self.assertIn("🏦 Schwab · Desk Alpha 3 · ", text) + self.assertIn("🏦 Schwab · Desk Alpha 1 · ", text) + self.assertNotIn("other-account", text) + + def test_equal_snapshot_instants_keep_verified_assets_and_holdings(self): + second = {**EXPECTED[0], "target_id": "other-strategy", "strategy_profile": "tqqq_growth_income", + "candidate_slot": "other-strategy"} + equivalent_time = datetime.fromisoformat(TIME).astimezone(timezone.utc).isoformat() + one = with_facts(ROW, snapshots=[snapshot()]) + two = with_facts({**ROW, **{key: second[key] for key in app.IDENTITY}, "source_run_id": "other-run"}, + snapshots=[snapshot(as_of=equivalent_time)]) + text, _ = self.report([one, two], ["present", "present"], locale="en", expected=[EXPECTED[0], second]) + self.assertEqual(text.count("Total assets: USD 50,000.00"), 1) + self.assertEqual(text.count("TQQQ 300 shares"), 1) + def test_budget_discloses_omitted_holdings_without_losing_totals(self): hold = [{"symbol": f"TEST{i}", "quantity": i + 1, "asset_type": "option"} for i in range(70)] binding = {**EXPECTED[0], "account_label": "Desk"} diff --git a/schemas/qsl-digest-candidates.v1.schema.json b/schemas/qsl-digest-candidates.v1.schema.json index a06ccf08..3f0ab437 100644 --- a/schemas/qsl-digest-candidates.v1.schema.json +++ b/schemas/qsl-digest-candidates.v1.schema.json @@ -367,6 +367,274 @@ "notification_event": { "$ref": "#/$defs/notification_event" }, + "account_daily_facts": { + "type": "object", + "description": "Optional observed account details; presence does not establish account-wide coverage.", + "additionalProperties": false, + "properties": { + "orders": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "symbol": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "side": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "strategy": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "asset_type": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "quantity": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0 + }, + "price": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "currency": { + "type": [ + "string", + "null" + ], + "pattern": "^[A-Za-z]{3}$" + }, + "status": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "filled_quantity": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "remaining_quantity": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "average_fill_price": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "occurred_at": { + "type": "string", + "format": "date-time" + }, + "submission_window_start": { + "type": "string", + "format": "date-time" + }, + "submission_window_end": { + "type": "string", + "format": "date-time" + } + }, + "anyOf": [ + { + "required": [ + "occurred_at" + ] + }, + { + "required": [ + "submission_window_start", + "submission_window_end" + ] + } + ] + } + }, + "fills": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "occurred_at" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "symbol": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "side": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "strategy": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "asset_type": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "quantity": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0 + }, + "price": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "currency": { + "type": [ + "string", + "null" + ], + "pattern": "^[A-Za-z]{3}$" + }, + "order_id": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "occurred_at": { + "type": "string", + "format": "date-time" + } + } + } + }, + "snapshots": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "as_of" + ], + "properties": { + "as_of": { + "type": "string", + "format": "date-time" + }, + "equity": { + "type": [ + "number", + "null" + ] + }, + "equity_currency": { + "type": [ + "string", + "null" + ], + "pattern": "^[A-Za-z]{3}$" + }, + "holdings_scope": { + "type": "string" + }, + "holdings_complete": { + "type": "boolean" + }, + "holdings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "symbol", + "quantity", + "asset_type" + ], + "properties": { + "symbol": { + "type": "string", + "minLength": 1 + }, + "quantity": { + "type": "number" + }, + "asset_type": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + }, + "fills_window_unknown": { + "type": "boolean" + }, + "fills_window_mixed": { + "type": "boolean" + } + } + }, "notification_events": { "type": "array", "items": { From e867082de9a76a0b030b6eaafffec7d135b305ef Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:10:45 +0800 Subject: [PATCH 3/4] fix: Preserve complete order evidence in the daily digest Co-Authored-By: Codex --- python/scripts/account_daily_digest.py | 3 ++- python/scripts/account_daily_facts.py | 8 +++---- .../tests/test_daily_caller_presentation.py | 24 +++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/python/scripts/account_daily_digest.py b/python/scripts/account_daily_digest.py index 034c1650..81d1b8c6 100644 --- a/python/scripts/account_daily_digest.py +++ b/python/scripts/account_daily_digest.py @@ -264,7 +264,8 @@ def strategy_for(record): partial = any(_order_status(o) == 'partially_filled' or (_number(o.get('filled_quantity')) and _number(o.get('quantity')) and 0 < o['filled_quantity'] < o['quantity']) for o in orders) - terminal = orders and all(_order_status(o) in {'cancelled', 'canceled', 'rejected', 'expired'} for o in orders) + terminal = (bool(orders) and counts['order'] == len(orders) + and all(_order_status(o) in {'cancelled', 'canceled', 'rejected', 'expired'} for o in orders)) if unknown_fill_window: details.append('结算含已确认成交,发生时段未核实' if zh else 'Settlement confirms fills; reporting window unverified') diff --git a/python/scripts/account_daily_facts.py b/python/scripts/account_daily_facts.py index b6fdf443..51f7199e 100644 --- a/python/scripts/account_daily_facts.py +++ b/python/scripts/account_daily_facts.py @@ -66,7 +66,7 @@ def _execution(raw, row, kind, day): value = _text(raw[name]) if value is None: return None - result[name] = value + result[name] = value.lower() if name == 'status' else value else: result[name] = None for name in (('quantity', 'filled_quantity', 'remaining_quantity', 'price', 'average_fill_price') if kind == 'orders' @@ -85,10 +85,10 @@ def _execution(raw, row, kind, day): else: result['currency'] = None if stamp is not None: - result['occurred_at'] = (stamp.astimezone(timezone.utc) if kind == 'fills' else stamp).isoformat() + result['occurred_at'] = stamp.astimezone(timezone.utc).isoformat() else: - result['submission_window_start'] = interval[0].isoformat() - result['submission_window_end'] = interval[1].isoformat() + result['submission_window_start'] = interval[0].astimezone(timezone.utc).isoformat() + result['submission_window_end'] = interval[1].astimezone(timezone.utc).isoformat() return result diff --git a/python/tests/test_daily_caller_presentation.py b/python/tests/test_daily_caller_presentation.py index 0663f68e..ff3f7a04 100644 --- a/python/tests/test_daily_caller_presentation.py +++ b/python/tests/test_daily_caller_presentation.py @@ -114,6 +114,14 @@ def test_mixed_case_terminal_status_agrees_with_order_details(self): self.assertIn(f" · {detail}", text) self.assertNotIn("Submitted, fills unconfirmed", text) + def test_closed_observed_order_does_not_close_unobserved_orders(self): + order = record("closed-order", status="cancelled", filled_quantity=0) + item = with_facts({**ROW, "order_count": 2, "fill_count": 0}, orders=[order]) + text, _ = self.report([item], ["present"], locale="en") + self.assertIn("Some order details are unavailable", text) + self.assertIn(" · cancelled", text) + self.assertNotIn("Orders closed; statuses below", text) + def test_simulation_does_not_establish_actual_account_trades(self): item = with_facts({**ROW, "execution_mode": "dry_run", "fill_count": 1}, fills=[record("simulated-fill", price=99)], snapshots=[snapshot()]) @@ -198,6 +206,22 @@ def test_equal_snapshot_instants_keep_verified_assets_and_holdings(self): self.assertEqual(text.count("Total assets: USD 50,000.00"), 1) self.assertEqual(text.count("TQQQ 300 shares"), 1) + def test_equivalent_order_instants_and_intervals_keep_order_details(self): + equivalent_time = datetime.fromisoformat(TIME).astimezone(timezone.utc).isoformat() + for interval in (False, True): + with self.subTest(interval=interval): + orders = [] + for stamp, status in ((TIME, "Cancelled"), (equivalent_time, "cancelled")): + order = record("closed-order", status=status, filled_quantity=0, occurred_at=stamp) + if interval: + order.pop("occurred_at") + order.update(submission_window_start=stamp, submission_window_end=stamp) + orders.append(order) + item = with_facts({**ROW, "order_count": 1, "fill_count": 0}, orders=orders) + text, _ = self.report([item], ["present"], locale="en") + self.assertIn("Orders closed; statuses below", text) + self.assertEqual(text.count(" · cancelled"), 1) + def test_budget_discloses_omitted_holdings_without_losing_totals(self): hold = [{"symbol": f"TEST{i}", "quantity": i + 1, "asset_type": "option"} for i in range(70)] binding = {**EXPECTED[0], "account_label": "Desk"} From ead549b298e890dd31861f4670b709761a1177cf Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:17:01 +0800 Subject: [PATCH 4/4] fix: Compare daily holdings independently of row order Co-Authored-By: Codex --- python/scripts/account_daily_facts.py | 1 + .../tests/test_daily_caller_presentation.py | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/python/scripts/account_daily_facts.py b/python/scripts/account_daily_facts.py index 51f7199e..8cdf9c3c 100644 --- a/python/scripts/account_daily_facts.py +++ b/python/scripts/account_daily_facts.py @@ -118,6 +118,7 @@ def _snapshot(raw, end): projected.append({'symbol': symbol, 'quantity': quantity, 'asset_type': asset}) if equity is None and not projected and not complete: return None + projected.sort(key=lambda item: (item['symbol'], item['asset_type'], item['quantity'])) return {'as_of': stamp.astimezone(timezone.utc).isoformat(), 'equity': equity, 'equity_currency': currency, 'holdings': projected, 'holdings_complete': complete} diff --git a/python/tests/test_daily_caller_presentation.py b/python/tests/test_daily_caller_presentation.py index ff3f7a04..ab791902 100644 --- a/python/tests/test_daily_caller_presentation.py +++ b/python/tests/test_daily_caller_presentation.py @@ -206,6 +206,27 @@ def test_equal_snapshot_instants_keep_verified_assets_and_holdings(self): self.assertEqual(text.count("Total assets: USD 50,000.00"), 1) self.assertEqual(text.count("TQQQ 300 shares"), 1) + def test_snapshot_position_order_does_not_mask_real_conflicts(self): + second = {**EXPECTED[0], "target_id": "other-strategy", "strategy_profile": "tqqq_growth_income", + "candidate_slot": "other-strategy"} + holdings = [*snapshot()["holdings"], {"symbol": "BOXX", "quantity": 100, "asset_type": "etf"}] + first = with_facts(ROW, snapshots=[snapshot(holdings=holdings)]) + second_row = {**ROW, **{key: second[key] for key in app.IDENTITY}, "source_run_id": "other-run"} + for change in (False, True): + with self.subTest(change=change): + reordered = list(reversed(holdings)) + if change: + reordered[0] = {**reordered[0], "quantity": 101} + other = with_facts(second_row, snapshots=[snapshot(holdings=reordered)]) + text, _ = self.report([first, other], ["present", "present"], locale="en", + expected=[EXPECTED[0], second]) + if change: + self.assertIn("Total assets: unknown", text) + self.assertIn("Holdings: unknown", text) + else: + for part in ("Total assets: USD 50,000.00", "BOXX 100 shares", "TQQQ 300 shares"): + self.assertEqual(text.count(part), 1) + def test_equivalent_order_instants_and_intervals_keep_order_details(self): equivalent_time = datetime.fromisoformat(TIME).astimezone(timezone.utc).isoformat() for interval in (False, True):