From e5ff0e80d35c1cf8838753e42637145c6815b02e Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:25:11 +0800 Subject: [PATCH] feat(ops): add PAPER notification preview Co-Authored-By: Codex --- .../workflows/paper-notification-preview.yml | 59 ++++ scripts/send_paper_notification_preview.py | 280 ++++++++++++++++++ tests/test_paper_notification_preview.py | 204 +++++++++++++ 3 files changed, 543 insertions(+) create mode 100644 .github/workflows/paper-notification-preview.yml create mode 100644 scripts/send_paper_notification_preview.py create mode 100644 tests/test_paper_notification_preview.py diff --git a/.github/workflows/paper-notification-preview.yml b/.github/workflows/paper-notification-preview.yml new file mode 100644 index 0000000..d228859 --- /dev/null +++ b/.github/workflows/paper-notification-preview.yml @@ -0,0 +1,59 @@ +name: PAPER Notification Preview + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: paper-notification-preview-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + preview: + name: Send PAPER Telegram notification preview pack + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + id-token: write + env: + GCP_PROJECT_ID: firstradequant + GCP_WORKLOAD_IDENTITY_PROVIDER: projects/1088907247379/locations/global/workloadIdentityPools/github-actions/providers/github-main + GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: firstrade-platform-deploy@firstradequant.iam.gserviceaccount.com + # Production runtime target stays fail-closed for this manual preview path. + RUNTIME_TARGET_ENABLED: "false" + NOTIFY_LANG: ${{ vars.NOTIFY_LANG || 'zh' }} + GLOBAL_TELEGRAM_CHAT_ID: ${{ secrets.GLOBAL_TELEGRAM_CHAT_ID }} + TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} + TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Set up uv + 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 != '' }} + 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 != '' }} + uses: google-github-actions/setup-gcloud@v3 + with: + project_id: ${{ env.GCP_PROJECT_ID }} + + - name: Install locked runtime dependencies + run: | + set -euo pipefail + uv sync --frozen --no-dev + + - name: Send PAPER notification preview pack + 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 new file mode 100644 index 0000000..86b7a97 --- /dev/null +++ b/scripts/send_paper_notification_preview.py @@ -0,0 +1,280 @@ +#!/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. +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +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_summary, +) + +_MAX_PREVIEW_MESSAGES = 6 +_PREVIEW_STRATEGY_PROFILE = "tqqq_growth_income" +_PREVIEW_EXTRA_LINES = ( + "🧪 【PREVIEW】PAPER notification preview", + "synthetic / 合成样例 · 不会下单 · No order will be placed", +) +_SYNTHETIC_SYMBOL = "PREVIEW" + + +def _resolve_locale(raw: str | None = None) -> str: + value = str(raw or os.environ.get("QSL_NOTIFY_LANG") or os.environ.get("NOTIFY_LANG") or "zh") + value = value.strip().lower() + return "en" if value.startswith("en") else "zh" + + +def _split_chat_ids(raw: str | None) -> list[str]: + if not raw: + return [] + return [ + part.strip() + for part in str(raw).replace(";", ",").replace("\n", ",").split(",") + if part.strip() + ] + + +def resolve_telegram_token() -> str: + direct_token = (os.environ.get("TELEGRAM_TOKEN") or os.environ.get("TG_TOKEN") or "").strip() + if direct_token: + return direct_token + secret_name = (os.environ.get("TELEGRAM_TOKEN_SECRET_NAME") or "").strip() + if not secret_name: + return "" + result = subprocess.run( + ["gcloud", "secrets", "versions", "access", "latest", "--secret", secret_name], + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + return "" + return result.stdout.strip() + + +def resolve_telegram_chat_id() -> str: + chats = _split_chat_ids( + os.environ.get("QSL_GLOBAL_TELEGRAM_CHAT_ID") + or os.environ.get("GLOBAL_TELEGRAM_CHAT_ID") + ) + return chats[0] if chats else "" + + +def _with_preview_markers(body: str) -> str: + return "\n".join(("[PAPER]", body, *_PREVIEW_EXTRA_LINES)) + + +def _empty_portfolio() -> dict[str, Any]: + return { + "total_equity": 0.0, + "liquid_cash": 0.0, + "portfolio_rows": (), + "market_values": {}, + "quantities": {}, + } + + +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 build_preview_messages(*, locale: str | None = None) -> list[str]: + """Build at most six synthetic compact PAPER preview messages.""" + + 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_summary( + { + **_base_result(dry_run_only=True, strategy_display_name=strategy_name), + }, + lang=resolved_locale, + ) + + dry_run = render_cycle_summary( + { + **_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, + ) + + pending = render_cycle_summary( + { + **_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, + ) + 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), + account_line, + translator("dry_run_banner"), + translator("order_logs_title"), + filled_order, + "synthetic PREVIEW filled / 成交确认", + ) + ) + + rejected = render_cycle_summary( + { + **_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, + ) + 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 + + +def send_preview(*, locale: str | None = None, send_fn=None, requests_module=None) -> bool: + messages = build_preview_messages(locale=locale) + token = resolve_telegram_token() + chat_id = resolve_telegram_chat_id() + if not token or not chat_id: + print( + "Notification preview not sent: Telegram target is not configured.", + file=sys.stderr, + ) + 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 + return True + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Send a bounded FirstradePlatform PAPER Telegram notification preview pack." + ) + parser.add_argument( + "--locale", + default=os.environ.get("NOTIFY_LANG"), + help="Optional notification locale override (zh/en). Defaults to NOTIFY_LANG.", + ) + args = parser.parse_args(argv) + + # Fail closed: this path never enables a production runtime target. + if (os.environ.get("RUNTIME_TARGET_ENABLED") or "").strip().lower() in { + "1", + "true", + "yes", + "y", + "on", + }: + print( + "Notification preview refused: RUNTIME_TARGET_ENABLED must stay disabled.", + file=sys.stderr, + ) + return 1 + + 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)." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_paper_notification_preview.py b/tests/test_paper_notification_preview.py new file mode 100644 index 0000000..0b9600c --- /dev/null +++ b/tests/test_paper_notification_preview.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from scripts import send_paper_notification_preview as preview + +WORKFLOW = (ROOT / ".github/workflows/paper-notification-preview.yml").read_text( + encoding="utf-8" +) +SCRIPT_PATH = ROOT / "scripts" / "send_paper_notification_preview.py" + +_FORBIDDEN_IMPORT_ROOTS = ( + "firstrade", + "main", + "application", + "strategy_runtime", + "decision_mapper", + "entrypoints", + "runtime_config_support", + "runtime_execution_policy", +) + + +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_send_preview_calls_sender_once_per_message_without_broker_imports(monkeypatch): + monkeypatch.setenv("TELEGRAM_TOKEN", "token-preview") + monkeypatch.setenv("GLOBAL_TELEGRAM_CHAT_ID", "chat-preview") + monkeypatch.setenv("NOTIFY_LANG", "zh") + monkeypatch.setenv("RUNTIME_TARGET_ENABLED", "false") + + before_modules = { + name + for name in sys.modules + if any(name == root or name.startswith(f"{root}.") for root in _FORBIDDEN_IMPORT_ROOTS) + } + + sent = [] + + def fake_send(text): + sent.append(text) + return True + + 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", + } + + after_modules = { + name + for name in sys.modules + if any(name == root or name.startswith(f"{root}.") for root in _FORBIDDEN_IMPORT_ROOTS) + } + assert after_modules == before_modules + + +def test_preview_script_has_no_broker_or_execution_imports(): + tree = ast.parse(SCRIPT_PATH.read_text(encoding="utf-8")) + imported = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + imported.add(alias.name.split(".")[0]) + imported.add(alias.name) + elif isinstance(node, ast.ImportFrom) and node.module: + imported.add(node.module.split(".")[0]) + imported.add(node.module) + + for forbidden in _FORBIDDEN_IMPORT_ROOTS: + assert forbidden not in imported + assert not any( + name == forbidden or name.startswith(f"{forbidden}.") for name in imported + ) + + assert "notifications.telegram" in imported + + +def test_send_preview_fails_closed_without_telegram_target(monkeypatch): + monkeypatch.delenv("TELEGRAM_TOKEN", raising=False) + monkeypatch.delenv("TG_TOKEN", raising=False) + monkeypatch.delenv("TELEGRAM_TOKEN_SECRET_NAME", raising=False) + monkeypatch.delenv("GLOBAL_TELEGRAM_CHAT_ID", raising=False) + monkeypatch.delenv("QSL_GLOBAL_TELEGRAM_CHAT_ID", raising=False) + sent = [] + assert preview.send_preview(send_fn=lambda text: sent.append(text) or True) is False + assert sent == [] + + +def test_main_refuses_enabled_runtime_target(monkeypatch): + monkeypatch.setenv("RUNTIME_TARGET_ENABLED", "true") + monkeypatch.setenv("TELEGRAM_TOKEN", "token-preview") + monkeypatch.setenv("GLOBAL_TELEGRAM_CHAT_ID", "chat-preview") + assert preview.main([]) == 1 + + +def test_main_returns_nonzero_when_delivery_fails(monkeypatch): + monkeypatch.setenv("RUNTIME_TARGET_ENABLED", "false") + 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 + + +def test_workflow_static_safety_constraints(): + assert "name: PAPER Notification Preview" in WORKFLOW + assert "workflow_dispatch:" in WORKFLOW + assert "schedule:" not in WORKFLOW + assert "workflow_run:" not in WORKFLOW + assert "scripts/send_paper_notification_preview.py" in WORKFLOW + assert 'RUNTIME_TARGET_ENABLED: "false"' in WORKFLOW + assert "secrets.TELEGRAM_TOKEN" in WORKFLOW + assert "secrets.GLOBAL_TELEGRAM_CHAT_ID" in WORKFLOW + assert "vars.GLOBAL_TELEGRAM_CHAT_ID" not in WORKFLOW + assert "uv sync --frozen --no-dev" in WORKFLOW + assert "astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9" in WORKFLOW + + for forbidden in ( + "gcloud run deploy", + "gcloud run services", + "gcloud run jobs", + "gcloud scheduler", + "Cloud Run", + "continue-on-error: true", + "strategy_profile", + "main.py", + "application/", + "FIRSTRADE_USERNAME", + "FIRSTRADE_PASSWORD", + "invoke-cloud-run", + ): + assert forbidden not in WORKFLOW