From 26a9058e3f845776402a78d2c9d9c0762f007396 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 22 Apr 2026 04:06:15 +0800 Subject: [PATCH 1/3] Pin shared runtime packages for Cloud Run startup --- .github/workflows/ci.yml | 16 ++++++++++++++++ requirements.txt | 4 ++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2378d3e..ca86fba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,22 @@ jobs: set -euo pipefail python -m pip install --upgrade pip python -m pip install -r requirements.txt pytest ruff + + - name: Smoke import pinned shared packages + run: | + set -euo pipefail + python - <<'PY' + from quant_platform_kit.common.port_adapters import CallableNotificationPort, CallablePortfolioPort + from us_equity_strategies import resolve_canonical_profile + + assert CallableNotificationPort + assert CallablePortfolioPort + assert resolve_canonical_profile("mega_cap_leader_rotation_top50_balanced") == "mega_cap_leader_rotation_top50_balanced" + PY + + - name: Install editable shared repositories + run: | + set -euo pipefail python -m pip install --no-deps -e external/QuantPlatformKit -e external/UsEquityStrategies -e external/UsEquitySnapshotPipelines - name: Run ruff diff --git a/requirements.txt b/requirements.txt index 0b4163f..e0c8850 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ flask gunicorn -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@v0.7.19 -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@v0.7.31 +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@ac550fee3ca33bbf28c8a886848dede4e4e09a1b +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@0f4373f69c9d9f800eda476ff213b7013e51aba4 pandas numpy requests From 373513c1afe3a92236d444a743239530a6841aed Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 22 Apr 2026 04:46:55 +0800 Subject: [PATCH 2/3] Document Cloud Run source staging bucket IAM --- README.md | 4 ++++ docs/ibkr_runtime_rollout.md | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/README.md b/README.md index f17469d..d989a8c 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,7 @@ Important: - Here "shared config" still only means the **IBKR pair** (`InteractiveBrokersPlatform` + `IBKRGatewayManager`). `TELEGRAM_TOKEN` and `TELEGRAM_TOKEN_SECRET_NAME` remain repository-specific. - If `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` is set, the Cloud Run runtime needs Secret Manager access to that secret. - GitHub now authenticates to Google Cloud with OIDC + Workload Identity Federation, so `GCP_SA_KEY` is no longer required for this workflow. +- If you deploy with `gcloud run deploy --source` or a Cloud Run source trigger, the staging bucket `gs://run-sources--` also needs `roles/storage.objectViewer` for the build service account, the deploy service account, and the default compute service account. Missing this binding fails the deploy before Cloud Build starts with `storage.objects.get` denied. ### Deployment unit and naming @@ -258,6 +259,7 @@ Important: 1. **GCE**: Set up IB Gateway (paper or live) on a GCE instance. Ensure API access is enabled, remote clients are allowed when needed, and use `4001` for `live` or `4002` for `paper`. 2. **VPC / Subnet**: Put Cloud Run and GCE in the same VPC. For cleaner firewall rules, reserve a dedicated subnet for Cloud Run Direct VPC egress. 3. **Cloud Run**: Deploy or update this Flask app with Direct VPC egress. Set `STRATEGY_PROFILE`, `ACCOUNT_GROUP`, and `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME`. Keep `IB_GATEWAY_ZONE` / `IB_GATEWAY_IP_MODE` only as transition fallbacks if the selected account-group payload does not already contain them. The runtime service account needs `roles/secretmanager.secretAccessor` and, for instance-name resolution, `roles/compute.viewer`. + - For Cloud Run source deploy, also grant `roles/storage.objectViewer` on `gs://run-sources-${PROJECT_ID}-${REGION}` to the build service account, the deploy service account, and `${PROJECT_NUMBER}-compute@developer.gserviceaccount.com`. 4. **Firewall**: Allow TCP `4001` (`live`) or `4002` (`paper`) from the Cloud Run egress subnet CIDR to the GCE instance. 5. **Cloud Scheduler**: Create a job that POSTs to the Cloud Run URL. Choose the cron from the strategy-layer cadence in `UsEquityStrategies`; daily profiles can still use a near-close weekday schedule such as `45 15 * * 1-5` in `America/New_York`. 6. **Optional public-IP mode**: Only if you cannot use VPC, set `IB_GATEWAY_IP_MODE=external`, expose the GCE public IP deliberately, and restrict source ranges tightly. This is not the default path. @@ -449,6 +451,7 @@ IB_GATEWAY_IP_MODE=internal - 这里说的“共享配置”仍然只针对 **IBKR 这一组系统**。`TELEGRAM_TOKEN` 和 `TELEGRAM_TOKEN_SECRET_NAME` 都还是这个仓库自己的配置,不建议提升成所有 quant 共用的全局配置。 - 如果设置了 `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME`,Cloud Run 运行时还需要有对应 Secret 的访问权限。 - GitHub 现在通过 OIDC + Workload Identity Federation 登录 Google Cloud,这个 workflow 不再需要 `GCP_SA_KEY`。 +- 如果你用 `gcloud run deploy --source` 或 Cloud Run source trigger 部署,还要给 staging bucket `gs://run-sources--` 补 `roles/storage.objectViewer`,对象包括 build service account、deploy service account、默认 compute service account。少了这层权限,会在 Cloud Build 启动前直接报 `storage.objects.get denied`。 ### 部署单元和命名建议 @@ -463,6 +466,7 @@ IB_GATEWAY_IP_MODE=internal 1. **GCE**: 部署 IB Gateway(模拟或实盘),确认 API 已开启、需要远程连接时已允许非 localhost 客户端,并确认 `live` 使用 `4001`、`paper` 使用 `4002`。 2. **VPC / 子网**: 让 Cloud Run 和 GCE 处于同一个 VPC。为了让防火墙规则更干净,建议给 Cloud Run Direct VPC egress 单独准备一个子网。 3. **Cloud Run**: 部署此 Flask 应用时启用 Direct VPC egress。设置 `STRATEGY_PROFILE`、`ACCOUNT_GROUP`、`IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME`;只有在账号组配置里还没放 `ib_gateway_zone` / `ib_gateway_ip_mode` 时,才临时保留 `IB_GATEWAY_ZONE` / `IB_GATEWAY_IP_MODE` 作为过渡 fallback。runtime service account 需要 `roles/secretmanager.secretAccessor`,若走实例名解析,还需要 `roles/compute.viewer`。 + - 如果使用 Cloud Run source deploy,还要给 `gs://run-sources-${PROJECT_ID}-${REGION}` 这个 bucket 授权 `roles/storage.objectViewer`,对象是 build service account、deploy service account,以及 `${PROJECT_NUMBER}-compute@developer.gserviceaccount.com`。 4. **防火墙**: 只允许 Cloud Run 出口子网访问 GCE 的 `TCP 4001`(`live`)或 `TCP 4002`(`paper`)。 5. **Cloud Scheduler**: 创建定时任务,POST 到 Cloud Run URL。cron 频率以 `UsEquityStrategies` 里的策略层 cadence 为准;日频 profile 仍可使用美股临近收盘的工作日计划,例如 `45 15 * * 1-5`(America/New_York 时区)。 6. **可选公网模式**: 只有在不能走 VPC 时,才设置 `IB_GATEWAY_IP_MODE=external`,并且要明确开放 GCE 公网 IP,同时严格限制来源 IP 和防火墙规则。 diff --git a/docs/ibkr_runtime_rollout.md b/docs/ibkr_runtime_rollout.md index c78fc15..24f2b71 100644 --- a/docs/ibkr_runtime_rollout.md +++ b/docs/ibkr_runtime_rollout.md @@ -151,6 +151,38 @@ gcloud projects add-iam-policy-binding "${PROJECT_ID}" \ 如果你后面改成直接配固定私网 host,而且代码不再走实例名解析,这个权限可以再收回。**但按现在这套推荐部署,先给上更稳。** +## 3.1 Cloud Run source deploy 的 staging bucket 权限 + +如果部署路径是 `gcloud run deploy --source`,或 Cloud Run 绑定 GitHub/source trigger,那么还要检查 staging bucket: + +- bucket 一般是 `gs://run-sources-${PROJECT_ID}-${REGION}` +- 至少需要给下面这些身份授予 `roles/storage.objectViewer` + - build service account + - deploy service account + - 默认 compute service account:`${PROJECT_NUMBER}-compute@developer.gserviceaccount.com` + +少了这层权限,部署会在“源码上传完成后、Cloud Build 启动前”直接失败,报错类似: + +```text +storage.objects.get denied on gs://run-sources-... +``` + +示例: + +```bash +gcloud storage buckets add-iam-policy-binding "gs://run-sources-${PROJECT_ID}-${REGION}" \ + --member="serviceAccount:${BUILD_SA}" \ + --role="roles/storage.objectViewer" + +gcloud storage buckets add-iam-policy-binding "gs://run-sources-${PROJECT_ID}-${REGION}" \ + --member="serviceAccount:${DEPLOY_SA}" \ + --role="roles/storage.objectViewer" + +gcloud storage buckets add-iam-policy-binding "gs://run-sources-${PROJECT_ID}-${REGION}" \ + --member="serviceAccount:${PROJECT_NUMBER}-compute@developer.gserviceaccount.com" \ + --role="roles/storage.objectViewer" +``` + ### 当前这一步不用先加的权限 - 不需要为了这个仓库再加 Storage 锁相关权限;当前代码已经不靠 `EXECUTION_LOCK_BUCKET` 那套 GCS 锁运行。 From e7f0e5019b416d92eca3ab1b613f3cbaa26753e9 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 23 Apr 2026 03:59:04 +0800 Subject: [PATCH 3/3] Fix timing i18n and pin shared strategy update --- application/rebalance_service.py | 87 ++++++++++++++++++++++++++++---- notifications/renderers.py | 24 +++++++-- notifications/telegram.py | 16 ++++++ requirements.txt | 2 +- tests/test_rebalance_service.py | 53 ++++++++++++++++++- 5 files changed, 167 insertions(+), 15 deletions(-) diff --git a/application/rebalance_service.py b/application/rebalance_service.py index ff7c4dd..0e37130 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -131,6 +131,48 @@ def _localize_notification_text(text: str, *, translator) -> str: return localized +def _localize_timing_contract(contract: str, *, translator) -> str: + value = str(contract or "").strip() + if not value: + return "" + if value == "same_trading_day": + return "当日执行" if _translator_uses_zh(translator) else "same trading day" + if value == "next_trading_day": + return "次一交易日执行" if _translator_uses_zh(translator) else "next trading day" + match = re.fullmatch(r"next_(\d+)_trading_days", value) + if match: + count = int(match.group(1)) + if _translator_uses_zh(translator): + return f"{count}个交易日后执行" + return f"next {count} trading days" + return _localize_notification_text(value, translator=translator) + + +def _render_notification_context_text( + notification_context: Mapping[str, object] | None, + *, + translator, + fallback: str = "", +) -> str: + if not isinstance(notification_context, Mapping): + return fallback + key = str(notification_context.get("code") or "").strip() + if not key: + return fallback + params = dict(notification_context.get("params") or {}) + rendered = translator(key, **params) + return fallback if rendered == key else str(rendered) + + +def _translate_snapshot_guard_decision(decision: object, *, translator) -> str: + value = str(decision or "").strip() + if not value: + return "" + key = f"snapshot_guard_decision_{value}" + translated = translator(key) + return value if translated == key else str(translated) + + def _split_detail_segment(text: str) -> list[str]: value = str(text or "").strip() if not value: @@ -328,12 +370,13 @@ def _build_timing_audit_lines(signal_metadata, *, translator) -> list[str]: if not signal_date and not effective_date and not contract: return [] label = "⏱ 执行时点" if _translator_uses_zh(translator) else "⏱ Timing" + localized_contract = _localize_timing_contract(contract, translator=translator) if signal_date and effective_date: value = f"{signal_date} -> {effective_date}" else: - value = signal_date or effective_date or contract - if contract and contract not in value: - value = f"{value} ({contract})" if value else contract + value = signal_date or effective_date or localized_contract + if localized_contract and localized_contract not in value: + value = f"{value} ({localized_contract})" if value else localized_contract return [f"{label}: {value}"] @@ -585,13 +628,37 @@ def run_strategy_core( decision = signal_metadata.get("snapshot_guard_decision") no_op_reason = signal_metadata.get("no_op_reason") fail_reason = signal_metadata.get("fail_reason") - no_op_text = config.translator("no_trades") - if decision: - no_op_text = f"{no_op_text} | {_localize_notification_text(f'decision={decision}', translator=config.translator)}" - if no_op_reason: - no_op_text = f"{no_op_text} | {_localize_notification_text(f'reason={no_op_reason}', translator=config.translator)}" - if fail_reason: - no_op_text = f"{no_op_text} | {_localize_notification_text(f'fail_reason={fail_reason}', translator=config.translator)}" + notification_context = signal_metadata.get("notification_context") + status_context = ( + notification_context.get("status") + if isinstance(notification_context, Mapping) + else None + ) + rendered_status = _render_notification_context_text( + status_context, + translator=config.translator, + fallback="", + ) + no_op_segments = [config.translator("no_trades")] + if rendered_status: + no_op_segments.append(rendered_status) + else: + if decision: + no_op_segments.append( + config.translator( + "snapshot_decision_detail", + value=_translate_snapshot_guard_decision(decision, translator=config.translator), + ) + ) + if no_op_reason: + no_op_segments.append( + _localize_notification_text(f"reason={no_op_reason}", translator=config.translator) + ) + if fail_reason: + no_op_segments.append( + _localize_notification_text(f"fail_reason={fail_reason}", translator=config.translator) + ) + no_op_text = " | ".join(segment for segment in no_op_segments if str(segment).strip()) no_op_text = "\n".join(_split_labeled_text(no_op_text)) record = build_reconciliation_record( strategy_profile=signal_metadata.get("strategy_profile"), diff --git a/notifications/renderers.py b/notifications/renderers.py index 21b7548..8124194 100644 --- a/notifications/renderers.py +++ b/notifications/renderers.py @@ -120,6 +120,23 @@ def _localize_notification_text(text: str, *, translator) -> str: return localized +def _localize_timing_contract(contract: str, *, translator) -> str: + value = str(contract or "").strip() + if not value: + return "" + if value == "same_trading_day": + return "当日执行" if _translator_uses_zh(translator) else "same trading day" + if value == "next_trading_day": + return "次一交易日执行" if _translator_uses_zh(translator) else "next trading day" + match = re.fullmatch(r"next_(\d+)_trading_days", value) + if match: + count = int(match.group(1)) + if _translator_uses_zh(translator): + return f"{count}个交易日后执行" + return f"next {count} trading days" + return _localize_notification_text(value, translator=translator) + + def _split_detail_segment(text: str) -> list[str]: value = str(text or "").strip() if not value: @@ -317,12 +334,13 @@ def _build_timing_audit_lines(signal_metadata, *, translator) -> list[str]: if not signal_date and not effective_date and not contract: return [] label = "⏱ 执行时点" if _translator_uses_zh(translator) else "⏱ Timing" + localized_contract = _localize_timing_contract(contract, translator=translator) if signal_date and effective_date: value = f"{signal_date} -> {effective_date}" else: - value = signal_date or effective_date or contract - if contract and contract not in value: - value = f"{value} ({contract})" if value else contract + value = signal_date or effective_date or localized_contract + if localized_contract and localized_contract not in value: + value = f"{value} ({localized_contract})" if value else localized_contract return [f"{label}: {value}"] diff --git a/notifications/telegram.py b/notifications/telegram.py index 3c33cef..19f6aac 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -12,6 +12,8 @@ "strategy_label": "🧭 策略: {name}", "equity": "净值", "buying_power": "购买力", + "reserved_cash": "预留现金", + "investable_cash": "可投资现金", "empty_positions": " (空仓)", "empty_target_weights": " (无目标持仓)", "account_summary_title": "📊 账户摘要", @@ -50,6 +52,12 @@ "partial_sell_batch": "⚠️ 卖单部分成交 {count}个标的: {details}", "no_equity": "❌ 无净值", "signal_label": "信号", + "signal_monthly_snapshot_waiting": "月度快照节奏 | 等待进入执行窗口", + "status_monthly_snapshot_waiting_window": "不执行 | 原因=当前不在月度执行窗口 | 快照日期={snapshot_as_of} | 允许日期={allowed_dates}", + "status_no_execution_window_after_snapshot": "不执行 | 原因=快照后没有可用执行窗口 | 快照日期={snapshot_as_of}", + "snapshot_guard_decision_proceed": "继续", + "snapshot_guard_decision_no_op": "不执行", + "snapshot_guard_decision_fail_closed": "关闭执行", "market_status_risk_on": "🚀 风险开启({asset})", "market_status_delever": "🛡️ 降杠杆({asset})", "signal_risk_on": "SOXL 站上 {window} 日均线,持有 SOXL,交易层风险仓位 {ratio}", @@ -94,6 +102,8 @@ "strategy_label": "🧭 Strategy: {name}", "equity": "Equity", "buying_power": "Buying Power", + "reserved_cash": "Reserved Cash", + "investable_cash": "Investable Cash", "empty_positions": " (No positions)", "empty_target_weights": " (No target positions)", "account_summary_title": "📊 Account Summary", @@ -132,6 +142,12 @@ "partial_sell_batch": "⚠️ Partial sell fills for {count} symbols: {details}", "no_equity": "❌ No equity", "signal_label": "Signal", + "signal_monthly_snapshot_waiting": "monthly snapshot cadence | waiting inside execution window", + "status_monthly_snapshot_waiting_window": "no-op | reason=outside monthly execution window | snapshot_as_of={snapshot_as_of} | allowed={allowed_dates}", + "status_no_execution_window_after_snapshot": "no-op | reason=no execution window after snapshot | snapshot_as_of={snapshot_as_of}", + "snapshot_guard_decision_proceed": "proceed", + "snapshot_guard_decision_no_op": "no_op", + "snapshot_guard_decision_fail_closed": "fail_closed", "market_status_risk_on": "🚀 RISK-ON ({asset})", "market_status_delever": "🛡️ DE-LEVER ({asset})", "signal_risk_on": "SOXL above {window}d MA, hold SOXL, risk {ratio}", diff --git a/requirements.txt b/requirements.txt index e0c8850..34bbd20 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ flask gunicorn quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@ac550fee3ca33bbf28c8a886848dede4e4e09a1b -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@0f4373f69c9d9f800eda476ff213b7013e51aba4 +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@fcffc7e3901089ddc10e161d4e5703417c0a03d3 pandas numpy requests diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index be65454..51922f3 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -240,7 +240,7 @@ def fake_execute_rebalance( assert "Total assets (strategy symbols + cash): $1,000.00" in observed["messages"][0] assert "💼 Strategy holdings" in observed["messages"][0] assert "📏 breadth=60.0%" in observed["messages"][0] - assert "⏱ Timing: 2026-04-01 -> 2026-04-02 (next_trading_day)" in observed["messages"][0] + assert "⏱ Timing: 2026-04-01 -> 2026-04-02 (next trading day)" in observed["messages"][0] assert "Target Weights" not in observed["messages"][0] @@ -368,3 +368,54 @@ def disconnect(self): assert payload_path is not None payload = json.loads(payload_path.read_text(encoding="utf-8")) assert payload["no_op_reason"] == "outside_execution_window" + + +def test_run_strategy_core_prefers_structured_noop_status_in_zh(): + observed = {"messages": []} + + class FakeIB: + def isConnected(self): + return True + + def disconnect(self): + return None + + result = run_strategy_core( + connect_ib=lambda: FakeIB(), + get_current_portfolio=lambda _ib: ({}, {"equity": 1000.0, "buying_power": 500.0}), + compute_signals=lambda _ib, _holdings: ( + None, + "monthly snapshot cadence | waiting inside execution window", + False, + "no-op | reason=outside_monthly_execution_window", + { + "strategy_profile": "mega_cap_leader_rotation_top50_balanced", + "trade_date": "2026-04-22", + "snapshot_as_of": "2026-04-16", + "snapshot_guard_decision": "proceed", + "no_op_reason": "outside_monthly_execution_window snapshot=2026-04-16 allowed=2026-04-17,2026-04-20,2026-04-21", + "managed_symbols": ("AAPL", "MSFT", "BOXX"), + "dry_run_only": True, + "notification_context": { + "signal": {"code": "signal_monthly_snapshot_waiting", "params": {}}, + "status": { + "code": "status_monthly_snapshot_waiting_window", + "params": { + "snapshot_as_of": "2026-04-16", + "allowed_dates": "2026-04-17, 2026-04-20, 2026-04-21", + }, + }, + }, + }, + ), + execute_rebalance=lambda *_args, **_kwargs: [], + send_tg_message=lambda message: observed["messages"].append(message), + translator=build_translator("zh"), + separator="---", + strategy_display_name="Mega Cap Top50 平衡龙头轮动", + ) + + assert result.result == "OK - heartbeat" + assert observed["messages"] + assert "当前不在月度执行窗口" in observed["messages"][0] + assert "决策=proceed" not in observed["messages"][0]