diff --git a/docs/unified-daily-notification.md b/docs/unified-daily-notification.md index 7cfd4744..2380db15 100644 --- a/docs/unified-daily-notification.md +++ b/docs/unified-daily-notification.md @@ -51,6 +51,39 @@ normalizing them before rendering; unsupported or malformed locales fail configuration validation. The default is Chinese. Locale does not change the recipient/date delivery reservation or permit a second daily send. +## Unified operator display + +The consolidated renderer uses 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, +the local account-context changes, and the current file hash. The existing +event and routing snapshots retain their original source revisions. + +Financial observations remain inside their originating active account block. +Required diagnostics retain that account's display header, including when +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 +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. + +Verified idle reports retain the business window, independently known counts, +source coverage and next action. Their result says “No trades in verified +targets”; unbound sources do not support an all-account claim. The one-message +3900 UTF-16-unit budget reserves these sections and reports omitted evidence. +An artificially small budget that cannot contain the mandatory fields fails +with `notification_required_fields_exceed_budget` rather than dropping them. + +Existing receipt, delivery, routing, business-window and execution semantics +are unchanged. QPK risk/guard/receipt templates adopt the same sections at +source level; consumers need separate reviewed adoption. No source PR proves +runtime adoption, a complete data connection or actual notification delivery. + ## IBKR post-submit settlement The optional IBKR settlement feature starts with diff --git a/python/scripts/notification_snapshot/presentation-source.json b/python/scripts/notification_snapshot/presentation-source.json new file mode 100644 index 00000000..e99b57fc --- /dev/null +++ b/python/scripts/notification_snapshot/presentation-source.json @@ -0,0 +1,8 @@ +{ + "repository": "QuantStrategyLab/QuantPlatformKit", + "commit": "fb8e687fb9a5d61694d5ad21591c52247bf8dfa1", + "path": "src/quant_platform_kit/notifications/presentation.py", + "sha256": "dca21d14215dfb9c18fe9798ca9f0fcb17a9482def69adb7df971c280501ad9f", + "upstream_sha256": "e3cfdadb0c138daa2e174ae1a94f43a81abacdd70b2c66b502443da4bc08e999", + "local_changes": "Preserve account context for required diagnostics and optional observations during budget selection." +} diff --git a/python/scripts/notification_snapshot/presentation.py b/python/scripts/notification_snapshot/presentation.py new file mode 100644 index 00000000..b0da7855 --- /dev/null +++ b/python/scripts/notification_snapshot/presentation.py @@ -0,0 +1,102 @@ +"""Shared display sections; no execution, routing, receipt or transport policy.""" +from __future__ import annotations + +from collections.abc import Sequence +from urllib.parse import urlsplit, urlunsplit + +SECTION_NAMES = { + "zh": ("结果", "范围", "证据", "覆盖", "下一步"), + "en": ("Result", "Scope", "Evidence", "Coverage", "Next step"), +} + + +def safe_run_url(value: object) -> str | None: + """Use only caller-supplied HTTPS links, dropping query and fragment.""" + if not isinstance(value, str) or any(c.isspace() or ord(c) < 32 for c in value): + return None + try: + parsed = urlsplit(value) + if parsed.scheme == "https" and parsed.hostname and not parsed.username and not parsed.password: + return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", "")) + except ValueError: + pass + return None + + +def next_step_text(locale: str, *, needs_review: bool) -> str: + if locale == "zh": + return ("核对该次运行、回执和缺失来源;保持现有执行限制。" if needs_review else + "无需操作;等待下一统计窗口。") + return ("Review the source run, receipt and missing sources; retain existing execution restrictions." + if needs_review else "No action required; await the next reporting window.") + + +def render_operator_message(*, title: str, locale: str, result: Sequence[str], + scope: Sequence[str], evidence: Sequence[str], coverage: Sequence[str], + next_step: Sequence[str], max_units: int | None = None, + required_evidence: Sequence[str] = (), + optional_evidence: Sequence[Sequence[str]] = ()) -> str: + """Keep result, scope, coverage and next step before optional evidence. + + Truncation is explicit and cannot remove a section or imply full coverage. + Optional observations correspond to evidence entries in the same order. + Callers put urgent evidence before optional observations. Financial facts + must already have been validated by the producer; this helper infers none. + """ + names = SECTION_NAMES["zh" if locale == "zh" else "en"] + unknown = "未知" if locale == "zh" else "Unknown" + groups = [[str(line) for line in group if line] or [unknown] + for group in (result, scope, evidence, coverage, next_step)] + + def assemble(): + lines = [title] + for name, group in zip(names, groups): + lines.extend([f"【{name}】" if locale == "zh" else f"[{name}]", *group]) + return "\n".join(lines) + + contexts = groups[2] + + def observations(index): + return optional_evidence[index] if index < len(optional_evidence) else () + + groups[2] = [line for index, context in enumerate(contexts) + for line in (context, *observations(index))] + if max_units is None: + return assemble() + if max_units < 256: + raise ValueError("notification_budget_too_small") + if len(assemble().encode("utf-16-le")) // 2 <= max_units: + return assemble() + original = contexts + # Retain caller order and duplicate reasons belonging to distinct accounts. + kept = {index for index, line in enumerate(original) if line in required_evidence} + suffix = "部分证据已省略;请查看运行记录。" if locale == "zh" else "Some evidence omitted; inspect source run." + + def select_evidence(): + groups[2] = [line for index, line in enumerate(original) if index in kept] + [suffix] + + select_evidence() + if len(assemble().encode("utf-16-le")) // 2 > max_units: + raise ValueError("notification_required_fields_exceed_budget") + for index in range(len(original)): + if index in kept: + continue + kept.add(index) + select_evidence() + if len(assemble().encode("utf-16-le")) // 2 > max_units: + kept.remove(index) + # Add observations only after their retained account context, after all + # account summaries have had a chance to fit. + details = {} + for index, context in enumerate(original): + if index not in kept: + continue + for line in observations(index): + details.setdefault(index, []).append(line) + groups[2] = [item for i, entry in enumerate(original) if i in kept + for item in (entry, *details.get(i, ()))] + [suffix] + if len(assemble().encode("utf-16-le")) // 2 > max_units: + details[index].pop() + groups[2] = [item for i, entry in enumerate(original) if i in kept + for item in (entry, *details.get(i, ()))] + [suffix] + return assemble() diff --git a/python/scripts/unified_daily_digest.py b/python/scripts/unified_daily_digest.py index 8251fdd9..a0d68931 100644 --- a/python/scripts/unified_daily_digest.py +++ b/python/scripts/unified_daily_digest.py @@ -6,6 +6,7 @@ from datetime import date, datetime, time from notification_mode import consolidation_enabled, routine_mode from notification_snapshot.unified_contract import SGT, sgt_business_window +from notification_snapshot.presentation import next_step_text, render_operator_message from display_names import broker_account_identity, display_name, runtime_account_names, safe_display_text as _safe_display_text from daily_digest_notify import DailyDigestInput, _presentation_needs_attention, _trade_counts, block_reason_text, counts_not_connected, is_blocked, normalize_locale, routine_decision_text, routine_observation_text @@ -267,8 +268,7 @@ def _render_consolidated(payload: DailyDigestInput, *, max_units: int) -> str: 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"))) - if complete and all(_verified_no_trade(row) for row in payload.runs): - return "😴 今日全部账号无交易" if zh else "😴 No trades across all accounts today" + verified_idle = complete 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 @@ -283,9 +283,15 @@ def _render_consolidated(payload: DailyDigestInput, *, max_units: int) -> str: _execution_unconfirmed(row) for row in payload.runs) else "" lines.append(" · ".join(counts) + confirmation) inventory = [] - for platform, source in summary.get("source_inventory", {}).items(): + sources = summary.get("source_inventory", {}) + platforms = dict.fromkeys(("ibkr", "schwab", "firstrade", "longbridge", "binance", "qrs", *sources)) + for platform in platforms: + source = sources.get(platform) fallback = ("管理站" if zh else "Management console") if platform == "qrs" else "" label = display_name("platforms", platform, locale, fallback=fallback) + if source is None: + inventory.append(f"{label}:来源覆盖未知" if zh else f"{label}: source coverage unknown") + continue expected, covered, runs = source["expected_targets"], source["covered_targets"], source["runs"] if not expected: inventory.append(f"{label}:未纳入本期汇总,运行状态未知" if zh else @@ -306,7 +312,7 @@ def _render_consolidated(payload: DailyDigestInput, *, max_units: int) -> str: identity = row.opaque_account_uid or row.target_id or row.account_hint or ("row", index) accounts.setdefault((row.platform_id, identity), []).append((row, header, strategy)) blocks, diagnostics, diagnostic_observations = [], [], [] - required_diagnostics, compact_pending = set(), {} + required_diagnostics = [] for entries in accounts.values(): rows = [row for row, _, _ in entries] if all(_verified_no_trade(row) for row in rows): @@ -352,15 +358,14 @@ def _render_consolidated(payload: DailyDigestInput, *, max_units: int) -> str: if needs_attention: block.append("需核对" if zh else "Needs review") block.extend(dict.fromkeys(pending)) - required_diagnostics.update(details) - required_diagnostics.update(pending) - compact_pending.update((text, "订单最终状态待确认" if zh else "Final order state unconfirmed") for text in pending) details.extend(pending) optional = list(dict.fromkeys(text for row in rows for text in (routine_decision_text(row, locale), routine_observation_text(row, locale)) if text)) if any(_has_activity(row) for row in rows): optional.extend(_account_observations(rows, locale)) - blocks.append((needs_attention, block, optional)) + # Keep the header and required reasons atomic under truncation. + required = [block[0], *dict.fromkeys(details)] if details else [] + blocks.append((needs_attention, block, optional, required)) else: # Unknown counts are not trading activity. Keep safe diagnostics # without an account header, strategy block or account observations. @@ -368,43 +373,28 @@ def _render_consolidated(payload: DailyDigestInput, *, max_units: int) -> str: diagnostic_observations.extend(optional) if diagnostics or diagnostic_observations: blocks.append((True, ["需关注" if zh else "Diagnostics"] + list(dict.fromkeys(diagnostics)), - list(dict.fromkeys(diagnostic_observations)))) + list(dict.fromkeys(diagnostic_observations)), list(dict.fromkeys(diagnostics)))) blocks.sort(key=lambda item: not item[0]) - footer = [] if complete else ["覆盖不完整,缺失统计保留未知" if zh else "Coverage incomplete; missing counts remain unknown"] - omitted = "部分观察字段已省略" if zh else "Some observations omitted" - # Reserve every account's counts and diagnostics before optional snapshots. - required = lines + [line for _, block, _ in blocks for line in block] + footer - potential_lines = len(required) + len(inventory) + sum(len(optional) for _, _, optional in blocks) + 1 - suffix = f"其余 {potential_lines} 项已省略" if zh else f"{potential_lines} more items omitted" - budget = _units("\n".join(required)) + 1 + max(_units(omitted), _units(suffix)) - observations_omitted = False - for _, block, optional in blocks: - if budget + _units("\n".join(optional)) + bool(optional) <= max_units: - block.extend(optional) - budget += _units("\n".join(optional)) + bool(optional) - elif optional: - observations_omitted = True - lines.extend(block) - lines.extend(footer) - if observations_omitted: - lines.append(omitted) - # Coverage inventory is supplementary and cannot displace trading accounts. - lines.extend(inventory) - # Compact finality before dropping context, and reserve every pending - # diagnostic before accepting dispensable window/count/account lines. - if _units("\n".join(lines)) > max_units: - lines = [compact_pending.get(line, line) for line in lines] - required_diagnostics = {compact_pending.get(line, line) for line in required_diagnostics} - pending_units = sum(_units(line) + 1 for line in lines[1:] if line in required_diagnostics) - result, skipped = [lines[0]], 0 - suffix = f"其余 {len(lines)} 项已省略" if zh else f"{len(lines)} more items omitted" - for line in lines[1:]: - if line in required_diagnostics: - pending_units -= _units(line) + 1 - if _units("\n".join(result + [line, suffix])) + pending_units > max_units: - skipped += 1 - continue - result.append(line) - if skipped: - result.append(f"其余 {skipped} 项已省略" if zh else f"{skipped} more items omitted") - return "\n".join(result) + coverage_lines = ["统计范围:已绑定目标;缺失统计保留未知" if zh else + "Counts cover bound targets; missing counts remain unknown"] + coverage_lines.extend(inventory) + evidence, optional_evidence = [], [] + for _, block, optional, required in blocks: + context = "\n".join(required or block) + evidence.append(context) + optional_evidence.append(([line for line in block if line not in required] + if required else []) + optional) + if required: + required_diagnostics.append(context) + needs_review = (not complete 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") + return render_operator_message( + title=lines[0], locale=locale, result=result, scope=[lines[1]], + evidence=evidence, coverage=coverage_lines, + next_step=[next_step_text(locale, needs_review=needs_review)], max_units=max_units, + required_evidence=required_diagnostics, optional_evidence=optional_evidence, + ) diff --git a/python/tests/test_cloud_run_daily_digest.py b/python/tests/test_cloud_run_daily_digest.py index 78a5c3a8..165682d6 100644 --- a/python/tests/test_cloud_run_daily_digest.py +++ b/python/tests/test_cloud_run_daily_digest.py @@ -139,7 +139,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.assertEqual(self.sent[0], '😴 今日全部账号无交易') + self.assertIn('😴 已核验目标无交易', self.sent[0]) self.assertNotIn('覆盖不完整', self.sent[0]) def test_disabled_real_delivery_does_not_read_or_reserve(self): @@ -221,7 +221,7 @@ 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.assertEqual(self.sent[0], '😴 今日全部账号无交易') + self.assertIn('😴 已核验目标无交易', self.sent[0]) self.assertNotIn('休市', self.sent[0]) def test_retry_keeps_original_report_date_and_rejects_another_day(self): diff --git a/python/tests/test_daily_digest_v3.py b/python/tests/test_daily_digest_v3.py index a7d5f274..bbb8cd64 100644 --- a/python/tests/test_daily_digest_v3.py +++ b/python/tests/test_daily_digest_v3.py @@ -34,12 +34,12 @@ def test_active_accounts_are_flat_and_idle_account_is_omitted(self): for absent in ('00000003', '美股日策略', '定投', '加密小时策略', '提交订单', '单1/成1'): self.assertNotIn(absent, text) - def test_verified_idle_is_exactly_one_line_even_with_cycles_or_observations(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.assertEqual(render(payload(item)), '😴 今日全部账号无交易') - self.assertEqual(render(payload()), '😴 今日全部账号无交易') + self.assertIn('😴 已核验目标无交易', render(payload(item))) + self.assertIn('😴 已核验目标无交易', render(payload())) blocked = render(payload(replace(item, status='alert', rebalance_conclusion='blocked'))) - self.assertNotIn('今日全部账号无交易', blocked) + self.assertNotIn('已核验目标无交易', blocked) self.assertIn('已拦截', blocked) def test_only_provided_observation_fields_are_rendered_without_mutation(self): @@ -66,10 +66,10 @@ def test_multiple_runs_of_one_account_have_one_header_and_summed_counts(self): def test_unknown_evidence_cannot_be_reported_as_idle(self): text = render(payload(row(order_count=1, fill_count=None, fill_count_status='unknown'))) self.assertIn('提交1笔/成交未知笔', text) - self.assertNotIn('今日全部账号无交易', text) + self.assertNotIn('已核验目标无交易', text) from daily_digest_notify import EvidenceCoverage text = render(replace(payload(), evidence_coverage=EvidenceCoverage(candidate_failures=({'reason': 'read_failed'},)))) - self.assertNotIn('今日全部账号无交易', text) + self.assertNotIn('已核验目标无交易', text) self.assertIn('未知', text) def test_holdings_are_sorted_capped_and_counted(self): @@ -151,8 +151,8 @@ def test_accounts_without_known_activity_are_omitted_without_claiming_idle(self) self.assertNotIn('00000003', text) self.assertEqual(text.count('🏦 '), len(active)) self.assertIn('全窗口统计未知' if locale == 'zh' else 'whole-window count unknown', text) - self.assertNotIn('今日全部账号无交易', text) - self.assertNotIn('No trades across all accounts today', text) + self.assertNotIn('已核验目标无交易', text) + self.assertNotIn('No trades in verified targets', text) def test_active_account_retains_unknown_counts_from_another_run(self): active = row(order_count=1, fill_count=0) diff --git a/python/tests/test_digest_config_labels.py b/python/tests/test_digest_config_labels.py index 5efa9d3d..cb7038b3 100644 --- a/python/tests/test_digest_config_labels.py +++ b/python/tests/test_digest_config_labels.py @@ -98,8 +98,7 @@ def test_labels_win_and_masked_fallback_and_collisions_remain(self): self.assertIn('IBKR·sg 2', text) self.assertNotIn('U0000099', text) self.assertIn('IBKR·***99', render(payload(replace(active, account_label='')))) - self.assertEqual(render(payload(replace(active, order_count=0, fill_count=0))), - '😴 今日全部账号无交易') + self.assertIn('😴 已核验目标无交易', render(payload(replace(active, order_count=0, fill_count=0)))) def test_cli_withholds_private_labels_and_fake_ids(self): with patch.dict(os.environ, {'QRS_UNIFIED_NOTIFICATION_ENABLED': 'true'}, clear=True): diff --git a/python/tests/test_notification_consolidation.py b/python/tests/test_notification_consolidation.py index f8d5f3c6..11fa5e30 100644 --- a/python/tests/test_notification_consolidation.py +++ b/python/tests/test_notification_consolidation.py @@ -136,19 +136,20 @@ def test_late_reruns_language_layout_and_force_do_not_send_a_second_summary(self class ConsolidationLanguageTests(unittest.TestCase): - def test_utf16_budget_remains_one_message_with_both_locales(self): + def test_utf16_budget_rejects_unfitting_required_account_context_in_both_locales(self): from dataclasses import replace item = row(order_count=1, status="alert", block_reason=dict(reason_code="strategy_risk_rejected", order_submitted_this_run=False)) with patch.dict(os.environ, ON, clear=True): for locale in ("zh", "en"): - text = render(replace(payload(*(row(order_count=1, account_hint=f"synthetic-{index}", status="alert", block_reason=item.block_reason) for index in range(100))), locale=locale), max_units=256) - self.assertLessEqual(len(text.encode("utf-16-le")) // 2, 256) - self.assertIn("省略" if locale == "zh" else "omitted", text) + # Required failures must retain each account association; an + # impossible budget must fail rather than merge their reasons. + with self.assertRaisesRegex(ValueError, "notification_required_fields_exceed_budget"): + render(replace(payload(*(row(order_count=1, account_hint=f"synthetic-{index}", status="alert", block_reason=item.block_reason) for index in range(100))), locale=locale), max_units=1024) - def test_zero_runs_and_verified_zero_trade_runs_get_one_idle_line(self): + def test_zero_runs_and_verified_zero_trade_runs_keep_scoped_idle_summary(self): with patch.dict(os.environ, ON, clear=True): - self.assertEqual(render(payload()), "😴 今日全部账号无交易") - self.assertEqual(render(payload(row())), "😴 今日全部账号无交易") + self.assertIn("😴 已核验目标无交易", render(payload())) + self.assertIn("😴 已核验目标无交易", render(payload(row()))) def test_localized_schwab_reason_is_run_scoped_and_does_not_infer_fills(self): from dataclasses import replace @@ -223,7 +224,7 @@ def test_archive_resolves_raw_event_counts_in_both_input_orders(self): self.assertEqual(len(p.runs), 1) self.assertEqual(receipt["total_fills"], 0) self.assertFalse(receipt["notification_summary"]["counts_unknown"]) - self.assertEqual(text, "😴 今日全部账号无交易") + self.assertIn("😴 已核验目标无交易", text) self.assertNotIn("全窗口统计未知", text) def test_raw_event_cannot_clear_real_archive_conflict(self): @@ -308,7 +309,7 @@ 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.assertEqual(text, "😴 今日全部账号无交易") + self.assertIn("😴 已核验目标无交易", text) self.assertEqual(receipt["evidence_status"], "verified_idle") def test_missing_expected_binding_is_visible_without_filling_unknown_counts(self): @@ -379,7 +380,7 @@ def test_acknowledged_order_text_keeps_fill_uncertainty_and_independent_issues(s def test_verified_idle_text_does_not_apply_to_unknown_counts(self): _, known, _, _ = build([candidate(execution_status="no_action")]) - self.assertEqual(known, "😴 今日全部账号无交易") + self.assertIn("😴 已核验目标无交易", known) _, unknown, _, _ = build([candidate(execution_status="no_action", fill_count=None)]) self.assertNotIn("无交易", unknown) diff --git a/python/tests/test_notification_producer_roundtrip.py b/python/tests/test_notification_producer_roundtrip.py index 9719a3dd..376f5c1e 100644 --- a/python/tests/test_notification_producer_roundtrip.py +++ b/python/tests/test_notification_producer_roundtrip.py @@ -112,7 +112,7 @@ def test_historical_producer_orders_remain_routine_daily_records(self): self.assertEqual(sends, []) self.assertFalse(summary["counts_unknown"]) self.assertEqual(receipt["total_fills"], 0) - self.assertEqual(text, "😴 今日全部账号无交易") + self.assertIn("😴 已核验目标无交易", text) self.assertNotIn("全窗口统计未知", text) orders = [{"status": "filled", "broker_order_id": "synthetic-order"}] real, sends = self.produce([("order-run", orders), ("order-run", orders)]) diff --git a/python/tests/test_notify_projection.py b/python/tests/test_notify_projection.py index 1f22f6d9..f6b7783f 100644 --- a/python/tests/test_notify_projection.py +++ b/python/tests/test_notify_projection.py @@ -36,13 +36,13 @@ def test_unconfirmed_status_is_visible_even_with_observed_fills(self): def test_zero_counts_do_not_hide_an_inner_failure(self): with patch.dict(os.environ, {"QRS_UNIFIED_NOTIFICATION_ENABLED": "true"}, clear=True): text = render(payload(row(status="alert", execution_status="risk_blocked"))) - self.assertNotIn("今日全部账号无交易", text) + self.assertNotIn("已核验目标无交易", text) self.assertIn("已拦截", text) def test_truncation_cannot_separate_counts_from_unconfirmed_status(self): for unified in ("false", "true"): for locale in ("zh", "en"): - for budget in (256, 300, 3900): + for budget in ((1024, 3900) if unified == "true" else (256, 300, 3900)): with self.subTest(unified=unified, locale=locale, budget=budget), patch.dict(os.environ, {"QRS_UNIFIED_NOTIFICATION_ENABLED": unified}, clear=True): text = render(replace(payload(row(order_count=1, fill_count=1, @@ -169,7 +169,7 @@ def test_short_budget_retains_execution_and_evidence_failure_diagnostics(self): ("evidence_delivery_status", "delivery_failed", "证据传递失败", "Evidence delivery failed"), ("evidence_persistence_status", "persistence_failed", "证据留存失败", "Evidence persistence failed")): for locale, diagnostic in (("zh", zh), ("en", en)): - for budget in (300, 3900): + for budget in (1024, 3900): with self.subTest(axis=axis, locale=locale, budget=budget), patch.dict(os.environ, {"QRS_UNIFIED_NOTIFICATION_ENABLED": "true"}, clear=True): text = render(replace(payload(row(order_count=1, fill_count=1, @@ -189,7 +189,7 @@ def test_blocked_submitted_run_keeps_each_evidence_failure_within_budget(self): ) for evidence in failures: for locale in ("en", "zh"): - for budget in (300, 3900): + for budget in (1024, 3900): for intake in (False, True): with self.subTest(evidence=evidence, locale=locale, budget=budget, intake=intake), patch.dict( os.environ, {"QRS_UNIFIED_NOTIFICATION_ENABLED": "true"}, clear=True): diff --git a/python/tests/test_operator_format.py b/python/tests/test_operator_format.py new file mode 100644 index 00000000..016b5dd6 --- /dev/null +++ b/python/tests/test_operator_format.py @@ -0,0 +1,42 @@ +"""Unified display contract using synthetic producer evidence only.""" +import hashlib +import json +import unittest +from dataclasses import replace +from pathlib import Path +from unittest.mock import patch + +from test_notification_consolidation import ON, build, candidate +from unified_daily_digest import render + + +class OperatorFormatTests(unittest.TestCase): + def test_six_sources_and_five_sections_remain_visible_on_idle_day(self): + _, text, _, _ = build([candidate()]) + for section in ("结果", "范围", "证据", "覆盖", "下一步"): + self.assertIn(f"【{section}】", text) + for platform in ("IBKR", "Schwab", "Firstrade", "LongBridge", "Binance", "管理站"): + self.assertIn(platform, text) + self.assertIn("未纳入本期汇总,运行状态未知", text) + self.assertIn("已核验目标无交易", text) + self.assertNotIn("全部账号无交易", text) + + def test_unknown_counts_are_not_zero_and_locale_keeps_the_same_facts(self): + p, _, _, receipt = build([candidate(order_count=None, fill_count=None, + field_status={"order_count": "unknown", "fill_count": "unknown", "cycle_count": "known"})]) + for locale in ("zh", "en"): + with self.subTest(locale=locale), patch.dict("os.environ", ON, clear=True): + text = render(replace(p, locale=locale)) + self.assertIn("全窗口统计未知" if locale == "zh" else "whole-window count unknown", text) + self.assertNotIn("订单:0" if locale == "zh" else "Orders: 0", text) + self.assertIn("【下一步】" if locale == "zh" else "[Next step]", text) + self.assertIsNone(receipt["total_fills"]) + + def test_presentation_snapshot_matches_its_separate_source_manifest(self): + root = Path(__file__).resolve().parents[1] / "scripts/notification_snapshot" + source = json.loads((root / "presentation-source.json").read_text()) + self.assertEqual(source["repository"], "QuantStrategyLab/QuantPlatformKit") + self.assertRegex(source["commit"], r"^[a-f0-9]{40}$") + self.assertRegex(source["upstream_sha256"], r"^[a-f0-9]{64}$") + self.assertIn("account context", source["local_changes"]) + self.assertEqual(hashlib.sha256((root / "presentation.py").read_bytes()).hexdigest(), source["sha256"]) diff --git a/python/tests/test_routine_notification_contract.py b/python/tests/test_routine_notification_contract.py index 56b8e829..3ec0aa9d 100644 --- a/python/tests/test_routine_notification_contract.py +++ b/python/tests/test_routine_notification_contract.py @@ -124,7 +124,7 @@ def test_each_plan_keeps_its_own_amount_and_duplicate_observations_are_displayed payload, text, _ = self.render([row(routine_decision=first, routine_decisions=[first, second, first])], locale=locale, unified=unified) if unified: - self.assertEqual(text, "😴 今日全部账号无交易" if locale == "zh" else "😴 No trades across all accounts today") + self.assertIn("😴 已核验目标无交易" if locale == "zh" else "😴 No trades in verified targets", text) else: self.assertEqual(text.count("12.5 USD"), 1) self.assertEqual(text.count("17.5 HKD"), 1) diff --git a/python/tests/test_unified_daily_digest_presentation.py b/python/tests/test_unified_daily_digest_presentation.py index d36b7898..3263df40 100644 --- a/python/tests/test_unified_daily_digest_presentation.py +++ b/python/tests/test_unified_daily_digest_presentation.py @@ -6,7 +6,7 @@ import unittest from unittest.mock import patch -from daily_digest_notify import DailyDigestInput, DigestRunEntry, EvidenceCoverage +from daily_digest_notify import DailyDigestInput, DigestHolding, DigestRunEntry, EvidenceCoverage from unified_daily_digest import render @@ -102,6 +102,48 @@ def test_same_platform_accounts_and_strategies_are_distinguishable_without_mutat self.assertNotIn(item.target_id, text) self.assertNotIn(item.source_run_id, text) + def test_financial_observations_stay_in_their_account_block(self): + runs = (row(equity=100, equity_currency="USD", + holdings=(DigestHolding("SYN_A", quantity=1),)), + row("synthetic-b", equity=900, equity_currency="USD", + holdings=(DigestHolding("SYN_B", quantity=9),))) + for locale in ("zh", "en"): + with self.subTest(locale=locale): + text = render(payload(*runs, locale=locale)) + blocks = text.split("🏦 ")[1:] + self.assertEqual(len(blocks), 2) + self.assertIn("USD 100.00", blocks[0]) + self.assertIn("SYN_A", blocks[0]) + self.assertNotIn("SYN_B", blocks[0]) + self.assertIn("USD 900.00", blocks[1]) + self.assertIn("SYN_B", blocks[1]) + self.assertNotIn("SYN_A", blocks[1]) + for item in runs: + for private in (item.account_hint, item.opaque_account_uid, item.target_id, item.source_run_id): + self.assertNotIn(private, text) + + def test_required_diagnostics_keep_account_context_with_and_without_truncation(self): + runs = (row(rebalance_conclusion="blocked", + block_reason={"reason_code": "consecutive_loss_limit_exceeded", + "observed_value": 6, "limit": 5}), + row("synthetic-b", evidence_persistence_status="persistence_failed")) + for locale in ("zh", "en"): + for budget in (3900, 1024): + with self.subTest(locale=locale, budget=budget): + text = render(payload(*runs, *(row("synthetic-extra-" + str(i)) + for i in range(20)), locale=locale), max_units=budget) + blocks = text.split("🏦 ")[1:] + self.assertGreaterEqual(len(blocks), 2) + reason = "连续亏损 6 次" if locale == "zh" else "Consecutive losses 6" + evidence = "证据留存失败" if locale == "zh" else "Evidence persistence failed" + self.assertIn(reason, blocks[0]) + self.assertNotIn(evidence, blocks[0]) + self.assertIn(evidence, blocks[1]) + self.assertNotIn(reason, blocks[1]) + self.assertLessEqual(len(text.encode("utf-16-le")) // 2, budget) + if budget == 1024: + self.assertIn("省略" if locale == "zh" else "omitted", text) + def test_selected_locale_reaches_alias_and_strategy_for_variants(self): mapping = {"accounts": {"schwab": {"synthetic-a": {"zh": "00000011", "en": "Account 00000011"}}}} with self.display_names(mapping): @@ -163,7 +205,7 @@ def test_private_looking_or_invalid_alias_values_fall_back_safely(self): self.assertIn("账户", text) if isinstance(value, str): self.assertNotIn(value, text) - self.assertNotIn("nan", text.lower()) + self.assertNotRegex(text.lower(), r"(?