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
128 changes: 97 additions & 31 deletions application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"}:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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 {}),
Expand Down
1 change: 1 addition & 0 deletions application/runtime_composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions application/runtime_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down
2 changes: 1 addition & 1 deletion constraints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 7 additions & 5 deletions scripts/check_qpk_pin_consistency.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
21 changes: 7 additions & 14 deletions tests/test_rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions tests/test_runtime_composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading