From da0f32a1891a113bf4bd9add4acdfa2198284581 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Mon, 2 Nov 2026 04:06:21 +0800 Subject: [PATCH] fix(notifications): render single daily-digest PAPER preview Replace the six-message PAPER notification preview pack with one consolidated message rendered by the pinned QRS daily-digest snapshot. Vendor the missing display_names.json into the snapshot with a provenance entry. The workflow_dispatch input `send` defaults to false and only prints the preview to the job log; GCP auth steps now run only when sending. --- .../workflows/paper-notification-preview.yml | 15 +- scripts/send_paper_notification_preview.py | 235 ++++++----------- .../qrs_notification_716/display_names.json | 241 ++++++++++++++++++ .../qrs_notification_716/provenance.json | 5 + tests/test_paper_notification_preview.py | 159 +++++++----- 5 files changed, 422 insertions(+), 233 deletions(-) create mode 100644 tests/fixtures/qrs_notification_716/display_names.json diff --git a/.github/workflows/paper-notification-preview.yml b/.github/workflows/paper-notification-preview.yml index d228859..a6e7e1e 100644 --- a/.github/workflows/paper-notification-preview.yml +++ b/.github/workflows/paper-notification-preview.yml @@ -2,6 +2,12 @@ name: PAPER Notification Preview on: workflow_dispatch: + inputs: + send: + description: Send the rendered preview message to Telegram (false prints the preview to the job log only) + required: true + type: boolean + default: false permissions: contents: read @@ -12,7 +18,7 @@ concurrency: jobs: preview: - name: Send PAPER Telegram notification preview pack + name: Render PAPER notification preview (optional send) runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -25,6 +31,7 @@ jobs: # Production runtime target stays fail-closed for this manual preview path. RUNTIME_TARGET_ENABLED: "false" NOTIFY_LANG: ${{ vars.NOTIFY_LANG || 'zh' }} + PREVIEW_SEND: ${{ inputs.send }} GLOBAL_TELEGRAM_CHAT_ID: ${{ secrets.GLOBAL_TELEGRAM_CHAT_ID }} TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }} @@ -38,14 +45,14 @@ jobs: uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - name: Authenticate to Google Cloud for existing Telegram secret access - if: ${{ env.TELEGRAM_TOKEN == '' && env.TELEGRAM_TOKEN_SECRET_NAME != '' }} + if: ${{ inputs.send == true && env.TELEGRAM_TOKEN == '' && env.TELEGRAM_TOKEN_SECRET_NAME != '' }} uses: google-github-actions/auth@v3 with: workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }} - name: Set up gcloud for existing Telegram secret access - if: ${{ env.TELEGRAM_TOKEN == '' && env.TELEGRAM_TOKEN_SECRET_NAME != '' }} + if: ${{ inputs.send == true && env.TELEGRAM_TOKEN == '' && env.TELEGRAM_TOKEN_SECRET_NAME != '' }} uses: google-github-actions/setup-gcloud@v3 with: project_id: ${{ env.GCP_PROJECT_ID }} @@ -55,5 +62,5 @@ jobs: set -euo pipefail uv sync --frozen --no-dev - - name: Send PAPER notification preview pack + - name: Render PAPER notification preview (sends only when requested) run: uv run --no-sync python scripts/send_paper_notification_preview.py diff --git a/scripts/send_paper_notification_preview.py b/scripts/send_paper_notification_preview.py index 0262ab7..3d82d4f 100644 --- a/scripts/send_paper_notification_preview.py +++ b/scripts/send_paper_notification_preview.py @@ -1,10 +1,16 @@ #!/usr/bin/env python3 -"""Send a bounded FirstradePlatform PAPER Telegram notification preview pack. - -Renders synthetic compact messages via existing notification renderer, -translator, and Telegram sender. Does not trade, read Firstrade accounts, -positions, or quotes; does not import or call broker/order/Cloud Run -production interfaces; and does not change production configuration. +"""Render or send ONE FirstradePlatform PAPER Telegram notification preview. + +The preview renders a single consolidated synthetic message in the unified +daily-digest format (the QuantSentinel daily digest contract) by reusing the +pinned QRS digest renderer snapshot in ``tests/fixtures/qrs_notification_716``. +With ``--send`` the workflow delivers that one message through the existing +Telegram sender; without ``--send`` it prints the rendered message to the job +log and sends nothing. + +Does not trade, read Firstrade accounts, positions, or quotes; does not import +or call broker/order/Cloud Run production interfaces; and does not change +production configuration. """ from __future__ import annotations @@ -13,22 +19,24 @@ import os import subprocess import sys +from datetime import datetime from pathlib import Path -from typing import Any +from zoneinfo import ZoneInfo ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from notifications.telegram import ( # noqa: E402 - build_sender, - build_strategy_display_name, - build_translator, - render_cycle_notification, -) +from notifications.telegram import build_sender # noqa: E402 -_MAX_PREVIEW_MESSAGES = 6 +_QRS_SNAPSHOT = ROOT / "tests" / "fixtures" / "qrs_notification_716" +_PREVIEW_PLATFORM_ID = "firstrade" _PREVIEW_STRATEGY_PROFILE = "tqqq_growth_income" +_PREVIEW_EXTRA_LINES = ( + "🧪 【PREVIEW】PAPER notification preview", + "synthetic / 合成样例 · 不会下单 · No order will be placed", +) +_SHANGHAI = ZoneInfo("Asia/Shanghai") def _require_supported_preview_profile(profile: str) -> str: @@ -38,11 +46,6 @@ def _require_supported_preview_profile(profile: str) -> str: if profile not in SUPPORTED_STRATEGY_PROFILES: raise RuntimeError(f"preview strategy profile {profile!r} is not enabled on Firstrade") return profile -_PREVIEW_EXTRA_LINES = ( - "🧪 【PREVIEW】PAPER notification preview", - "synthetic / 合成样例 · 不会下单 · No order will be placed", -) -_SYNTHETIC_SYMBOL = "PREVIEW" def _resolve_locale(raw: str | None = None) -> str: @@ -87,151 +90,46 @@ def resolve_telegram_chat_id() -> str: return chats[0] if chats else "" -def _with_preview_markers(body: str) -> str: - return "\n".join(("[PAPER]", body, *_PREVIEW_EXTRA_LINES)) - +def _digest_renderer(): + """Load the pinned QRS daily-digest renderer snapshot (a pure formatter).""" + snapshot = str(_QRS_SNAPSHOT) + if snapshot not in sys.path: + sys.path.insert(0, snapshot) + import daily_digest_notify -def _empty_portfolio() -> dict[str, Any]: - return { - "total_equity": 0.0, - "liquid_cash": 0.0, - "portfolio_rows": (), - "market_values": {}, - "quantities": {}, - } + return daily_digest_notify -def _base_result(*, dry_run_only: bool, strategy_display_name: str) -> dict[str, Any]: - return { - "account": "PAPER", - "strategy_profile": _PREVIEW_STRATEGY_PROFILE, - "strategy_display_name": strategy_display_name, - "dry_run_only": dry_run_only, - "portfolio": _empty_portfolio(), - "allocation": {"targets": {}}, - "execution": {"cash_only_execution": True}, - "submitted_orders": [], - "skipped_orders": [], - } +def _default_business_day() -> str: + return datetime.now(_SHANGHAI).date().isoformat() -def build_preview_messages(*, locale: str | None = None) -> list[str]: - """Build at most six synthetic compact PAPER preview messages.""" +def build_preview_message(*, locale: str | None = None, business_day: str | None = None) -> str: + """Render ONE synthetic message in the unified daily-digest format.""" _require_supported_preview_profile(_PREVIEW_STRATEGY_PROFILE) resolved_locale = _resolve_locale(locale) - translator = build_translator(resolved_locale) - strategy_name = build_strategy_display_name(resolved_locale, translator)( - _PREVIEW_STRATEGY_PROFILE, - fallback_name="TQQQ Growth Income", - ) - account_line = translator("account_label", account="PAPER") - - heartbeat = render_cycle_notification( - { - **_base_result(dry_run_only=True, strategy_display_name=strategy_name), - }, - lang=resolved_locale, - ).compact_text - - dry_run = render_cycle_notification( - { - **_base_result(dry_run_only=True, strategy_display_name=strategy_name), - "portfolio": { - "total_equity": 0.0, - "liquid_cash": 0.0, - "portfolio_rows": ((_SYNTHETIC_SYMBOL,),), - "market_values": {_SYNTHETIC_SYMBOL: 0.0}, - "quantities": {_SYNTHETIC_SYMBOL: 0}, - }, - "allocation": {"targets": {_SYNTHETIC_SYMBOL: 100.0}}, - "submitted_orders": [ - { - "side": "buy", - "symbol": _SYNTHETIC_SYMBOL, - "quantity": 0, - "order_type": "limit", - "limit_price": 0, - } - ], - }, - lang=resolved_locale, - ).compact_text - - pending = render_cycle_notification( - { - **_base_result(dry_run_only=False, strategy_display_name=strategy_name), - "allocation": {"targets": {_SYNTHETIC_SYMBOL: 100.0}}, - "submitted_orders": [ - { - "side": "buy", - "symbol": _SYNTHETIC_SYMBOL, - "quantity": 0, - "order_type": "limit", - "limit_price": 0, - "broker_order_id": "preview-synthetic-pending", - } - ], - }, - lang=resolved_locale, - ).compact_text - pending = "\n".join((pending, "synthetic PREVIEW pending confirmation / 订单待确认")) - - filled_order = ( - f"📈 {translator('order_type_market')}{translator('side_buy')} {_SYNTHETIC_SYMBOL}: " - f"{translator('quantity_shares', quantity='0')}" - f"{translator('order_id_suffix', order_id='preview-synthetic-filled')}" - ) - filled = "\n".join( - ( - translator("rebalance_title"), - translator("strategy_label", name=strategy_name), - translator("dry_run_banner"), - filled_order, - "synthetic PREVIEW filled / 成交确认", - ) + digest = _digest_renderer() + payload = digest.DailyDigestInput( + business_day=business_day or _default_business_day(), + locale=resolved_locale, + runs=( + digest.DigestRunEntry( + platform_id=_PREVIEW_PLATFORM_ID, + strategy_profile=_PREVIEW_STRATEGY_PROFILE, + status="ok", + order_count=0, + fill_count=0, + cycle_count=1, + ), + ), ) - - rejected = render_cycle_notification( - { - **_base_result(dry_run_only=True, strategy_display_name=strategy_name), - "allocation": {"targets": {_SYNTHETIC_SYMBOL: 100.0}}, - "skipped_orders": [ - {"symbol": _SYNTHETIC_SYMBOL, "reason": "broker_rejected"}, - ], - }, - lang=resolved_locale, - ).compact_text - rejected = "\n".join((rejected, "synthetic PREVIEW reject / 拒单异常")) - - unknown_status = "\n".join( - ( - translator("runtime_failure_title"), - translator("strategy_label", name=strategy_name), - account_line, - translator("dry_run_banner"), - f"status={translator('strategy_plugin_route_unknown_route')}", - "synthetic PREVIEW unknown status / 未知状态", - ) - ) - - messages = [ - _with_preview_markers(heartbeat), - _with_preview_markers(dry_run), - _with_preview_markers(pending), - _with_preview_markers(filled), - _with_preview_markers(rejected), - _with_preview_markers(unknown_status), - ] - if len(messages) > _MAX_PREVIEW_MESSAGES: - raise RuntimeError( - f"preview message count {len(messages)} exceeds cap {_MAX_PREVIEW_MESSAGES}" - ) - return messages + body = digest.render_daily_digest(payload) + return "\n".join(("[PAPER]", body, *_PREVIEW_EXTRA_LINES)) def send_preview(*, locale: str | None = None, send_fn=None, requests_module=None) -> bool: - messages = build_preview_messages(locale=locale) + message = build_preview_message(locale=locale) token = resolve_telegram_token() chat_id = resolve_telegram_chat_id() if not token or not chat_id: @@ -242,22 +140,31 @@ def send_preview(*, locale: str | None = None, send_fn=None, requests_module=Non return False sender = send_fn or build_sender(token, chat_id, requests_module=requests_module) - for message in messages: - if not sender(message): - print("Notification preview delivery failed.", file=sys.stderr) - return False + if not sender(message): + print("Notification preview delivery failed.", file=sys.stderr) + return False return True def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( - description="Send a bounded FirstradePlatform PAPER Telegram notification preview pack." + description=( + "Render (or send with --send) one FirstradePlatform PAPER " + "notification preview in the unified daily-digest format." + ) ) parser.add_argument( "--locale", default=os.environ.get("NOTIFY_LANG"), help="Optional notification locale override (zh/en). Defaults to NOTIFY_LANG.", ) + parser.add_argument( + "--send", + action="store_true", + default=(os.environ.get("PREVIEW_SEND") or "").strip().lower() + in {"1", "true", "yes", "on"}, + help="Send the rendered preview message to Telegram. Default: print only.", + ) args = parser.parse_args(argv) # Fail closed: this path never enables a production runtime target. @@ -274,12 +181,22 @@ def main(argv: list[str] | None = None) -> int: ) return 1 + message = build_preview_message(locale=args.locale) + if not args.send: + print("Rendered PAPER notification preview (send=false; nothing sent):") + print(message) + print( + "Preview rendered only: one consolidated unified daily-digest " + "message; no Telegram delivery." + ) + return 0 + delivered = send_preview(locale=args.locale) if not delivered: return 1 print( - "Notification preview delivered bounded synthetic PAPER pack " - f"(at most {_MAX_PREVIEW_MESSAGES} messages; no orders)." + "Notification preview delivered: one consolidated synthetic unified " + "daily-digest message (no orders)." ) return 0 diff --git a/tests/fixtures/qrs_notification_716/display_names.json b/tests/fixtures/qrs_notification_716/display_names.json new file mode 100644 index 0000000..5f2e628 --- /dev/null +++ b/tests/fixtures/qrs_notification_716/display_names.json @@ -0,0 +1,241 @@ +{ + "account_aliases": { + "hk": { + "en": "HK", + "zh": "香港" + }, + "live": { + "en": "live", + "zh": "实盘" + }, + "paper": { + "en": "paper", + "zh": "模拟" + }, + "sg": { + "en": "SG", + "zh": "新加坡" + }, + "soxl": { + "en": "SOXL", + "zh": "SOXL" + }, + "tqqq": { + "en": "TQQQ", + "zh": "TQQQ" + } + }, + "accounts": { + "binance": { + "Binance": { + "en": "live", + "zh": "实盘" + }, + "binance": { + "en": "live", + "zh": "实盘" + }, + "crypto_combo": { + "en": "live", + "zh": "实盘" + }, + "default": { + "en": "live", + "zh": "实盘" + } + } + }, + "domains": { + "cn_equity": { + "en": "CN A-share", + "zh": "A股" + }, + "crypto": { + "en": "Crypto", + "zh": "加密" + }, + "hk_equity": { + "en": "HK Equity", + "zh": "港股" + }, + "us_equity": { + "en": "US Equity", + "zh": "美股" + } + }, + "lanes": { + "dry_run": { + "en": "dry run", + "zh": "演练" + }, + "live": { + "en": "live", + "zh": "实盘" + }, + "paper": { + "en": "paper", + "zh": "模拟" + }, + "research": { + "en": "research", + "zh": "研究" + }, + "shadow": { + "en": "shadow", + "zh": "影子" + } + }, + "platforms": { + "binance": "Binance", + "firstrade": "Firstrade", + "ibkr": "IBKR", + "longbridge": "LongBridge", + "qmt": "QMT", + "schwab": "Schwab" + }, + "senders": { + "quant_sentinel": { + "en": "QuantSentinel", + "zh": "量化哨兵" + } + }, + "strategies": { + "cn_chinext_growth_momentum_quality": { + "en": "CN ChiNext Growth Momentum Quality", + "zh": "创业板成长动量质量" + }, + "cn_chinext_growth_momentum_quality_snapshot": { + "en": "CN ChiNext Growth Quality Snapshot", + "zh": "创业板成长质量快照" + }, + "cn_chinext_tactical_rotation": { + "en": "CN ChiNext Tactical Rotation", + "zh": "创业板战术轮动" + }, + "cn_csi500_multi_factor_snapshot": { + "en": "CN CSI 500 Multi-Factor Snapshot", + "zh": "A股中证500多因子" + }, + "cn_dividend_quality_snapshot": { + "en": "CN Dividend Quality", + "zh": "A股红利质量" + }, + "cn_equity_combo": { + "en": "CN Alpha Combo", + "zh": "A股进取组合" + }, + "cn_index_etf_tactical_rotation": { + "en": "CN Index ETF Tactical Rotation", + "zh": "A股宽基ETF战术轮动" + }, + "cn_industry_etf_rotation": { + "en": "CN Industry ETF Rotation", + "zh": "A股行业ETF轮动" + }, + "cn_industry_etf_rotation_aggressive": { + "en": "CN ETF Rotation", + "zh": "A股ETF轮动" + }, + "cn_star_growth_momentum_quality": { + "en": "CN STAR Growth Momentum Quality", + "zh": "科创板成长动量质量" + }, + "cn_stock_momentum_rotation": { + "en": "CN Stock Momentum Rotation", + "zh": "A股股票动量轮动" + }, + "crypto_btc_dca": { + "en": "BTC DCA", + "zh": "BTC定投" + }, + "crypto_equity_combo": { + "en": "Crypto Core Combo", + "zh": "加密动量组合" + }, + "crypto_leader_rotation": { + "en": "Crypto Leader Rotation", + "zh": "加密领涨轮动" + }, + "crypto_live_pool_rotation": { + "en": "Crypto Pool Rotation", + "zh": "加密池轮动" + }, + "crypto_trend_rotation": { + "en": "Altcoin Trend", + "zh": "山寨趋势轮动" + }, + "global_etf_confidence_vol_gate": { + "en": "Global ETF Confidence Vol Gate", + "zh": "全球 ETF 置信波动门控" + }, + "global_etf_rotation": { + "en": "Global ETF Rotation", + "zh": "全球ETF轮动" + }, + "hk_equity_combo": { + "en": "HK Core Combo", + "zh": "港股恒生组合" + }, + "hk_global_etf_tactical_rotation": { + "en": "HK ETF Tactical Rotation", + "zh": "港股ETF战术轮动" + }, + "hk_low_vol_dividend_quality_snapshot": { + "en": "HK Dividend Quality", + "zh": "港股红利质量" + }, + "ibit_smart_dca": { + "en": "IBIT Bitcoin DCA", + "zh": "IBIT比特币定投" + }, + "nasdaq_sp500_smart_dca": { + "en": "NASDAQ/S&P 500 DCA", + "zh": "纳指标普定投" + }, + "qqq_tech_enhancement": { + "en": "Tech/Communication Pullback Enhancement", + "zh": "科技通信回调增强" + }, + "russell_top50_leader_rotation": { + "en": "Russell Top50 Leaders", + "zh": "罗素Top50领涨" + }, + "soxl_soxx_core_only_p2_v7_longterm_compounding_cash_reserve": { + "en": "SOXL/SOXX Core Compounding (Cash Reserve)", + "zh": "SOXL/SOXX 核心复利(现金保留)" + }, + "soxl_soxx_trend_income": { + "en": "Semiconductor Trend Income", + "zh": "半导体趋势收益" + }, + "tech_communication_pullback_enhancement": { + "en": "Tech/Communication Pullback Enhancement", + "zh": "科技通信回调增强" + }, + "tecl_xlk_trend_income": { + "en": "TECL/XLK Trend Income", + "zh": "TECL/XLK趋势收益" + }, + "tqqq_growth_income": { + "en": "NASDAQ Growth Income", + "zh": "纳斯达克增长收益" + }, + "us_equity_combo": { + "en": "US Core Income Combo", + "zh": "美股核心收益组合" + }, + "us_equity_combo_core": { + "en": "US Core Combo", + "zh": "美股核心组合" + }, + "us_equity_combo_leveraged": { + "en": "US Alpha Combo", + "zh": "美股加速组合" + } + }, + "strategy_aliases": { + "bitcoin_etf_smart_dca": "ibit_smart_dca", + "global_etf_confidence_vol_gate": "global_etf_rotation", + "global_macro_etf_rotation": "global_etf_rotation" + } +} diff --git a/tests/fixtures/qrs_notification_716/provenance.json b/tests/fixtures/qrs_notification_716/provenance.json index fee62d8..60095c7 100644 --- a/tests/fixtures/qrs_notification_716/provenance.json +++ b/tests/fixtures/qrs_notification_716/provenance.json @@ -33,6 +33,11 @@ "source_sha256": "20e26298cf48c77431257a4d73d2c9ef5ce0eda2a88323a23852d0119bdacf8f", "vendored_sha256": "9483eaa55ee5afb66ea373a2a98df4b61a1295e95342c1e7b4f9c73faf272a16" }, + "display_names.json": { + "source_path": "python/scripts/display_names.json", + "source_sha256": "b453f762fff0186342c4eb2b60e95a24cce56bf988d6cfaeea47afe8b8077b54", + "vendored_sha256": "b453f762fff0186342c4eb2b60e95a24cce56bf988d6cfaeea47afe8b8077b54" + }, "notification_mode.py": { "source_path": "python/scripts/notification_mode.py", "source_sha256": "4f3a77af9b677095589c69f976ea0ecf633322b2b41c8eb18457afdaec87d2df", diff --git a/tests/test_paper_notification_preview.py b/tests/test_paper_notification_preview.py index 87aff75..d997721 100644 --- a/tests/test_paper_notification_preview.py +++ b/tests/test_paper_notification_preview.py @@ -28,57 +28,47 @@ "runtime_execution_policy", ) +_FORBIDDEN_TEXT = ( + "api.telegram.org", + "https://", + "Traceback", + "FIRSTRADE_", + "secret-token", +) + + +def test_build_preview_message_renders_single_unified_daily_digest(): + message = preview.build_preview_message(locale="zh", business_day="2026-11-02") + + assert isinstance(message, str) + assert message.count("[PAPER]") == 1 + assert message.startswith("[PAPER]") + + # Unified daily-digest fields (QuantSentinel daily digest contract, zh). + assert "📡 量化哨兵 · 心跳" in message + assert "业务日: 2026-11-02" in message + assert "今日有运行且已验证零成交。" in message + assert "【当日已运行】" in message + assert "· firstrade / 纳斯达克增长收益 · 周期 1 · 无交易 · 正常" in message + + # Preview markers stay explicit and safe. + assert "PREVIEW" in message + assert "synthetic" in message.lower() + assert "不会下单" in message + for forbidden in _FORBIDDEN_TEXT: + assert forbidden not in message -def _classify_preview_text(text: str) -> str | None: - lower = text.lower() - if "未知状态" in text or "unknown status" in lower: - return "unknown_status" - if "成交确认" in text or "preview filled" in lower: - return "filled" - if "订单待确认" in text or "pending confirmation" in lower or "尚未确认成交" in text or "fill not confirmed" in lower: - return "pending_confirmation" - if ( - "拒单异常" in text - or "preview reject" in lower - or "券商拒绝" in text - or "broker rejected" in lower - ): - return "rejected_or_exception" - if "心跳检测" in text or "💓" in text: - return "heartbeat_no_rebalance" - if "模拟限价" in text or "dry-run" in lower or "🧪 dry-run" in lower or "🧪 模拟限价" in text: - return "rebalance_dry_run" - return None - - -def test_build_preview_messages_covers_required_categories_with_safe_markers(): - messages = preview.build_preview_messages(locale="zh") - assert 1 <= len(messages) <= 6 - assert len(messages) == 6 - - categories = {_classify_preview_text(message) for message in messages} - assert categories == { - "heartbeat_no_rebalance", - "rebalance_dry_run", - "pending_confirmation", - "filled", - "rejected_or_exception", - "unknown_status", - } - for message in messages: - assert message.startswith("[PAPER]") - assert "PREVIEW" in message - assert "synthetic" in message.lower() or "合成" in message - assert "不会下单" in message or "No order will be" in message - for forbidden in ( - "api.telegram.org", - "https://", - "Traceback", - "FIRSTRADE_", - "secret-token", - ): - assert forbidden not in message +def test_build_preview_message_supports_english_locale(): + message = preview.build_preview_message(locale="en", business_day="2026-11-02") + + assert "📡 QuantSentinel · Heartbeat" in message + assert "Business day: 2026-11-02" in message + assert "Runs completed with verified zero fills today." in message + assert "[Ran today]" in message + assert "· firstrade / NASDAQ Growth Income · cycles 1 · No trades · ok" in message + assert "No order will be placed" in message + assert "今日有运行且已验证零成交。" not in message def test_preview_profile_is_enabled_on_firstrade(): @@ -91,10 +81,10 @@ def test_preview_rejects_a_profile_that_is_not_enabled(monkeypatch): monkeypatch.setattr(preview, "_PREVIEW_STRATEGY_PROFILE", "retired_profile") with pytest.raises(RuntimeError, match="not enabled on Firstrade"): - preview.build_preview_messages(locale="en") + preview.build_preview_message(locale="en") -def test_send_preview_calls_sender_once_per_message_without_broker_imports(monkeypatch): +def test_send_preview_calls_sender_exactly_once_without_broker_imports(monkeypatch): monkeypatch.setenv("TELEGRAM_TOKEN", "token-preview") monkeypatch.setenv("GLOBAL_TELEGRAM_CHAT_ID", "chat-preview") monkeypatch.setenv("NOTIFY_LANG", "zh") @@ -115,24 +105,11 @@ def fake_send(text): delivered = preview.send_preview(send_fn=fake_send) assert delivered is True - assert len(sent) == 6 - assert len(sent) <= 6 - - for text in sent: - assert text.startswith("[PAPER]") - assert "PREVIEW" in text - assert "token-preview" not in text - assert "chat-preview" not in text - - categories = {_classify_preview_text(text) for text in sent} - assert categories == { - "heartbeat_no_rebalance", - "rebalance_dry_run", - "pending_confirmation", - "filled", - "rejected_or_exception", - "unknown_status", - } + assert len(sent) == 1 + assert sent[0].startswith("[PAPER]") + assert "PREVIEW" in sent[0] + assert "token-preview" not in sent[0] + assert "chat-preview" not in sent[0] after_modules = { name @@ -174,6 +151,41 @@ def test_send_preview_fails_closed_without_telegram_target(monkeypatch): assert sent == [] +def test_main_renders_without_sending_by_default(monkeypatch, capsys): + monkeypatch.setenv("RUNTIME_TARGET_ENABLED", "false") + monkeypatch.setenv("NOTIFY_LANG", "zh") + monkeypatch.delenv("PREVIEW_SEND", raising=False) + monkeypatch.delenv("TELEGRAM_TOKEN", raising=False) + monkeypatch.delenv("GLOBAL_TELEGRAM_CHAT_ID", raising=False) + + def explode_send(**_kwargs): + raise AssertionError("send_preview must not be called when send is false") + + monkeypatch.setattr(preview, "send_preview", explode_send) + + assert preview.main([]) == 0 + out = capsys.readouterr().out + assert "[PAPER]" in out + assert "业务日" in out + assert "no Telegram delivery" in out + + +def test_main_sends_single_message_when_requested(monkeypatch): + monkeypatch.setenv("RUNTIME_TARGET_ENABLED", "false") + monkeypatch.setenv("TELEGRAM_TOKEN", "token-preview") + monkeypatch.setenv("GLOBAL_TELEGRAM_CHAT_ID", "chat-preview") + + calls = [] + + def fake_send_preview(**kwargs): + calls.append(kwargs) + return True + + monkeypatch.setattr(preview, "send_preview", fake_send_preview) + assert preview.main(["--send"]) == 0 + assert len(calls) == 1 + + def test_main_refuses_enabled_runtime_target(monkeypatch): monkeypatch.setenv("RUNTIME_TARGET_ENABLED", "true") monkeypatch.setenv("TELEGRAM_TOKEN", "token-preview") @@ -186,14 +198,21 @@ def test_main_returns_nonzero_when_delivery_fails(monkeypatch): monkeypatch.setenv("TELEGRAM_TOKEN", "token-preview") monkeypatch.setenv("GLOBAL_TELEGRAM_CHAT_ID", "chat-preview") monkeypatch.setattr(preview, "send_preview", lambda **_kwargs: False) - assert preview.main([]) == 1 + assert preview.main(["--send"]) == 1 def test_workflow_static_safety_constraints(): assert "name: PAPER Notification Preview" in WORKFLOW assert "workflow_dispatch:" in WORKFLOW + # Dispatch-only trigger with a send switch that defaults to print-only. + assert "push:" not in WORKFLOW + assert "pull_request:" not in WORKFLOW assert "schedule:" not in WORKFLOW assert "workflow_run:" not in WORKFLOW + assert "type: boolean" in WORKFLOW + assert "default: false" in WORKFLOW + assert "PREVIEW_SEND: ${{ inputs.send }}" in WORKFLOW + assert "if: ${{ inputs.send == true" in WORKFLOW assert "scripts/send_paper_notification_preview.py" in WORKFLOW assert 'RUNTIME_TARGET_ENABLED: "false"' in WORKFLOW assert "secrets.TELEGRAM_TOKEN" in WORKFLOW