Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions .github/workflows/paper-notification-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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 }}
Expand All @@ -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 }}
Expand All @@ -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
235 changes: 76 additions & 159 deletions scripts/send_paper_notification_preview.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand All @@ -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

Expand Down
Loading
Loading