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
2 changes: 1 addition & 1 deletion .github/workflows/sync-cloud-run-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -840,7 +840,7 @@ jobs:
--concurrency=1 \
--memory=512Mi \
--cpu=1 \
--timeout=300s \
--timeout=600s \
--no-traffic \
--labels="managed-by=github-actions,commit-sha=${GITHUB_SHA},github-run-id=${GITHUB_RUN_ID},deployment-label=${deployment_label}" \
--quiet
Expand Down
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,6 @@ COPY . .
RUN python -m pip install --upgrade pip uv \
&& uv sync --frozen --no-dev

CMD ["gunicorn", "--bind", ":8080", "--workers", "1", "--threads", "1", "--timeout", "300", "main:app"]
# Keep the application timeout below Cloud Run/Scheduler's 600-second ceiling so
# Gunicorn can return a controlled failure instead of being killed at the edge.
CMD ["gunicorn", "--bind", ":8080", "--workers", "1", "--threads", "1", "--timeout", "570", "main:app"]
87 changes: 87 additions & 0 deletions notifications/compact_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Adapter for compact, user-facing notification sections."""

from __future__ import annotations

import re
from collections.abc import Iterable

_HOLDINGS_HEADERS = {
"💼 持仓",
"💼 策略持仓",
"💼 当前持仓",
"💼 Holdings",
"💼 Current Holdings",
"💼 Strategy Holdings",
"💼 Strategy holdings",
}
_NUMBER_RE = re.compile(r"[-+]?\d[\d,]*(?:\.\d+)?")


def _contains_nonzero_number(text: str) -> bool:
for match in _NUMBER_RE.finditer(text):
try:
if abs(float(match.group(0).replace(",", ""))) > 1e-12:
return True
except ValueError:
continue
return False


def _localize_holding_detail(detail: str, *, locale: str) -> str:
if str(locale).lower().startswith("zh"):
return re.sub(r"\s+shares?\b", "股", detail, flags=re.IGNORECASE)

def replace_share(match: re.Match[str]) -> str:
quantity = match.group(1)
try:
unit = "share" if abs(float(quantity.replace(",", ""))) == 1 else "shares"
except ValueError:
unit = "shares"
return f"{quantity} {unit}"

return re.sub(r"([-+]?\d[\d,]*(?:\.\d+)?)\s*股", replace_share, detail)


def adapt_compact_sections(
dashboard_text: str,
*,
locale: str,
supplemental_lines: Iterable[object] = (),
) -> tuple[str, ...]:
"""Return normalized non-zero holdings followed by explicit supplements.

Holdings are read from the already-rendered same-cycle dashboard so each
platform keeps ownership of broker-specific valuation and quantity rules.
Supplemental lines must already be localized by the platform translator.
"""
holdings: list[str] = []
in_holdings = False
for raw_line in str(dashboard_text or "").splitlines():
line = raw_line.strip()
if line in _HOLDINGS_HEADERS:
in_holdings = True
continue
if not in_holdings or not line:
continue
if line.startswith("━") or line.startswith(("📌", "💵", "📊", "🎯", "🧾", "⏱", "🧩")):
break
normalized = line.lstrip("-• ").strip()
if ":" not in normalized and ":" not in normalized:
continue
separator = ":" if ":" in normalized else ":"
symbol, detail = (part.strip() for part in normalized.split(separator, 1))
if not symbol or not detail or not _contains_nonzero_number(detail):
continue
detail = _localize_holding_detail(detail, locale=locale)
holdings.append(f"- {symbol}: {detail}")

lines: list[str] = []
if holdings:
lines.append("💼 持仓" if str(locale).lower().startswith("zh") else "💼 Holdings")
lines.extend(holdings)

for raw_line in supplemental_lines:
line = str(raw_line or "").strip()
if line and line not in lines:
lines.append(line)
return tuple(lines)
96 changes: 79 additions & 17 deletions notifications/renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from collections.abc import Mapping
import math

from notifications.compact_adapter import adapt_compact_sections
from notifications.events import RenderedNotification
from quant_platform_kit.common.notification_localization import (
localize_notification_text as _base_localize_notification_text,
Expand Down Expand Up @@ -310,6 +311,50 @@ def _append_extra_notification_lines(lines, extra_notification_lines) -> None:
lines.append(text)


def _compact_total_assets_line(execution, *, translator) -> str:
dashboard = _format_dashboard_text(
execution.get("dashboard_text"),
translator=translator,
cash_only_execution=bool(execution.get("cash_only_execution", True)),
)
labels = (
"总资产",
"账户总权益",
"净值",
"total assets",
"total account equity",
"net assets",
"net value",
"equity",
)
for raw_line in dashboard.splitlines():
for segment in raw_line.split(" | "):
line = segment.strip().lstrip("- ").strip()
lowered = line.lower()
if any(label in lowered for label in labels) and ":" in line:
return line if line.startswith("💰") else f"💰 {line}"
return ""


def _heartbeat_account_equity_line(execution, *, translator) -> str:
snapshot = execution.get("heartbeat_account_snapshot")
snapshot = snapshot if isinstance(snapshot, Mapping) else {}
observed_at = str(snapshot.get("observed_at") or "").strip()
currency = str(snapshot.get("equity_currency") or snapshot.get("currency") or "").strip()
amount = snapshot.get("net_assets")
valid = (
isinstance(amount, (int, float))
and not isinstance(amount, bool)
and math.isfinite(amount)
and amount > 0
and bool(currency)
and bool(observed_at)
)
if not valid:
return ""
return translator("heartbeat_account_equity", value=f"{currency} {amount:,.2f}")


def render_rebalance_notification(
*,
execution,
Expand Down Expand Up @@ -345,10 +390,22 @@ def render_rebalance_notification(
_append_strategy_line(compact_lines, strategy_display_name=strategy_display_name, translator=translator)
if dry_run_only:
compact_lines.append(translator("dry_run_banner"))
_append_extra_notification_lines(compact_lines, extra_notification_lines)
_append_dashboard_block(compact_lines, execution=execution, separator=separator, translator=translator, compact=True)
compact_lines.append(separator)
compact_lines.append(formatted_logs)
total_assets_line = _compact_total_assets_line(execution, translator=translator)
if total_assets_line:
compact_lines.append(total_assets_line)
compact_dashboard = _format_dashboard_text(
execution.get("dashboard_text"),
translator=translator,
cash_only_execution=bool(execution.get("cash_only_execution", True)),
)
compact_lines.extend(
adapt_compact_sections(
compact_dashboard,
locale="zh" if _translator_uses_zh(translator) else "en",
supplemental_lines=execution.get("compact_supplemental_lines", ()),
)
)
compact_lines.extend(str(log).strip() for log in logs if str(log).strip())
return RenderedNotification(
detailed_text="\n".join(detailed_lines),
compact_text="\n".join(compact_lines),
Expand Down Expand Up @@ -434,21 +491,26 @@ def render_heartbeat_notification(
_append_strategy_line(compact_lines, strategy_display_name=strategy_display_name, translator=translator)
if dry_run_only:
compact_lines.append(translator("dry_run_banner"))
_append_extra_notification_lines(compact_lines, extra_notification_lines)
_append_heartbeat_account_lines(compact_lines, execution=execution, translator=translator)
_append_dashboard_block(compact_lines, execution=execution, separator=separator, translator=translator, compact=True)
total_assets_line = _heartbeat_account_equity_line(execution, translator=translator)
if not total_assets_line:
total_assets_line = _compact_total_assets_line(execution, translator=translator)
if total_assets_line:
compact_lines.append(total_assets_line)
compact_dashboard = _format_dashboard_text(
execution.get("dashboard_text"),
translator=translator,
cash_only_execution=bool(execution.get("cash_only_execution", True)),
)
compact_lines.extend(
[
separator,
translator("no_executable_orders") if (skip_logs or note_logs) else translator("no_trades"),
]
adapt_compact_sections(
compact_dashboard,
locale="zh" if _translator_uses_zh(translator) else "en",
supplemental_lines=execution.get("compact_supplemental_lines", ()),
)
)
compact_lines.append(
translator("no_executable_orders") if (skip_logs or note_logs) else translator("no_trades")
)
if skip_logs:
compact_lines.extend([separator, translator("skipped_actions")])
compact_lines.extend(f" - {log}" for log in skip_logs)
if note_logs:
compact_lines.extend([separator, translator("notes_title")])
compact_lines.extend(f" - {log}" for log in note_logs)

return RenderedNotification(
detailed_text=detailed_text,
Expand Down
56 changes: 37 additions & 19 deletions scripts/send_paper_notification_preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,59 +32,77 @@

_SEPARATOR = "━━━━━━━━━━━━━━━━━━"
_MAX_PREVIEW_MESSAGES = 6
_PREVIEW_STRATEGY_NAME = "PAPER Notification Preview"
_PREVIEW_EXTRA_LINES = (
"🧪 【PREVIEW】PAPER notification preview",
"synthetic / 合成样例 · 不会下单 · No order will be placed",
)
_PREVIEW_COPY = {
"zh": {
"strategy_name": "PAPER 通知预览",
"extra_lines": (
"🧪 【PREVIEW】PAPER 通知预览",
"合成样例 · 不会下单",
),
},
"en": {
"strategy_name": "PAPER Notification Preview",
"extra_lines": (
"🧪 【PREVIEW】PAPER notification preview",
"Synthetic example · No order will be placed",
),
},
}


def _resolve_locale(raw: str | None = None) -> str:
value = str(raw or os.environ.get("NOTIFY_LANG") or "zh").strip().lower()
return "en" if value.startswith("en") else "zh"


def _synthetic_execution(*, with_dashboard: bool) -> dict:
def _synthetic_execution(*, with_dashboard: bool, locale: str) -> dict:
copy = _PREVIEW_COPY[locale]
execution: dict = {
"cash_only_execution": True,
"status_display": "preview_hold",
"signal_display": "synthetic preview signal",
"status_display": "预览保持" if locale == "zh" else "preview hold",
"signal_display": "合成预览信号" if locale == "zh" else "synthetic preview signal",
"compact_supplemental_lines": copy["extra_lines"],
}
if with_dashboard:
execution["dashboard_text"] = (
"📌 PAPER PREVIEW\n"
" - 可用现金: $0.00 | 可投资现金: $0.00\n"
" - synthetic positions only"
"📌 PAPER 预览\n - 可用现金: $0.00 | 可投资现金: $0.00\n - 仅含合成持仓"
if locale == "zh"
else "📌 PAPER PREVIEW\n - Available cash: $0.00 | Investable cash: $0.00\n"
" - Synthetic positions only"
)
return execution


def build_preview_messages(*, locale: str | None = None) -> list[str]:
translator = build_translator(_resolve_locale(locale))
resolved_locale = _resolve_locale(locale)
translator = build_translator(resolved_locale)
preview_copy = _PREVIEW_COPY[resolved_locale]
strategy_name = preview_copy["strategy_name"]
extra_lines = preview_copy["extra_lines"]
messages: list[str] = []

heartbeat = render_heartbeat_notification(
execution=_synthetic_execution(with_dashboard=True),
execution=_synthetic_execution(with_dashboard=True, locale=resolved_locale),
skip_logs=(),
note_logs=(),
translator=translator,
separator=_SEPARATOR,
strategy_display_name=_PREVIEW_STRATEGY_NAME,
strategy_display_name=strategy_name,
dry_run_only=True,
extra_notification_lines=_PREVIEW_EXTRA_LINES,
extra_notification_lines=extra_lines,
)
messages.append(heartbeat.compact_text)

rebalance = render_rebalance_notification(
execution=_synthetic_execution(with_dashboard=True),
execution=_synthetic_execution(with_dashboard=True, locale=resolved_locale),
logs=("SYNTHETIC Buy PREVIEW.US x1 @ market (preview only)",),
skip_logs=(),
note_logs=(),
translator=translator,
separator=_SEPARATOR,
strategy_display_name=_PREVIEW_STRATEGY_NAME,
strategy_display_name=strategy_name,
dry_run_only=True,
extra_notification_lines=_PREVIEW_EXTRA_LINES,
extra_notification_lines=extra_lines,
)
messages.append(rebalance.compact_text)

Expand All @@ -109,7 +127,7 @@ def build_preview_messages(*, locale: str | None = None) -> list[str]:
translator=translator,
)
body = rendered.compact_text
messages.append("\n".join((body, *_PREVIEW_EXTRA_LINES)))
messages.append("\n".join((body, *extra_lines)))

if len(messages) > _MAX_PREVIEW_MESSAGES:
raise RuntimeError(
Expand Down
41 changes: 41 additions & 0 deletions tests/test_compact_notification_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from notifications.compact_adapter import adapt_compact_sections


def test_adapter_keeps_only_nonzero_holdings_and_appends_supplements():
dashboard = """📌 策略账户概览
- 总资产(策略净值): $581.59
💼 策略持仓
- SOXL: $151.80 / 1股
- SOXX: $0.00 / 0股
- BOXX: $0.00 / 2股
━━━━━━━━━━━━━━━━━━
📊 市场状态: 观察"""

assert adapt_compact_sections(
dashboard,
locale="zh",
supplemental_lines=("⚠️ 订单结果待确认", "", "⚠️ 订单结果待确认"),
) == (
"💼 持仓",
"- SOXL: $151.80 / 1股",
"- BOXX: $0.00 / 2股",
"⚠️ 订单结果待确认",
)


def test_adapter_omits_empty_holdings_section():
assert adapt_compact_sections(
"💼 Strategy Holdings\n- TQQQ: $0.00 / 0 shares",
locale="en",
) == ()


def test_adapter_localizes_holding_units_without_mixed_language():
assert adapt_compact_sections(
"💼 策略持仓\n- TQQQ: $80.18 / 1股",
locale="en",
) == ("💼 Holdings", "- TQQQ: $80.18 / 1 share")
assert adapt_compact_sections(
"💼 Strategy Holdings\n- SOXL: $151.80 / 2 shares",
locale="zh",
) == ("💼 持仓", "- SOXL: $151.80 / 2股")
Loading
Loading