diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index 60d3dca..dd7b716 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index 6c9a499..58395f7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/notifications/compact_adapter.py b/notifications/compact_adapter.py new file mode 100644 index 0000000..0e978a0 --- /dev/null +++ b/notifications/compact_adapter.py @@ -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) diff --git a/notifications/renderers.py b/notifications/renderers.py index 08a5350..4b969ea 100644 --- a/notifications/renderers.py +++ b/notifications/renderers.py @@ -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, @@ -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, @@ -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), @@ -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, diff --git a/scripts/send_paper_notification_preview.py b/scripts/send_paper_notification_preview.py index de39f01..2183a60 100755 --- a/scripts/send_paper_notification_preview.py +++ b/scripts/send_paper_notification_preview.py @@ -32,11 +32,22 @@ _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: @@ -44,47 +55,54 @@ def _resolve_locale(raw: str | None = None) -> str: 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) @@ -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( diff --git a/tests/test_compact_notification_adapter.py b/tests/test_compact_notification_adapter.py new file mode 100644 index 0000000..fe23fd2 --- /dev/null +++ b/tests/test_compact_notification_adapter.py @@ -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股") diff --git a/tests/test_notifications.py b/tests/test_notifications.py index dd74576..be39903 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -220,7 +220,7 @@ def test_dry_run_heartbeat_uses_dry_run_title(self): self.assertIn("🧪 【Strategy Dry Run】", en_rendered.compact_text) self.assertNotIn("💓 【Heartbeat】", en_rendered.compact_text) - def test_heartbeat_keeps_cash_and_equity_currencies_separate(self): + def test_heartbeat_compact_copy_keeps_only_account_equity_currency(self): execution = {"heartbeat_account_snapshot": { "available_cash": 105.25, "cash_currency": "USD", "net_assets": 2500.5, "equity_currency": "SGD", @@ -235,9 +235,46 @@ def test_heartbeat_keeps_cash_and_equity_currencies_separate(self): translator=build_translator(language), separator="---", strategy_display_name="Example", dry_run_only=False, ) - self.assertIn(cash, rendered.compact_text) + self.assertNotIn(cash, rendered.compact_text) self.assertIn(equity, rendered.compact_text) + def test_compact_heartbeat_keeps_nonzero_holdings_and_omits_noise(self): + rendered = render_heartbeat_notification( + execution={ + "heartbeat_account_snapshot": { + "available_cash": 105.25, + "cash_currency": "USD", + "net_assets": 2500.5, + "equity_currency": "SGD", + "observed_at": "2026-09-25T14:09:00+00:00", + }, + "dashboard_text": ( + "📌 策略账户概览\n" + " - 总资产(策略净值): $2,500.50\n" + " - 可用现金: $105.25\n" + "💼 策略持仓\n - SOXL: 1股" + ), + "signal_display": "SOXX 站上 140 日门槛线", + }, + skip_logs=(), + note_logs=(), + translator=build_translator("zh"), + separator="━━━━━━━━━━━━━━━━━━", + strategy_display_name="半导体趋势收益", + dry_run_only=False, + extra_notification_lines=("🧩 插件:市场状态控制",), + ) + + self.assertEqual( + rendered.compact_text, + "💓 【策略运行心跳】\n" + "🧭 策略: 半导体趋势收益\n" + "💰 账户总权益: SGD 2,500.50\n" + "💼 持仓\n" + "- SOXL: 1股\n" + "✅ 无交易,无需调仓", + ) + def test_heartbeat_renders_tqqq_volatility_delever_risk_control(self): zh_rendered = render_heartbeat_notification( execution={ @@ -430,9 +467,9 @@ def test_dashboard_relabels_total_assets_for_margin_execution(self): dry_run_only=False, ) - self.assertIn("总资产(策略净值): $50,000.00", rendered.compact_text) - self.assertIn("购买力: $75,000.00", rendered.compact_text) - self.assertNotIn("不含融资额度", rendered.compact_text) + self.assertIn("总资产(策略净值): $50,000.00", rendered.detailed_text) + self.assertIn("购买力: $75,000.00", rendered.detailed_text) + self.assertNotIn("不含融资额度", rendered.detailed_text) def test_dashboard_relabels_buying_power_for_cash_only_execution(self): rendered = render_rebalance_notification( @@ -450,8 +487,8 @@ def test_dashboard_relabels_buying_power_for_cash_only_execution(self): strategy_display_name="demo", dry_run_only=False, ) - self.assertIn("可用现金: $100.00", rendered.compact_text) - self.assertNotIn("购买力: $100.00", rendered.compact_text) + self.assertIn("可用现金: $100.00", rendered.detailed_text) + self.assertNotIn("购买力: $100.00", rendered.detailed_text) def test_dashboard_keeps_margin_buying_power_label_when_cash_only_disabled(self): rendered = render_rebalance_notification( @@ -469,8 +506,43 @@ def test_dashboard_keeps_margin_buying_power_label_when_cash_only_disabled(self) strategy_display_name="demo", dry_run_only=False, ) - self.assertIn("Buying power: $100.00", rendered.compact_text) - self.assertNotIn("Available cash: $100.00", rendered.compact_text) + self.assertIn("Buying power: $100.00", rendered.detailed_text) + self.assertNotIn("Available cash: $100.00", rendered.detailed_text) + + def test_compact_rebalance_keeps_nonzero_holdings_and_order_result(self): + rendered = render_rebalance_notification( + execution={ + "dashboard_text": ( + "📌 策略账户概览\n" + " - 总资产(策略净值): $2,500.50\n" + " - 可用现金: $105.25\n" + "💼 策略持仓\n - SOXL: 1股" + ), + "cash_only_execution": False, + "signal_display": "hold", + "status_display": "hold", + "compact_supplemental_lines": ("⚠️ 订单仍待券商确认",), + }, + logs=("📈 [限价买入] SOXL: 1股 @ $151.80(状态: PendingSubmit)",), + skip_logs=("QQQM 低于一股价格",), + note_logs=("整数股偏离 -43.6pp",), + translator=build_translator("zh"), + separator="━━━━━━━━━━━━━━━━━━", + strategy_display_name="半导体趋势收益", + dry_run_only=False, + extra_notification_lines=("🧩 插件:市场状态控制",), + ) + + self.assertEqual( + rendered.compact_text, + "🔔 【调仓指令】\n" + "🧭 策略: 半导体趋势收益\n" + "💰 总资产(策略净值): $2,500.50\n" + "💼 持仓\n" + "- SOXL: 1股\n" + "⚠️ 订单仍待券商确认\n" + "📈 [限价买入] SOXL: 1股 @ $151.80(状态: PendingSubmit)", + ) def test_build_prefixer_prefers_account_prefix_only(self): with_prefix = build_prefixer("HK", "longbridge-quant-semiconductor-rotation-income-hk") diff --git a/tests/test_paper_notification_preview.py b/tests/test_paper_notification_preview.py index 04f7387..dc90a50 100644 --- a/tests/test_paper_notification_preview.py +++ b/tests/test_paper_notification_preview.py @@ -92,6 +92,16 @@ def test_build_preview_messages_covers_required_categories_with_safe_markers(): assert forbidden not in message +def test_preview_copy_follows_selected_locale_without_mixed_safety_text(): + zh_messages = preview.build_preview_messages(locale="zh") + en_messages = preview.build_preview_messages(locale="en") + + assert all("No order will be placed" not in message for message in zh_messages) + assert all("不会下单" not in message for message in en_messages) + assert all("PAPER 通知预览" in message for message in zh_messages[:2]) + assert all("PAPER Notification Preview" in message for message in en_messages[:2]) + + def test_send_preview_calls_sender_once_per_message_without_order_apis(monkeypatch): monkeypatch.setenv("TELEGRAM_TOKEN", "token-preview") monkeypatch.setenv("GLOBAL_TELEGRAM_CHAT_ID", "chat-preview") diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index de24c78..aaef0f4 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -2057,8 +2057,8 @@ def record_marker(self, *_args, **_kwargs): self.assertEqual(len(checked_keys), 1) self.assertIn("paper", checked_keys[0]) self.assertEqual(len(sent_messages), 1) - self.assertIn("已跳过重复执行", sent_messages[0]) - self.assertIn("2026-06-01", sent_messages[0]) + self.assertNotIn("已跳过重复执行", sent_messages[0]) + self.assertNotIn("2026-06-01", sent_messages[0]) self.assertNotIn("限价买入", sent_messages[0]) def test_run_strategy_dry_run_bypasses_execution_marker_when_env_enabled(self): @@ -2218,7 +2218,7 @@ def record_marker(self, *_args, **_kwargs): self.assertEqual(report_checks[0]["signal_date"], "2026-06-01") self.assertEqual(report_checks[0]["effective_date"], "2026-06-02") self.assertEqual(len(sent_messages), 1) - self.assertIn("已跳过重复执行", sent_messages[0]) + self.assertNotIn("已跳过重复执行", sent_messages[0]) self.assertNotIn("限价买入", sent_messages[0]) def test_run_strategy_records_execution_marker_after_dry_run_order_preview(self): @@ -2510,8 +2510,8 @@ def test_sell_then_buy_skip_is_sent_in_single_summary_message(self): self.assertIn("🧭 策略: SOXL/SOXX 半导体趋势收益", sent_messages[0]) self.assertNotIn("⏱ 执行时点:", sent_messages[0]) self.assertIn("限价卖出", sent_messages[0]) - self.assertIn("买入说明", sent_messages[0]) - self.assertIn("SOXX.US", sent_messages[0]) + self.assertNotIn("买入说明", sent_messages[0]) + self.assertNotIn("SOXX.US", sent_messages[0]) def test_buy_skip_without_orders_is_sent_in_single_heartbeat_message(self): plan = _build_plan( @@ -2542,9 +2542,9 @@ def test_buy_skip_without_orders_is_sent_in_single_heartbeat_message(self): self.assertIn("💓 【策略运行心跳】", sent_messages[0]) self.assertNotIn("⏱ 执行时点:", sent_messages[0]) self.assertIn("本轮没有可执行订单", sent_messages[0]) - self.assertIn("说明", sent_messages[0]) - self.assertIn("可投资现金", sent_messages[0]) - self.assertIn("SOXX.US", sent_messages[0]) + self.assertNotIn("说明", sent_messages[0]) + self.assertNotIn("可投资现金", sent_messages[0]) + self.assertNotIn("SOXX.US", sent_messages[0]) def test_strategy_target_keeps_cash_when_only_risk_target_is_unbuyable(self): plan = _build_plan( @@ -2575,9 +2575,9 @@ def test_strategy_target_keeps_cash_when_only_risk_target_is_unbuyable(self): self.assertEqual(len(sent_messages), 1) self.assertIn("⏳ 【订单待券商最终确认】", sent_messages[0]) - self.assertIn("SOXX.US 目标金额 $163.14 低于 1 股价格 $504.60", sent_messages[0]) - self.assertIn("本轮保留现金", sent_messages[0]) - self.assertIn("现金替代:BOXX.US", sent_messages[0]) + self.assertNotIn("SOXX.US 目标金额 $163.14 低于 1 股价格 $504.60", sent_messages[0]) + self.assertNotIn("本轮保留现金", sent_messages[0]) + self.assertNotIn("现金替代:BOXX.US", sent_messages[0]) self.assertNotIn("可投资现金 $1191.03 不足买入 1 股", sent_messages[0]) self.assertIn("市价卖出] BOXX: 6股", sent_messages[0]) self.assertNotIn("市价买入] SOXX", sent_messages[0]) @@ -2637,9 +2637,9 @@ def test_small_account_cash_substitution_note_is_not_duplicated_after_sell_refre self.assertEqual(len(sent_messages), 1) self.assertIn("⏳ 【订单待券商最终确认】", sent_messages[0]) self.assertIn("限价卖出] SOXL: 4股", sent_messages[0]) - self.assertEqual(sent_messages[0].count("[买入说明] SOXX.US"), 1) - self.assertEqual(sent_messages[0].count("本轮保留现金"), 1) - self.assertIn("SOXX.US 目标金额 $220.19 低于 1 股价格 $601.80", sent_messages[0]) + self.assertNotIn("[买入说明] SOXX.US", sent_messages[0]) + self.assertNotIn("本轮保留现金", sent_messages[0]) + self.assertNotIn("SOXX.US 目标金额 $220.19 低于 1 股价格 $601.80", sent_messages[0]) self.assertNotIn("SOXX.US 目标金额 $219.67 低于 1 股价格 $601.80", sent_messages[0]) def test_tqqq_delevered_qqq_target_below_one_share_reports_cash_reason(self): @@ -2698,8 +2698,8 @@ def test_tqqq_delevered_qqq_target_below_one_share_reports_cash_reason(self): self.assertEqual(len(sent_messages), 1) self.assertIn("💓 【策略运行心跳】", sent_messages[0]) self.assertIn("⚠️ 本轮没有可执行订单", sent_messages[0]) - self.assertIn("QQQ.US 目标金额 $507.87 低于 1 股价格 $715.86", sent_messages[0]) - self.assertIn("现金替代:现金", sent_messages[0]) + self.assertNotIn("QQQ.US 目标金额 $507.87 低于 1 股价格 $715.86", sent_messages[0]) + self.assertNotIn("现金替代:现金", sent_messages[0]) self.assertNotIn("✅ 无交易,无需调仓", sent_messages[0]) def test_tqqq_delevered_qqqm_target_is_executable_for_small_account(self): @@ -2860,7 +2860,7 @@ def test_target_gap_below_one_share_does_not_report_cash_shortage(self): self.assertEqual(len(sent_messages), 1) self.assertIn("💓 【策略运行心跳】", sent_messages[0]) - self.assertIn("BOXX.US 目标差额 $1005.08 未超过 1 股价格 $1167.40", sent_messages[0]) + self.assertNotIn("BOXX.US 目标差额 $1005.08 未超过 1 股价格 $1167.40", sent_messages[0]) self.assertNotIn("可投资现金 $164.98 不足买入 1 股", sent_messages[0]) def test_strategy_target_buy_floors_to_cash_backed_whole_shares(self): @@ -3106,7 +3106,7 @@ def test_zero_investable_cash_reports_buying_power_without_trade_note(self): self.assertEqual(len(sent_messages), 1) self.assertNotIn("账户现金", sent_messages[0]) - self.assertIn("可用现金: $3065.61 | 可投资现金: $0.00", sent_messages[0]) + self.assertNotIn("可用现金: $3065.61 | 可投资现金: $0.00", sent_messages[0]) self.assertIn("BOXX: $24,880.00 / 214股", sent_messages[0]) self.assertIn("✅ 无交易,无需调仓", sent_messages[0]) self.assertNotIn("本轮没有可执行订单", sent_messages[0]) @@ -3141,8 +3141,8 @@ def test_cash_limit_zero_mentions_possible_order_hold(self): ) self.assertEqual(len(sent_messages), 1) - self.assertIn("券商估算可买数量为 0", sent_messages[0]) - self.assertIn("可能有未完成挂单", sent_messages[0]) + self.assertNotIn("券商估算可买数量为 0", sent_messages[0]) + self.assertNotIn("可能有未完成挂单", sent_messages[0]) def test_non_usd_cash_is_reported_when_usd_cash_is_zero(self): plan = _build_plan( @@ -3175,8 +3175,8 @@ def test_non_usd_cash_is_reported_when_usd_cash_is_zero(self): ) self.assertGreaterEqual(len(sent_messages), 1) - self.assertTrue(any("各币种现金: SGD 350.00" in message for message in sent_messages)) - self.assertTrue(any("检测到非 USD 现金" in message for message in sent_messages)) + self.assertFalse(any("各币种现金: SGD 350.00" in message for message in sent_messages)) + self.assertFalse(any("检测到非 USD 现金" in message for message in sent_messages)) self.assertTrue(any("本轮没有可执行订单" in message for message in sent_messages)) self.assertFalse(any("✅ 无交易,无需调仓" in message for message in sent_messages)) @@ -3406,8 +3406,8 @@ def test_cash_sweep_symbol_does_not_sell_when_sweep_cannot_fund_one_share(self): self.assertEqual(observed_snapshots, [before_sell_snapshot]) self.assertEqual(len(observed_plan_inputs), 1) self.assertEqual(len(sent_messages), 1) - self.assertIn("买入说明", sent_messages[0]) - self.assertIn("不足买入 1 股", sent_messages[0]) + self.assertNotIn("买入说明", sent_messages[0]) + self.assertNotIn("不足买入 1 股", sent_messages[0]) self.assertNotIn("市价卖出", sent_messages[0]) self.assertNotIn("限价买入", sent_messages[0]) @@ -3538,7 +3538,7 @@ def test_dry_run_cash_sweep_reports_note_when_estimator_is_zero(self): self.assertEqual(len(sent_messages), 1) self.assertIn("🧪 模拟运行模式", sent_messages[0]) self.assertIn("🧪 模拟市价卖出 BOXX.US", sent_messages[0]) - self.assertIn( + self.assertNotIn( "ℹ️ [买入说明] SOXL.US 目标差额 $500.00,预算可买 4 股,但券商估算可买数量为 0;可能有未完成挂单、结算或购买力占用", sent_messages[0], ) @@ -3607,8 +3607,8 @@ def test_cash_sweep_rebuy_skips_when_broker_estimate_is_zero(self): self.assertEqual(len(sent_messages), 1) self.assertIn("💓 【策略运行心跳】", sent_messages[0]) - self.assertIn("BOXX.US 剩余可投资现金 $1200.00", sent_messages[0]) - self.assertIn("券商估算可买数量为 0", sent_messages[0]) + self.assertNotIn("BOXX.US 剩余可投资现金 $1200.00", sent_messages[0]) + self.assertNotIn("券商估算可买数量为 0", sent_messages[0]) self.assertNotIn("市价买入] BOXX", sent_messages[0]) def test_retries_account_refresh_after_sell_until_buying_power_updates(self): @@ -3788,12 +3788,12 @@ def test_heartbeat_accepts_normalized_portfolio_and_execution_sections(self): self.assertEqual(len(sent_messages), 1) self.assertIn("💓 【策略运行心跳】", sent_messages[0]) - self.assertIn("可投资现金", sent_messages[0]) + self.assertNotIn("可投资现金", sent_messages[0]) self.assertNotIn("💵 资金\n - 账户现金:", sent_messages[0]) - self.assertIn("📌 策略账户概览", sent_messages[0]) + self.assertNotIn("📌 策略账户概览", sent_messages[0]) self.assertIn("总资产(策略标的+现金): $60,000.00", sent_messages[0]) - self.assertIn("可用现金: $101.95 | 可投资现金: $101.95", sent_messages[0]) - self.assertIn("SOXX: $0.00 / 0股", sent_messages[0]) + self.assertNotIn("可用现金: $101.95 | 可投资现金: $101.95", sent_messages[0]) + self.assertNotIn("SOXX: $0.00 / 0股", sent_messages[0]) def test_hybrid_heartbeat_hides_empty_semiconductor_fields_and_shows_benchmark_line(self): plan = _build_plan( @@ -3834,18 +3834,18 @@ def test_hybrid_heartbeat_hides_empty_semiconductor_fields_and_shows_benchmark_l self.assertIn("💓 【策略运行心跳】", sent_messages[0]) self.assertIn("🧭 策略: TQQQ 增长收益", sent_messages[0]) self.assertIn("🧪 模拟运行模式", sent_messages[0]) - self.assertIn("📌 策略账户概览", sent_messages[0]) - self.assertIn("TQQQ: $0.00 / 0股", sent_messages[0]) - self.assertIn("BOXX: $0.00 / 0股", sent_messages[0]) - self.assertIn("QQQI: $0.00 / 0股", sent_messages[0]) - self.assertIn("SPYI: $0.00 / 0股", sent_messages[0]) + self.assertNotIn("📌 策略账户概览", sent_messages[0]) + self.assertNotIn("TQQQ: $0.00 / 0股", sent_messages[0]) + self.assertNotIn("BOXX: $0.00 / 0股", sent_messages[0]) + self.assertNotIn("QQQI: $0.00 / 0股", sent_messages[0]) + self.assertNotIn("SPYI: $0.00 / 0股", sent_messages[0]) self.assertNotIn("📈 QQQ 基准\n - QQQ: 588.50\n - MA200: 595.25\n - 退出线: 573.00", sent_messages[0]) self.assertNotIn("🎯 信号:", sent_messages[0]) # Dry-run cycles now surface the account new-risk gate diagnostic note so # verification reports can prove cycle-health axes; no-op heartbeats with # this note fall into the "no executable orders" branch instead of the # plain "no rebalance needed" branch. - self.assertIn("[Account new-risk gate]", sent_messages[0]) + self.assertNotIn("[Account new-risk gate]", sent_messages[0]) self.assertNotIn("账户现金: $0.00 | 可投资现金", sent_messages[0]) self.assertNotIn("TQQQ: $0.00 BOXX", sent_messages[0]) self.assertNotIn("📊 市场状态: ", sent_messages[0]) diff --git a/tests/test_uv_dependency_workflow.py b/tests/test_uv_dependency_workflow.py index bfd85f9..dbb7ec4 100644 --- a/tests/test_uv_dependency_workflow.py +++ b/tests/test_uv_dependency_workflow.py @@ -32,3 +32,12 @@ def test_ci_docker_and_env_sync_use_uv_lock() -> None: assert "--no-install-project" not in ci assert "--no-install-project" not in env_sync assert "--no-install-project" not in dockerfile + + +def test_cloud_run_and_gunicorn_timeouts_leave_response_margin() -> None: + dockerfile = Path("Dockerfile").read_text(encoding="utf-8") + env_sync = Path(".github/workflows/sync-cloud-run-env.yml").read_text(encoding="utf-8") + + assert '"--timeout", "570"' in dockerfile + assert "--timeout=600s" in env_sync + assert "--timeout=300s" not in env_sync