diff --git a/application/rebalance_service.py b/application/rebalance_service.py index 195daac..d2b4d84 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -65,15 +65,24 @@ def _localize_notification_text(text: str, *, translator) -> str: ) -def _should_suppress_noop_notification(signal_metadata: Mapping[str, object] | None, *, order_count: int = -1, has_error: bool = False) -> bool: +def _should_suppress_noop_notification( + signal_metadata: Mapping[str, object] | None, + *, + order_count: int = -1, + has_error: bool = False, + notify_no_trade_cycles: bool = True, +) -> bool: """Return True when we should skip sending a Telegram notification. Suppress when: - Outside execution window (monthly/weekly cadence) + - No orders placed, no errors, and no-trade cycle notifications are disabled - No orders placed, no errors, and signal is idle/waiting - Purely informational 'waiting for signal' runs """ metadata = signal_metadata if isinstance(signal_metadata, Mapping) else {} + if order_count == 0 and not has_error and not notify_no_trade_cycles: + return True no_op_reason = str(metadata.get("no_op_reason") or "").strip() if no_op_reason.startswith(("outside_execution_window", "outside_monthly_execution_window")): return True @@ -94,6 +103,29 @@ def _should_suppress_noop_notification(signal_metadata: Mapping[str, object] | N return False +def _execution_summary_has_order_activity(execution_summary: Mapping[str, object] | None) -> bool: + summary = execution_summary if isinstance(execution_summary, Mapping) else {} + return any( + bool(tuple(summary.get(key) or ())) + for key in ( + "orders_submitted", + "orders_filled", + "orders_partially_filled", + "option_orders_submitted", + "option_orders_filled", + "option_orders_partially_filled", + ) + ) + + +def _has_order_activity(*, trade_logs, execution_summary) -> bool: + if _execution_summary_has_order_activity(execution_summary): + return True + if execution_summary is None and tuple(trade_logs or ()): + return True + return False + + def _normalize_reconciliation_mode(value: object, *, fallback: str = "") -> str: normalized = str(value or "").strip().lower().replace("-", "_") if normalized in {"dry_run", "paper", "live"}: @@ -819,9 +851,12 @@ def run_strategy_core( flush=True, ) order_count = len(orders) if 'orders' in dir() and orders else 0 - has_error = bool(no_op_reason or fail_reason) + has_error = bool(fail_reason) notification_suppressed = _should_suppress_noop_notification( - signal_metadata, order_count=order_count, has_error=has_error + signal_metadata, + order_count=order_count, + has_error=has_error, + notify_no_trade_cycles=getattr(config, "notify_no_trade_cycles", True), ) if notification_suppressed: print( @@ -906,22 +941,36 @@ def run_strategy_core( no_op_reason="execution_already_recorded", ) record_path = write_reconciliation_record(record, output_path=config.reconciliation_output_path) - notification_publisher.publish( - notification_renderers.render_heartbeat_notification( - dashboard=dashboard, - strategy_dashboard=strategy_dashboard, - no_op_text=config.translator("no_trades"), - signal_desc=signal_desc, - status_desc=status_desc, - status_icon=signal_metadata.get("status_icon", "🐤"), - translator=config.translator, - separator=config.separator, - strategy_display_name=config.strategy_display_name, - extra_notification_lines=config.extra_notification_lines, + notification_suppressed = not getattr(config, "notify_no_trade_cycles", True) + if notification_suppressed: + print( + "notification_suppressed " + + json.dumps( + { + "reason": "execution_already_recorded", + "strategy_profile": signal_metadata.get("strategy_profile"), + }, + ensure_ascii=False, + ), + flush=True, + ) + else: + notification_publisher.publish( + notification_renderers.render_heartbeat_notification( + dashboard=dashboard, + strategy_dashboard=strategy_dashboard, + no_op_text=config.translator("no_trades"), + signal_desc=signal_desc, + status_desc=status_desc, + status_icon=signal_metadata.get("status_icon", "🐤"), + translator=config.translator, + separator=config.separator, + strategy_display_name=config.strategy_display_name, + extra_notification_lines=config.extra_notification_lines, + ) ) - ) return StrategyCycleResult( - result="OK - heartbeat", + result="OK - no-op" if notification_suppressed else "OK - heartbeat", signal_metadata=dict(signal_metadata or {}), target_weights=dict(resolved_target_weights or {}), execution_summary={"action_done": False, "no_op_reason": "execution_already_recorded"}, @@ -982,21 +1031,38 @@ def run_strategy_core( ), flush=True, ) - notification_publisher.publish( - notification_renderers.render_trade_notification( - dashboard=dashboard, - strategy_dashboard=strategy_dashboard, - trade_logs=trade_logs, - execution_summary=execution_summary, - signal_desc=signal_desc, - status_desc=status_desc, - status_icon=signal_metadata.get("status_icon", "🐤"), - translator=config.translator, - separator=config.separator, - strategy_display_name=config.strategy_display_name, - extra_notification_lines=config.extra_notification_lines, + if _has_order_activity(trade_logs=trade_logs, execution_summary=execution_summary) or getattr( + config, + "notify_no_trade_cycles", + True, + ): + notification_publisher.publish( + notification_renderers.render_trade_notification( + dashboard=dashboard, + strategy_dashboard=strategy_dashboard, + trade_logs=trade_logs, + execution_summary=execution_summary, + signal_desc=signal_desc, + status_desc=status_desc, + status_icon=signal_metadata.get("status_icon", "🐤"), + translator=config.translator, + separator=config.separator, + strategy_display_name=config.strategy_display_name, + extra_notification_lines=config.extra_notification_lines, + ) + ) + else: + print( + "notification_suppressed " + + json.dumps( + { + "reason": "no_trade_or_error", + "trade_logs_count": len(tuple(trade_logs or ())), + }, + ensure_ascii=False, + ), + flush=True, ) - ) return StrategyCycleResult( result="OK - executed", signal_metadata=dict(signal_metadata or {}), diff --git a/application/runtime_composer.py b/application/runtime_composer.py index b630590..ca280cf 100644 --- a/application/runtime_composer.py +++ b/application/runtime_composer.py @@ -170,6 +170,7 @@ def build_rebalance_config(self, *, extra_notification_lines=(), cash_only_execu execution_mode=execution_mode, strategy_profile=self.strategy_profile, dry_run_only=self.dry_run_only, + notify_no_trade_cycles=False, execution_dedup_enabled=resolve_execution_dedup_enabled( platform_env_prefix="IBKR", env_reader=self.env_reader, diff --git a/application/runtime_dependencies.py b/application/runtime_dependencies.py index a1ce114..f23cc57 100644 --- a/application/runtime_dependencies.py +++ b/application/runtime_dependencies.py @@ -21,6 +21,7 @@ class IBKRRebalanceConfig: execution_mode: str = "paper" strategy_profile: str = "" dry_run_only: bool = False + notify_no_trade_cycles: bool = True execution_dedup_enabled: bool = False execution_state_store: Any = None execution_state_account_scope: str = "" diff --git a/constraints.txt b/constraints.txt index 5fcff72..fefe067 100644 --- a/constraints.txt +++ b/constraints.txt @@ -3,7 +3,7 @@ # Generated: 2026-07-01 # Auto-updated by update-qpk-pin.yml on every push to QPK main. -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b0eacd2fe4884f7f2447b704a232e9a121f396c4 +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b9a7df85cfc848cebcc3aa6e1d77ec34ca7611ab us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@6568c315ce3be6f7ae5b799374cf7fb44232c170 hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@e9e3058c1eaf3f43b25d50df5eb14442816e568e cn-equity-strategies @ git+https://github.com/QuantStrategyLab/CnEquityStrategies.git@f6c735c33047d7613a23d5df018ed32f394e6001 diff --git a/requirements.txt b/requirements.txt index 1f5d31d..fcc9d93 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,6 +11,6 @@ google-cloud-compute google-cloud-secret-manager google-cloud-storage yfinance -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b0eacd2fe4884f7f2447b704a232e9a121f396c4 +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b9a7df85cfc848cebcc3aa6e1d77ec34ca7611ab us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@6568c315ce3be6f7ae5b799374cf7fb44232c170 hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@e9e3058c1eaf3f43b25d50df5eb14442816e568e diff --git a/scripts/check_qpk_pin_consistency.py b/scripts/check_qpk_pin_consistency.py index 360f881..02349db 100644 --- a/scripts/check_qpk_pin_consistency.py +++ b/scripts/check_qpk_pin_consistency.py @@ -2,11 +2,11 @@ """Check that all QPK git references match the canonical QPK_PIN. Usage: python scripts/check_qpk_pin_consistency.py [--fix] """ -import re, subprocess, sys +import re, sys from pathlib import Path QPK_PIN_URL = "https://raw.githubusercontent.com/QuantStrategyLab/QuantPlatformKit/main/QPK_PIN" -SHA_RE = re.compile(r"@([a-f0-9]{40})") +QPK_REF_RE = re.compile(r"QuantPlatformKit\.git@([a-f0-9]{40})") def fetch_pin() -> str: import urllib.request @@ -23,15 +23,17 @@ def main(): for path in sorted(Path.cwd().glob("**/requirements*.txt")) + sorted(Path.cwd().glob("**/pyproject.toml")): if "external" in str(path): continue content = path.read_text() - for m in SHA_RE.finditer(content): + updated = content + for m in QPK_REF_RE.finditer(content): sha = m.group(1) - if "QuantPlatformKit" not in content[max(0,m.start()-200):m.end()]: continue if sha != target: errors += 1 print(f" ❌ {path}: QPK@{sha[:12]} (expected {target[:12]})") if fix: - path.write_text(content.replace(sha, target)) + updated = updated.replace(f"QuantPlatformKit.git@{sha}", f"QuantPlatformKit.git@{target}") print(" → fixed") + if fix and updated != content: + path.write_text(updated) if errors: print(f"\n{errors} mismatch(es). Run with --fix to auto-fix.") return 1 diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index eafb276..1e62c62 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -293,25 +293,18 @@ def fake_execute_rebalance( ), execute_rebalance=fake_execute_rebalance, send_tg_message=lambda message: observed["messages"].append(message), - translator=_build_test_translator(), - separator="---", - strategy_display_name="Global ETF Rotation", + config=IBKRRebalanceConfig( + translator=_build_test_translator(), + separator="---", + strategy_display_name="Global ETF Rotation", + notify_no_trade_cycles=False, + ), ) assert result.result == "OK - executed" assert observed["strategy_symbols"] == ("AAA", "BOXX") assert observed["signal_metadata"]["managed_symbols"] == ("AAA", "BOXX") - assert observed["messages"] - assert "Account Summary" not in observed["messages"][0] - assert "Current Positions" not in observed["messages"][0] - assert "Execution Summary" not in observed["messages"][0] - assert "📌 Strategy portfolio" in observed["messages"][0] - assert "Total assets (strategy symbols + cash): $1,000.00" in observed["messages"][0] - assert "💼 Strategy holdings" in observed["messages"][0] - assert "📏 breadth=60.0%" not in observed["messages"][0] - assert "⏱ Timing:" not in observed["messages"][0] - assert "Signal snapshot:" not in observed["messages"][0] - assert "Target Weights" not in observed["messages"][0] + assert observed["messages"] == [] def test_run_strategy_core_writes_reconciliation_record(tmp_path): diff --git a/tests/test_runtime_composer.py b/tests/test_runtime_composer.py index b0e1567..6c4725f 100644 --- a/tests/test_runtime_composer.py +++ b/tests/test_runtime_composer.py @@ -104,4 +104,5 @@ def fake_reporting_builder(**kwargs): assert config.strategy_display_name == "全球 ETF 轮动" assert config.reconciliation_output_path == "/tmp/reconciliation.json" assert config.extra_notification_lines == ("plugin-line",) + assert config.notify_no_trade_cycles is False assert reporting_adapters == "reporting-adapters"