diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index a30915e..ddb9959 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -21,7 +21,7 @@ jobs: CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }} CLOUD_RUN_SERVICE: ${{ vars.CLOUD_RUN_SERVICE }} TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }} - STRATEGY_PROFILE: ${{ vars.STRATEGY_PROFILE }} + RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON }} ACCOUNT_GROUP: ${{ vars.ACCOUNT_GROUP }} IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME: ${{ vars.IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME }} IBKR_FEATURE_SNAPSHOT_PATH: ${{ vars.IBKR_FEATURE_SNAPSHOT_PATH }} @@ -29,6 +29,7 @@ jobs: IBKR_STRATEGY_CONFIG_PATH: ${{ vars.IBKR_STRATEGY_CONFIG_PATH }} IBKR_RECONCILIATION_OUTPUT_PATH: ${{ vars.IBKR_RECONCILIATION_OUTPUT_PATH }} IBKR_DRY_RUN_ONLY: ${{ vars.IBKR_DRY_RUN_ONLY }} + # Strategy-owned defaults continue to come from UsEquityStrategies; this workflow only syncs platform/runtime inputs. EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }} IB_GATEWAY_ZONE: ${{ vars.IB_GATEWAY_ZONE }} IB_GATEWAY_IP_MODE: ${{ vars.IB_GATEWAY_IP_MODE }} @@ -78,9 +79,13 @@ jobs: import sys from us_equity_strategies import resolve_canonical_profile - profile = os.environ.get("STRATEGY_PROFILE", "").strip().lower() + raw_runtime_target = os.environ.get("RUNTIME_TARGET_JSON", "").strip() + if not raw_runtime_target: + raise SystemExit("RUNTIME_TARGET_JSON is required") + runtime_target = json.loads(raw_runtime_target) + profile = str(runtime_target.get("strategy_profile") or "").strip().lower() if not profile: - raise SystemExit("STRATEGY_PROFILE is required") + raise SystemExit("RUNTIME_TARGET_JSON.strategy_profile is required") canonical_profile = resolve_canonical_profile(profile) raw_status = subprocess.check_output( @@ -112,6 +117,7 @@ jobs: output.write( f"reconciliation_output_policy={str(selected.get('reconciliation_output_policy') or 'none')}\n" ) + output.write(f"runtime_target_json={json.dumps(runtime_target, sort_keys=True)}\n") PY - name: Validate env sync inputs @@ -122,13 +128,13 @@ jobs: REQUIRES_STRATEGY_CONFIG_PATH: ${{ steps.strategy_requirements.outputs.requires_strategy_config_path }} CONFIG_SOURCE_POLICY: ${{ steps.strategy_requirements.outputs.config_source_policy }} RECONCILIATION_OUTPUT_POLICY: ${{ steps.strategy_requirements.outputs.reconciliation_output_policy }} + RUNTIME_TARGET_JSON: ${{ steps.strategy_requirements.outputs.runtime_target_json }} run: | set -euo pipefail required_vars=( CLOUD_RUN_REGION CLOUD_RUN_SERVICE - STRATEGY_PROFILE ACCOUNT_GROUP IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME GLOBAL_TELEGRAM_CHAT_ID @@ -151,6 +157,10 @@ jobs: required_vars+=(IBKR_RECONCILIATION_OUTPUT_PATH) fi + if [ -z "${RUNTIME_TARGET_JSON:-}" ]; then + required_vars+=(RUNTIME_TARGET_JSON) + fi + missing_vars=() for var_name in "${required_vars[@]}"; do if [ -z "${!var_name:-}" ]; then @@ -209,15 +219,32 @@ jobs: - name: Sync Cloud Run environment if: steps.config.outputs.enabled == 'true' + env: + RUNTIME_TARGET_JSON: ${{ steps.strategy_requirements.outputs.runtime_target_json }} run: | set -euo pipefail + join_by_delimiter() { + local delimiter="$1" + shift + local output="" + local item + for item in "$@"; do + if [ -z "${output}" ]; then + output="${item}" + else + output="${output}${delimiter}${item}" + fi + done + printf '%s' "${output}" + } + env_pairs=( "GLOBAL_TELEGRAM_CHAT_ID=${GLOBAL_TELEGRAM_CHAT_ID}" "NOTIFY_LANG=${NOTIFY_LANG}" - "STRATEGY_PROFILE=${STRATEGY_PROFILE}" "ACCOUNT_GROUP=${ACCOUNT_GROUP}" "IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME=${IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME}" + "RUNTIME_TARGET_JSON=${RUNTIME_TARGET_JSON}" ) secret_pairs=() @@ -292,7 +319,7 @@ jobs: --concurrency 1 --max-instances 1 --remove-env-vars "$(IFS=,; echo "${remove_env_vars[*]}")" - --update-env-vars "$(IFS=,; echo "${env_pairs[*]}")" + --update-env-vars "^|^$(join_by_delimiter "|" "${env_pairs[@]}")" ) if [ "${#remove_secret_vars[@]}" -gt 0 ]; then diff --git a/README.md b/README.md index e46964a..40af098 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,10 @@ ## English -IBKR runtime for shared `us_equity` strategy profiles from `UsEquityStrategies`. It supports the `us_equity` profiles listed in the IBKR profile status table below. Strategy logic, cadence, asset universes, parameters, and research/backtest notes live in `UsEquityStrategies`. +IBKR runtime for shared `us_equity` strategy profiles from `UsEquityStrategies`. Strategy logic, cadence, asset universes, parameters, and research/backtest notes live there. +The runtime carries a structured `RuntimeTarget` / `RUNTIME_TARGET_JSON` for the running service identity. Strategy-owned defaults come from `UsEquityStrategies`; platform variables are only explicit overrides. -Current strategy implementations are sourced from `UsEquityStrategies`. - -Full strategy documentation now lives in [`UsEquityStrategies`](https://github.com/QuantStrategyLab/UsEquityStrategies). This README focuses on IBKR runtime behavior, profile enablement, deployment, and credentials. -This runtime matrix is the authoritative enablement source for IBKR. `UsEquityStrategies` carries strategy-layer logic, cadence, compatibility, and metadata. +Full strategy documentation now lives in [`UsEquityStrategies`](https://github.com/QuantStrategyLab/UsEquityStrategies). This README focuses on IBKR runtime behavior, profile enablement, deployment, and credentials, and this profile matrix remains the authoritative IBKR enablement source. ### Execution boundary @@ -91,6 +89,7 @@ Telegram alerts support English/Chinese execution and heartbeat messages. Strate ### Runtime env vars The selected `ACCOUNT_GROUP` is now the runtime identity. Keep broker-specific identity in the account-group config payload, not in Cloud Run env vars. +For IBKR, keep `paper` as a single account-group entry. If you later add live accounts, split them into separate live groups; do not mix paper and live in one account-group entry. | Variable | Required | Description | |----------|----------|-------------| @@ -131,7 +130,7 @@ For the current first rollout, keep GitHub / Cloud Run focused on service-level ```bash STRATEGY_PROFILE=global_etf_rotation -ACCOUNT_GROUP=default +ACCOUNT_GROUP=paper IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME=ibkr-account-groups GLOBAL_TELEGRAM_CHAT_ID= NOTIFY_LANG=zh @@ -145,7 +144,7 @@ For the snapshot-based stock profiles: ```bash STRATEGY_PROFILE=russell_1000_multi_factor_defensive -ACCOUNT_GROUP=default +ACCOUNT_GROUP=paper IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME=ibkr-account-groups IBKR_FEATURE_SNAPSHOT_PATH=/var/data/r1000_feature_snapshot.csv GLOBAL_TELEGRAM_CHAT_ID= @@ -154,7 +153,7 @@ NOTIFY_LANG=zh ```bash STRATEGY_PROFILE=tech_communication_pullback_enhancement -ACCOUNT_GROUP=default +ACCOUNT_GROUP=paper IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME=ibkr-account-groups IBKR_FEATURE_SNAPSHOT_PATH=/var/data/tech_communication_pullback_enhancement_feature_snapshot_latest.csv IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH=/var/manifests/tech_communication_pullback_enhancement_feature_snapshot_latest.csv.manifest.json @@ -169,7 +168,7 @@ NOTIFY_LANG=zh ```bash STRATEGY_PROFILE=mega_cap_leader_rotation_top50_balanced -ACCOUNT_GROUP=default +ACCOUNT_GROUP=paper IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME=ibkr-account-groups IBKR_FEATURE_SNAPSHOT_PATH=/var/data/mega_cap_leader_rotation_top50_balanced_feature_snapshot_latest.csv IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH=/var/manifests/mega_cap_leader_rotation_top50_balanced_feature_snapshot_latest.csv.manifest.json @@ -184,7 +183,7 @@ Recommended account-group config payload: ```json { "groups": { - "default": { + "paper": { "ib_gateway_instance_name": "interactive-brokers-quant-instance", "ib_gateway_zone": "us-central1-c", "ib_gateway_mode": "paper", @@ -192,21 +191,14 @@ Recommended account-group config payload: "ib_client_id": 1, "service_name": "interactive-brokers-quant-service", "account_ids": ["DU1234567"] - }, - "ira": { - "ib_gateway_instance_name": "interactive-brokers-quant-instance", - "ib_gateway_zone": "us-central1-c", - "ib_gateway_mode": "paper", - "ib_gateway_ip_mode": "internal", - "ib_client_id": 7, - "service_name": "interactive-brokers-quant-ira-service", - "account_ids": ["U1234567"] } } } ``` -See [`docs/examples/ibkr-account-groups.default.json`](docs/examples/ibkr-account-groups.default.json) for a ready-to-edit starter example, and [`docs/ibkr_runtime_rollout.md`](docs/ibkr_runtime_rollout.md) for the exact rollout steps to get `ACCOUNT_GROUP=default` running. +If you later add live, keep it as a separate live group such as `live-main` with `ib_gateway_mode=live` and its own `account_ids`. + +See [`docs/examples/ibkr-account-groups.paper.json`](docs/examples/ibkr-account-groups.paper.json) for a ready-to-edit starter example, and [`docs/ibkr_runtime_rollout.md`](docs/ibkr_runtime_rollout.md) for the exact rollout steps to get `ACCOUNT_GROUP=paper` running. Current behavior is fail-fast: @@ -217,7 +209,7 @@ Current behavior is fail-fast: ### GitHub-managed Cloud Run env sync -If code deployment still uses Google Cloud Trigger, but you want GitHub to be the single source of truth for runtime env vars, this repo now includes `.github/workflows/sync-cloud-run-env.yml`. +If code deployment still uses Google Cloud Trigger, but you want GitHub to be the single source of truth for runtime env vars, this repo now includes `.github/workflows/sync-cloud-run-env.yml`. The workflow now also emits `RUNTIME_TARGET_JSON`, so the control plane carries a structured runtime target alongside the legacy `STRATEGY_PROFILE` selector. Recommended setup: @@ -227,7 +219,7 @@ Recommended setup: - `CLOUD_RUN_SERVICE` - `TELEGRAM_TOKEN_SECRET_NAME` (recommended when Cloud Run already uses Secret Manager for `TELEGRAM_TOKEN`) - `STRATEGY_PROFILE` (set explicitly to one enabled profile, such as `soxl_soxx_trend_income`) - - `ACCOUNT_GROUP` (recommended: `default`) + - `ACCOUNT_GROUP` (recommended: `paper`) - `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` - `GLOBAL_TELEGRAM_CHAT_ID` - `NOTIFY_LANG` @@ -239,7 +231,7 @@ Recommended setup: On every push to `main`, the workflow updates the existing Cloud Run service with the values above and removes legacy env vars that should now live in the account-group config (`IB_CLIENT_ID`, `IB_GATEWAY_INSTANCE_NAME`, `IB_GATEWAY_MODE`) plus the older transport vars (`IB_GATEWAY_HOST`, `IB_GATEWAY_PORT`, `TELEGRAM_CHAT_ID`). If `IB_GATEWAY_ZONE` or `IB_GATEWAY_IP_MODE` are blank in GitHub, the workflow also removes them from Cloud Run to avoid drift. -`STRATEGY_PROFILE` is now resolved from a platform capability matrix plus a rollout allowlist derived from `runtime_enabled` strategy metadata. The current strategy domain is `us_equity`, and the repo keeps the runtime registry thin: `eligible` means the platform can run the strategy in theory, while `enabled` means the current rollout really allows it. `ACCOUNT_GROUP` now selects one account-group config entry, and the service fails fast if that runtime identity is incomplete. +`STRATEGY_PROFILE` is resolved from a platform capability matrix plus a rollout allowlist derived from `runtime_enabled` strategy metadata. The current strategy domain is `us_equity`: `eligible` means the platform can run the strategy in theory, while `enabled` means the current rollout really allows it. `ACCOUNT_GROUP` selects one account-group config entry, and the service fails fast if that runtime identity is incomplete. `RUNTIME_TARGET_JSON` carries the structured runtime identity; strategy defaults continue to come from `UsEquityStrategies`. Important: @@ -261,7 +253,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`. +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 workflow emits `RUNTIME_TARGET_JSON` to describe the structured deployment target. 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`. @@ -279,7 +271,7 @@ gcloud run deploy interactive-brokers-quant-service \ --network default \ --subnet cloudrun-direct-egress \ --vpc-egress private-ranges-only \ - --set-env-vars STRATEGY_PROFILE=global_etf_rotation,ACCOUNT_GROUP=default,IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME=ibkr-account-groups,GLOBAL_TELEGRAM_CHAT_ID=123456789,NOTIFY_LANG=zh + --set-env-vars STRATEGY_PROFILE=global_etf_rotation,ACCOUNT_GROUP=paper,IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME=ibkr-account-groups,GLOBAL_TELEGRAM_CHAT_ID=123456789,NOTIFY_LANG=zh ``` If the service already exists and your CI only updates source/image, you can patch networking separately: @@ -302,9 +294,7 @@ gcloud run services update ibkr-quant \ IBKR runtime 负责把共享的 `us_equity` 策略档位部署到 GCP Cloud Run,并连接 GCE 上的 IB Gateway 执行。策略逻辑、策略频率、标的池、参数和研究/回测说明都放在 `UsEquityStrategies`;这个仓库只维护 IBKR 运行时、账号组、Gateway 连接、下单和通知。 -当前可运行的 `global_etf_rotation`、`russell_1000_multi_factor_defensive`、`tqqq_growth_income`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement` 和 `mega_cap_leader_rotation_top50_balanced` 的策略实现都来自 `UsEquityStrategies`。 - -完整策略说明现在放在 [`UsEquityStrategies`](https://github.com/QuantStrategyLab/UsEquityStrategies)。这个 README 只保留 IBKR 运行时、profile 启用状态、部署和凭据说明。 +完整策略说明放在 [`UsEquityStrategies`](https://github.com/QuantStrategyLab/UsEquityStrategies)。这个 README 只保留 IBKR 运行时、profile 启用状态、部署和凭据说明。 ### 执行边界 @@ -378,7 +368,7 @@ IBKR 账户 ```bash STRATEGY_PROFILE=soxl_soxx_trend_income -ACCOUNT_GROUP=default +ACCOUNT_GROUP=paper IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME=ibkr-account-groups GLOBAL_TELEGRAM_CHAT_ID= NOTIFY_LANG=zh @@ -395,7 +385,7 @@ IB_GATEWAY_IP_MODE=internal ```json { "groups": { - "default": { + "paper": { "ib_gateway_instance_name": "interactive-brokers-quant-instance", "ib_gateway_zone": "us-central1-c", "ib_gateway_mode": "paper", @@ -403,21 +393,12 @@ IB_GATEWAY_IP_MODE=internal "ib_client_id": 1, "service_name": "interactive-brokers-quant-service", "account_ids": ["DU1234567"] - }, - "ira": { - "ib_gateway_instance_name": "interactive-brokers-quant-instance", - "ib_gateway_zone": "us-central1-c", - "ib_gateway_mode": "paper", - "ib_gateway_ip_mode": "internal", - "ib_client_id": 7, - "service_name": "interactive-brokers-quant-ira-service", - "account_ids": ["U1234567"] } } } ``` -仓库里也提供了一个可以直接改的默认样例:[`docs/examples/ibkr-account-groups.default.json`](docs/examples/ibkr-account-groups.default.json)。如果你要按 `ACCOUNT_GROUP=default` 先落地,直接看 [`docs/ibkr_runtime_rollout.md`](docs/ibkr_runtime_rollout.md)。 +仓库里也提供了一个可以直接改的起始样例:[`docs/examples/ibkr-account-groups.paper.json`](docs/examples/ibkr-account-groups.paper.json)。如果你要按 `ACCOUNT_GROUP=paper` 先落地,直接看 [`docs/ibkr_runtime_rollout.md`](docs/ibkr_runtime_rollout.md)。 当前行为改成了 fail-fast: @@ -438,7 +419,7 @@ IB_GATEWAY_IP_MODE=internal - `CLOUD_RUN_SERVICE` - `TELEGRAM_TOKEN_SECRET_NAME`(如果 Cloud Run 上的 `TELEGRAM_TOKEN` 已经改成 Secret Manager,建议配置) - `STRATEGY_PROFILE`(显式设置为任一已启用 profile,例如 `soxl_soxx_trend_income`) - - `ACCOUNT_GROUP`(建议设为 `default`) + - `ACCOUNT_GROUP`(建议设为 `paper`) - `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` - `GLOBAL_TELEGRAM_CHAT_ID` - `NOTIFY_LANG` @@ -450,7 +431,7 @@ IB_GATEWAY_IP_MODE=internal 每次 push 到 `main` 时,这个 workflow 会把上面这些值同步到现有 Cloud Run 服务里,并清掉已经转移到账号组配置里的旧 env(`IB_CLIENT_ID`、`IB_GATEWAY_INSTANCE_NAME`、`IB_GATEWAY_MODE`)以及更早的传输层 env(`IB_GATEWAY_HOST`、`IB_GATEWAY_PORT`、`TELEGRAM_CHAT_ID`)。如果 GitHub 里没有配置 `IB_GATEWAY_ZONE` 或 `IB_GATEWAY_IP_MODE`,workflow 也会把 Cloud Run 上这两个旧值一起删除,避免双配置源漂移。 -`STRATEGY_PROFILE` 现在由平台能力矩阵和从 `runtime_enabled` 策略元数据派生的 rollout allowlist 一起决定。当前策略域仍是 `us_equity`:`eligible` 表示平台理论上能跑,`enabled` 表示当前 rollout 真正放开。`ACCOUNT_GROUP` 是严格必填项,并会选中一份账号组配置。运行身份不完整时,服务会直接失败,不再静默回退。 +`STRATEGY_PROFILE` 由平台能力矩阵和从 `runtime_enabled` 策略元数据派生的 rollout allowlist 一起决定。当前策略域仍是 `us_equity`:`eligible` 表示平台理论上能跑,`enabled` 表示当前 rollout 真正放开。`ACCOUNT_GROUP` 是严格必填项,并会选中一份账号组配置。运行身份不完整时,服务会直接失败,不再静默回退。 注意: @@ -490,7 +471,7 @@ gcloud run deploy interactive-brokers-quant-service \ --network default \ --subnet cloudrun-direct-egress \ --vpc-egress private-ranges-only \ - --set-env-vars STRATEGY_PROFILE=global_etf_rotation,ACCOUNT_GROUP=default,IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME=ibkr-account-groups,GLOBAL_TELEGRAM_CHAT_ID=123456789,NOTIFY_LANG=zh + --set-env-vars STRATEGY_PROFILE=global_etf_rotation,ACCOUNT_GROUP=paper,IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME=ibkr-account-groups,GLOBAL_TELEGRAM_CHAT_ID=123456789,NOTIFY_LANG=zh ``` 如果服务已经存在,而你们的 CI 只是更新代码/镜像,可以单独补一次网络配置: diff --git a/application/runtime_composer.py b/application/runtime_composer.py index ae825cb..81eb52a 100644 --- a/application/runtime_composer.py +++ b/application/runtime_composer.py @@ -3,13 +3,16 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass -from typing import Any +from dataclasses import dataclass, field +from typing import Any, Mapping from application.runtime_dependencies import IBKRRebalanceConfig, IBKRRebalanceRuntime from application.runtime_notification_adapters import build_runtime_notification_adapters from application.runtime_reporting_adapters import build_runtime_reporting_adapters +from quant_platform_kit.common.runtime_assembly import build_runtime_assembly +from quant_platform_kit.common.runtime_target import build_runtime_context_fields from quant_platform_kit.common.port_adapters import CallablePortfolioPort +from quant_platform_kit.common.runtime_target import RuntimeTarget @dataclass(frozen=True) @@ -58,6 +61,8 @@ class IBKRRuntimeComposer: printer: Callable[..., Any] = print notification_builder: Callable[..., Any] = build_runtime_notification_adapters reporting_builder: Callable[..., Any] = build_runtime_reporting_adapters + runtime_target: RuntimeTarget | None = None + extra_reporting_fields: Mapping[str, Any] = field(default_factory=dict) def build_notification_adapters(self): return self.notification_builder( @@ -66,23 +71,30 @@ def build_notification_adapters(self): ) def build_reporting_adapters(self): - return self.reporting_builder( + runtime_assembly = build_runtime_assembly( platform="interactive_brokers", deploy_target="cloud_run", service_name=self.service_name, strategy_profile=self.strategy_profile, - strategy_domain=self.strategy_domain, + runtime_target=self.runtime_target, account_scope=self.account_group, account_group=self.account_group, project_id=self.project_id, instance_name=self.instance_name, - extra_context_fields={ - "account_ids": list(self.account_ids), - "strategy_target_mode": self.strategy_target_mode, - "strategy_artifact_dir": self.strategy_artifact_dir, - "strategy_display_name": self.strategy_display_name, - "strategy_display_name_localized": self.strategy_display_name_localized, - }, + extra_context_fields=build_runtime_context_fields( + { + "account_ids": list(self.account_ids), + "strategy_target_mode": self.strategy_target_mode, + "strategy_artifact_dir": self.strategy_artifact_dir, + "strategy_display_name": self.strategy_display_name, + "strategy_display_name_localized": self.strategy_display_name_localized, + **dict(self.extra_reporting_fields or {}), + }, + ), + ) + return self.reporting_builder( + runtime_assembly=runtime_assembly, + strategy_domain=self.strategy_domain, managed_symbols=self.managed_symbols, signal_source=self.signal_source, status_icon=self.status_icon, @@ -177,6 +189,8 @@ def build_runtime_composer( trace_extractor: Callable[..., str | None], env_reader: Callable[[str, str], str | None], printer: Callable[..., Any] = print, + extra_reporting_fields: Mapping[str, Any] | None = None, + runtime_target: RuntimeTarget | None = None, ) -> IBKRRuntimeComposer: return IBKRRuntimeComposer( service_name=str(service_name or ""), @@ -221,4 +235,6 @@ def build_runtime_composer( trace_extractor=trace_extractor, env_reader=env_reader, printer=printer, + runtime_target=runtime_target, + extra_reporting_fields=dict(extra_reporting_fields or {}), ) diff --git a/application/runtime_reporting_adapters.py b/application/runtime_reporting_adapters.py index d42bc3a..b8f33e1 100644 --- a/application/runtime_reporting_adapters.py +++ b/application/runtime_reporting_adapters.py @@ -7,7 +7,9 @@ from datetime import datetime, timezone from typing import Any +from quant_platform_kit.common.runtime_assembly import RuntimeAssembly from quant_platform_kit.strategy_contracts import build_execution_timing_metadata +from quant_platform_kit.common.runtime_target import RuntimeTarget from runtime_logging import RuntimeLogContext @@ -17,15 +19,9 @@ def _utcnow() -> datetime: @dataclass(frozen=True) class IBKRRuntimeReportingAdapters: - platform: str - deploy_target: str - service_name: str - strategy_profile: str + runtime_assembly: RuntimeAssembly strategy_domain: str | None - account_scope: str | None - account_group: str | None - project_id: str | None - instance_name: str | None + runtime_target: RuntimeTarget | None = None extra_context_fields: Mapping[str, Any] = field(default_factory=dict) managed_symbols: tuple[str, ...] = () signal_source: str = "" @@ -70,19 +66,12 @@ def __post_init__(self) -> None: raise ValueError(f"Missing reporting adapter dependencies: {', '.join(missing)}") def build_log_context(self, *, trace_header: str | None = None) -> RuntimeLogContext: - return RuntimeLogContext( - platform=self.platform, - deploy_target=self.deploy_target, - service_name=self.service_name, - strategy_profile=self.strategy_profile, - account_scope=self.account_scope, - account_group=self.account_group, - project_id=self.project_id, - instance_name=self.instance_name, - extra_fields=dict(self.extra_context_fields), - ).with_run( - self.run_id_builder(), - trace=self.trace_extractor(self.project_id, trace_header), + return self.runtime_assembly.with_overrides( + runtime_target=self.runtime_target, + extra_context_fields=self.extra_context_fields, + ).build_log_context( + run_id=self.run_id_builder(), + trace=self.trace_extractor(self.runtime_assembly.project_id, trace_header), ) def build_report(self, log_context: RuntimeLogContext) -> dict[str, Any]: @@ -92,17 +81,15 @@ def build_report(self, log_context: RuntimeLogContext) -> dict[str, Any]: signal_effective_after_trading_days=self.signal_effective_after_trading_days, ) return self.report_builder( - platform=log_context.platform, - deploy_target=log_context.deploy_target, - service_name=log_context.service_name, - strategy_profile=self.strategy_profile, - strategy_domain=self.strategy_domain, - account_scope=log_context.account_scope, - account_group=log_context.account_group, - run_id=log_context.run_id, - run_source="cloud_run", - dry_run=self.dry_run, - started_at=started_at, + **self.runtime_assembly.with_overrides( + runtime_target=self.runtime_target, + extra_context_fields=self.extra_context_fields, + ).build_report_base_kwargs( + run_id=log_context.run_id, + dry_run=self.dry_run, + started_at=started_at, + strategy_domain=self.strategy_domain, + ), summary={ "account_ids": list(self.extra_context_fields.get("account_ids") or ()), "managed_symbols": list(self.managed_symbols), @@ -147,7 +134,7 @@ def persist_execution_report(self, report: dict[str, Any]) -> str | None: report, base_dir=self.report_base_dir, gcs_prefix_uri=self.report_gcs_prefix_uri, - gcp_project_id=self.project_id, + gcp_project_id=self.runtime_assembly.project_id, ) if isinstance(persisted, str): return persisted @@ -156,15 +143,9 @@ def persist_execution_report(self, report: dict[str, Any]) -> str | None: def build_runtime_reporting_adapters( *, - platform: str, - deploy_target: str, - service_name: str, - strategy_profile: str, + runtime_assembly: RuntimeAssembly, strategy_domain: str | None, - account_scope: str | None, - account_group: str | None, - project_id: str | None, - instance_name: str | None, + runtime_target: RuntimeTarget | None = None, extra_context_fields: Mapping[str, Any] | None = None, managed_symbols: tuple[str, ...], signal_source: str, @@ -196,15 +177,9 @@ def build_runtime_reporting_adapters( clock: Callable[[], datetime] = _utcnow, ) -> IBKRRuntimeReportingAdapters: return IBKRRuntimeReportingAdapters( - platform=platform, - deploy_target=deploy_target, - service_name=service_name, - strategy_profile=strategy_profile, + runtime_assembly=runtime_assembly, strategy_domain=strategy_domain, - account_scope=account_scope, - account_group=account_group, - project_id=project_id, - instance_name=instance_name, + runtime_target=runtime_target, extra_context_fields=dict(extra_context_fields or {}), managed_symbols=tuple(managed_symbols), signal_source=str(signal_source or ""), diff --git a/docs/examples/ibkr-account-groups.default.json b/docs/examples/ibkr-account-groups.paper.json similarity index 95% rename from docs/examples/ibkr-account-groups.default.json rename to docs/examples/ibkr-account-groups.paper.json index c4c5498..ebb4a4e 100644 --- a/docs/examples/ibkr-account-groups.default.json +++ b/docs/examples/ibkr-account-groups.paper.json @@ -1,6 +1,6 @@ { "groups": { - "default": { + "paper": { "ib_gateway_instance_name": "interactive-brokers-quant-instance", "ib_gateway_zone": "us-central1-c", "ib_gateway_mode": "paper", diff --git a/docs/ibkr_runtime_rollout.md b/docs/ibkr_runtime_rollout.md index 24f2b71..6d9789f 100644 --- a/docs/ibkr_runtime_rollout.md +++ b/docs/ibkr_runtime_rollout.md @@ -1,6 +1,7 @@ -# InteractiveBrokersPlatform 配置落地:先跑通 `ACCOUNT_GROUP=default` +# InteractiveBrokersPlatform 配置落地:先跑通 `ACCOUNT_GROUP=paper` -这份文档只管当前这一步:**先把 `interactive-brokers-quant-service` 这一个 Cloud Run 服务,用 `ACCOUNT_GROUP=default` 跑通。** +这份文档只管当前这一步:**先把 `interactive-brokers-quant-service` 这一个 Cloud Run 服务,用 `ACCOUNT_GROUP=paper` 跑通。** +策略默认值仍然来自 `UsEquityStrategies`,这里仅描述 IBKR 平台壳子、账号组、Secret 和运行目标的落地方式。 不在这一步里做的事: @@ -15,6 +16,8 @@ ### Cloud Run / GitHub 管服务级变量 +这些变量只描述 IBKR 运行身份和平台覆盖项;策略默认值、策略参数和策略频率继续由 `UsEquityStrategies` 提供。 + - `STRATEGY_PROFILE` - `ACCOUNT_GROUP` - `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` @@ -22,6 +25,7 @@ - `NOTIFY_LANG` - `TELEGRAM_TOKEN_SECRET_NAME`(推荐) - `TELEGRAM_TOKEN`(fallback) +- `RUNTIME_TARGET_JSON`(由同步 workflow 自动生成,表示结构化运行目标;其中的 `strategy_profile` 只是策略选择器) 可选过渡变量: @@ -47,13 +51,13 @@ 仓库里已经放了一个可直接改的样例: -- `docs/examples/ibkr-account-groups.default.json` +- `docs/examples/ibkr-account-groups.paper.json` 先复制一份出来改成你的真实值: ```bash cd /Users/lisiyi/Projects/InteractiveBrokersPlatform -cp docs/examples/ibkr-account-groups.default.json /tmp/ibkr-account-groups.json +cp docs/examples/ibkr-account-groups.paper.json /tmp/ibkr-account-groups.json ``` 最小推荐结构: @@ -61,7 +65,7 @@ cp docs/examples/ibkr-account-groups.default.json /tmp/ibkr-account-groups.json ```json { "groups": { - "default": { + "paper": { "ib_gateway_instance_name": "interactive-brokers-quant-instance", "ib_gateway_zone": "us-central1-c", "ib_gateway_mode": "paper", @@ -76,7 +80,7 @@ cp docs/examples/ibkr-account-groups.default.json /tmp/ibkr-account-groups.json 说明: -- `default`:就是这次先跑通的账号组名,要和 Cloud Run env 里的 `ACCOUNT_GROUP=default` 一致。 +- `paper`:就是这次先跑通的账号组名,要和 Cloud Run env 里的 `ACCOUNT_GROUP=paper` 一致。 - `ib_gateway_instance_name`:GCE 上 IB Gateway 实例名。 - `ib_gateway_zone`:建议现在就配上。当前推荐是按实例名解析内网 IP,这样 Cloud Run 不用手填固定私网 IP。 - `ib_gateway_mode`:`paper` 或 `live`。 @@ -101,7 +105,7 @@ gcloud secrets versions add ibkr-account-groups \ --data-file=/tmp/ibkr-account-groups.json ``` -上传后先看一眼 `default` 组: +上传后先看一眼 `paper` 组: ```bash gcloud secrets versions access latest \ @@ -201,10 +205,11 @@ gcloud storage buckets add-iam-policy-binding "gs://run-sources-${PROJECT_ID}-${ - `CLOUD_RUN_SERVICE` - `TELEGRAM_TOKEN_SECRET_NAME=interactive-brokers-telegram-token` - `STRATEGY_PROFILE=global_etf_rotation` -- `ACCOUNT_GROUP=default` +- `ACCOUNT_GROUP=paper` - `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME=ibkr-account-groups` - `GLOBAL_TELEGRAM_CHAT_ID` - `NOTIFY_LANG` +- `RUNTIME_TARGET_JSON` 不需要手工填,workflow 会自动算出并同步 可选过渡项: @@ -213,7 +218,7 @@ gcloud storage buckets add-iam-policy-binding "gs://run-sources-${PROJECT_ID}-${ 说明: -- 如果 `ibkr-account-groups` 里的 `default` 已经写了 `ib_gateway_zone` / `ib_gateway_ip_mode`,这两个 GitHub vars 可以留空。 +- 如果 `ibkr-account-groups` 里的 `paper` 已经写了 `ib_gateway_zone` / `ib_gateway_ip_mode`,这两个 GitHub vars 可以留空。 - 现在 workflow 会在它们留空时,顺手把 Cloud Run 上旧的 `IB_GATEWAY_ZONE` / `IB_GATEWAY_IP_MODE` env 清掉,避免双配置源漂移。 ### Repository Secrets @@ -225,7 +230,7 @@ gcloud storage buckets add-iam-policy-binding "gs://run-sources-${PROJECT_ID}-${ - GitHub 现在通过 OIDC + Workload Identity Federation 登录 Google Cloud,这个 workflow 不再需要 `GCP_SA_KEY`。 - 如果你现在只用这个 workflow 做“同步已有 Cloud Run service 的 env”,那这个 GitHub Actions 账号只需要能更新目标 Cloud Run service,不需要现在就补 Cloud Build / Artifact Registry 那一套权限。 -## 5. 先把 `ACCOUNT_GROUP=default` 跑通的顺序 +## 5. 先把 `ACCOUNT_GROUP=paper` 跑通的顺序 建议按这个顺序来: @@ -238,7 +243,7 @@ gcloud storage buckets add-iam-policy-binding "gs://run-sources-${PROJECT_ID}-${ - `ib_client_id` 2. **更新 Secret Manager** - - 把 `docs/examples/ibkr-account-groups.default.json` 改成真实值 + - 把 `docs/examples/ibkr-account-groups.paper.json` 改成真实值 - 上传成 `ibkr-account-groups` 最新版本 3. **给 runtime service account 授权** @@ -246,7 +251,7 @@ gcloud storage buckets add-iam-policy-binding "gs://run-sources-${PROJECT_ID}-${ - `roles/compute.viewer` 4. **配 GitHub vars / secrets** - - `ACCOUNT_GROUP=default` + - `ACCOUNT_GROUP=paper` - `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME=ibkr-account-groups` - 其他服务级变量按上面补齐 @@ -276,17 +281,18 @@ gcloud run services logs read interactive-brokers-quant-service \ - 缺 `STRATEGY_PROFILE` - 缺 `ACCOUNT_GROUP` - 缺 `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` -- secret 里没有 `default` -- `default` 组缺 `ib_gateway_instance_name` / `ib_gateway_mode` / `ib_client_id` +- 缺 `RUNTIME_TARGET_JSON`(通常说明 workflow 没跑到或输出没接上) +- secret 里没有 `paper` +- `paper` 组缺 `ib_gateway_instance_name` / `ib_gateway_mode` / `ib_client_id` ## 6. 哪些已经完成,哪些后续再做 ### 当前已经完成 -- `ibkr-account-groups` secret 建好,并至少把 `default` 组跑通 +- `ibkr-account-groups` secret 建好,并至少把 `paper` 组跑通 - Cloud Run runtime service account 权限补齐 - GitHub env sync 改成只管服务级变量 -- 继续保持现有 Cloud Run service、现有 trigger、现有 `ACCOUNT_GROUP=default` 运行链路稳定 +- 继续保持现有 Cloud Run service、现有 trigger、现有 `ACCOUNT_GROUP=paper` 运行链路稳定 ### 还可以后做 diff --git a/main.py b/main.py index 9d309dc..51104c3 100644 --- a/main.py +++ b/main.py @@ -25,6 +25,7 @@ from decision_mapper import map_strategy_decision from entrypoints.cloud_run import is_market_open_today from notifications.telegram import build_strategy_display_name, build_translator, send_telegram_message +from quant_platform_kit.common.runtime_assembly import build_runtime_assembly from quant_platform_kit.common.runtime_reports import ( append_runtime_report_error, build_runtime_report_base, @@ -46,7 +47,7 @@ get_market_prices as application_get_market_prices, ) from application.paper_liquidation_service import execute_paper_liquidation -from runtime_logging import RuntimeLogContext, build_run_id, emit_runtime_log, extract_cloud_trace +from runtime_logging import build_run_id, emit_runtime_log, extract_cloud_trace from runtime_config_support import load_platform_runtime_settings, resolve_ib_gateway_ip_mode from strategy_runtime import load_strategy_runtime @@ -244,16 +245,17 @@ def t(key, **kwargs): fallback_name=STRATEGY_DISPLAY_NAME, ) -RUNTIME_LOG_CONTEXT = RuntimeLogContext( +RUNTIME_LOG_CONTEXT = build_runtime_assembly( platform="interactive_brokers", deploy_target="cloud_run", service_name=SERVICE_NAME or os.getenv("K_SERVICE", "interactive-brokers-platform"), strategy_profile=STRATEGY_PROFILE, + runtime_target=RUNTIME_SETTINGS.runtime_target, account_scope=ACCOUNT_GROUP, account_group=ACCOUNT_GROUP, project_id=PROJECT_ID, instance_name=RUNTIME_SETTINGS.ib_gateway_instance_name, - extra_fields={ + extra_context_fields={ "account_ids": list(ACCOUNT_IDS), "strategy_target_mode": RUNTIME_SETTINGS.strategy_target_mode, "strategy_artifact_dir": RUNTIME_SETTINGS.strategy_artifact_dir, @@ -262,7 +264,7 @@ def t(key, **kwargs): "ib_connect_attempts": IB_CONNECT_ATTEMPTS, "ib_client_id_retry_offset": IB_CLIENT_ID_RETRY_OFFSET, }, -) +).build_log_context(run_id="") def resolve_reporting_managed_symbols() -> tuple[str, ...]: @@ -369,6 +371,7 @@ def build_composer(): trace_extractor=extract_cloud_trace, env_reader=os.getenv, printer=print, + runtime_target=RUNTIME_SETTINGS.runtime_target, ) diff --git a/notifications/telegram.py b/notifications/telegram.py index a122c9a..3106ddc 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -84,6 +84,7 @@ "small_account_warning_note": "小账户提示:净值 {portfolio_equity} 低于建议 {min_recommended_equity};{reason}", "small_account_warning_reason_integer_shares_min_position_value_may_prevent_backtest_replication": "整数股和最小仓位限制可能导致实盘无法完全复现回测", "strategy_name_global_etf_rotation": "全球 ETF 轮动", + "strategy_name_global_etf_confidence_vol_gate": "全球 ETF 置信波动门控", "strategy_name_russell_1000_multi_factor_defensive": "罗素1000多因子", "strategy_name_tech_communication_pullback_enhancement": "科技通信回调增强", "strategy_name_qqq_tech_enhancement": "科技通信回调增强", @@ -171,6 +172,7 @@ "small_account_warning_note": "small account warning: portfolio equity {portfolio_equity} is below recommended {min_recommended_equity}; {reason}", "small_account_warning_reason_integer_shares_min_position_value_may_prevent_backtest_replication": "integer-share minimum position sizing may prevent backtest replication", "strategy_name_global_etf_rotation": "Global ETF Rotation", + "strategy_name_global_etf_confidence_vol_gate": "Global ETF Confidence Vol Gate", "strategy_name_russell_1000_multi_factor_defensive": "Russell 1000 Multi-Factor", "strategy_name_tech_communication_pullback_enhancement": "Tech/Communication Pullback Enhancement", "strategy_name_qqq_tech_enhancement": "Tech/Communication Pullback Enhancement", diff --git a/requirements.txt b/requirements.txt index 282ad9c..683a6cc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ flask gunicorn -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@3368b261b6ab2210ecbfa79d0f93c5d0cd85c37d -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@2b034808e97401a63e9756c86692d28d718390e6 +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@573fc9e9917cf1f2c1acda9232c5a23a8a05d797 +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@53911cbe32f6932e759522e54aa38ca5350aa44e pandas numpy requests diff --git a/research/README.md b/research/README.md index f453189..2538dca 100644 --- a/research/README.md +++ b/research/README.md @@ -11,9 +11,5 @@ this repository so they are not reused as current IBKR performance references. Current retained IBKR-local research: none. -Brokerless signal research for COIN/CONL/CONI and MAGS7 grouped tool choice now -lives in `../PaperSignalPlatform/research/`, beside the paper-signal notifier -runtime that consumes it. - Use `UsEquitySnapshotPipelines` outputs when reviewing snapshot-backed live strategy performance. diff --git a/runtime_config_support.py b/runtime_config_support.py index 0cdd112..912037d 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -13,6 +13,10 @@ resolve_quantity_step_env, resolve_strategy_runtime_path_settings, ) +from quant_platform_kit.common.runtime_target import ( + RuntimeTarget, + resolve_runtime_target_from_env, +) from strategy_registry import ( IBKR_PLATFORM, resolve_strategy_definition, @@ -62,6 +66,7 @@ class PlatformRuntimeSettings: tg_token: str | None = None tg_chat_id: str | None = None notify_lang: str = "en" + runtime_target: RuntimeTarget | None = None def load_platform_runtime_settings( @@ -71,8 +76,17 @@ def load_platform_runtime_settings( secret_client_factory: Callable[[], Any] | None = None, ) -> PlatformRuntimeSettings: project_id = project_id_resolver() + account_group = resolve_account_group(os.getenv("ACCOUNT_GROUP")) + group_config = load_account_group_config( + project_id=project_id, + account_group=account_group, + raw_json=os.getenv("IB_ACCOUNT_GROUP_CONFIG_JSON"), + secret_name=os.getenv("IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME"), + secret_client_factory=secret_client_factory, + ) + runtime_target = resolve_runtime_target_from_env(env=os.environ, expected_platform_id=IBKR_PLATFORM) strategy_definition = resolve_strategy_definition( - os.getenv("STRATEGY_PROFILE"), + runtime_target.strategy_profile, platform_id=IBKR_PLATFORM, ) strategy_metadata = resolve_strategy_metadata( @@ -88,14 +102,6 @@ def load_platform_runtime_settings( repo_root=Path(__file__).resolve().parent, include_reconciliation_output=True, ) - account_group = resolve_account_group(os.getenv("ACCOUNT_GROUP")) - group_config = load_account_group_config( - project_id=project_id, - account_group=account_group, - raw_json=os.getenv("IB_ACCOUNT_GROUP_CONFIG_JSON"), - secret_name=os.getenv("IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME"), - secret_client_factory=secret_client_factory, - ) instance_name = require_group_string( group_config.ib_gateway_instance_name, @@ -157,6 +163,7 @@ def load_platform_runtime_settings( tg_token=os.getenv("TELEGRAM_TOKEN"), tg_chat_id=os.getenv("GLOBAL_TELEGRAM_CHAT_ID"), notify_lang=os.getenv("NOTIFY_LANG", "en"), + runtime_target=runtime_target, ) diff --git a/runtime_logging.py b/runtime_logging.py index dcf871f..ccd7005 100644 --- a/runtime_logging.py +++ b/runtime_logging.py @@ -1,153 +1,15 @@ -from __future__ import annotations - -import json -from dataclasses import dataclass, field, replace -from datetime import datetime, timezone -from typing import Any, Callable, Mapping - - -LogPrinter = Callable[..., Any] - - -def build_run_id(now: datetime | None = None) -> str: - current = now.astimezone(timezone.utc) if now is not None else datetime.now(timezone.utc) - return current.strftime("%Y%m%dT%H%M%SZ") - - -def extract_cloud_trace(project_id: str | None, header_value: str | None) -> str | None: - if not project_id or not header_value: - return None - trace_id = str(header_value).split("/", 1)[0].strip() - if not trace_id: - return None - return f"projects/{project_id}/traces/{trace_id}" - - -@dataclass(frozen=True) -class RuntimeLogContext: - platform: str - deploy_target: str - service_name: str - strategy_profile: str - run_id: str = "" - account_scope: str | None = None - account_group: str | None = None - account_region: str | None = None - project_id: str | None = None - instance_name: str | None = None - trace: str | None = None - extra_fields: Mapping[str, Any] = field(default_factory=dict) - - def __post_init__(self) -> None: - for field_name in ("platform", "deploy_target", "service_name", "strategy_profile"): - if not str(getattr(self, field_name, "") or "").strip(): - raise ValueError(f"{field_name} must not be empty") - - def with_run( - self, - run_id: str | None = None, - *, - trace: str | None = None, - extra_fields: Mapping[str, Any] | None = None, - ) -> "RuntimeLogContext": - merged_extra = dict(self.extra_fields) - if extra_fields: - merged_extra.update(dict(extra_fields)) - return replace( - self, - run_id=str(run_id or self.run_id or ""), - trace=self.trace if trace is None else trace, - extra_fields=merged_extra, - ) - - -def emit_runtime_log( - context: RuntimeLogContext, - event: str, - *, - message: str | None = None, - severity: str = "INFO", - printer: LogPrinter = print, - now: datetime | None = None, - **fields: Any, -) -> dict[str, Any]: - payload: dict[str, Any] = { - "timestamp": _format_timestamp(now), - "severity": str(severity or "INFO").upper(), - "event": str(event), - "message": str(message or event), - "platform": context.platform, - "deploy_target": context.deploy_target, - "service_name": context.service_name, - "strategy_profile": context.strategy_profile, - "run_id": context.run_id or None, - "account_scope": context.account_scope, - "account_group": context.account_group, - "account_region": context.account_region, - "project_id": context.project_id, - "instance_name": context.instance_name, - } - payload.update(_normalize_mapping(context.extra_fields)) - payload.update(_normalize_mapping(fields)) - if context.trace: - payload["logging.googleapis.com/trace"] = context.trace - - cleaned_payload = _drop_empty(payload) - encoded = json.dumps(cleaned_payload, ensure_ascii=False, sort_keys=True, default=_json_default) - _write_log_line(printer, encoded) - return cleaned_payload - - - -def _format_timestamp(now: datetime | None) -> str: - current = now.astimezone(timezone.utc) if now is not None else datetime.now(timezone.utc) - return current.isoformat().replace("+00:00", "Z") - - - -def _normalize_mapping(mapping: Mapping[str, Any] | None) -> dict[str, Any]: - if not mapping: - return {} - return {str(key): _normalize_value(value) for key, value in mapping.items()} - - - -def _normalize_value(value: Any) -> Any: - if isinstance(value, datetime): - return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") - if isinstance(value, Mapping): - return _drop_empty({str(key): _normalize_value(item) for key, item in value.items()}) - if isinstance(value, tuple): - return [_normalize_value(item) for item in value] - if isinstance(value, list): - return [_normalize_value(item) for item in value] - return value - - - -def _drop_empty(payload: Mapping[str, Any]) -> dict[str, Any]: - cleaned: dict[str, Any] = {} - for key, value in payload.items(): - if value is None: - continue - if isinstance(value, str) and not value.strip(): - continue - if isinstance(value, (list, tuple, dict)) and len(value) == 0: - continue - cleaned[str(key)] = value - return cleaned - - - -def _json_default(value: Any) -> Any: - if isinstance(value, datetime): - return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") - return str(value) - - - -def _write_log_line(printer: LogPrinter, line: str) -> None: - try: - printer(line, flush=True) - except TypeError: - printer(line) +from quant_platform_kit.common.runtime_logging import ( + LogPrinter, + RuntimeLogContext, + build_run_id, + emit_runtime_log, + extract_cloud_trace, +) + +__all__ = [ + "LogPrinter", + "RuntimeLogContext", + "build_run_id", + "emit_runtime_log", + "extract_cloud_trace", +] diff --git a/scripts/print_strategy_switch_env_plan.py b/scripts/print_strategy_switch_env_plan.py index 9d33321..e266134 100644 --- a/scripts/print_strategy_switch_env_plan.py +++ b/scripts/print_strategy_switch_env_plan.py @@ -15,6 +15,7 @@ if candidate_str not in sys.path: sys.path.insert(0, candidate_str) +from quant_platform_kit.common.runtime_target import build_runtime_target # noqa: E402 from quant_platform_kit.common.strategies import derive_strategy_artifact_paths # noqa: E402 from strategy_registry import ( # noqa: E402 IBKR_PLATFORM, @@ -50,8 +51,18 @@ def build_switch_plan(profile: str) -> dict[str, object]: reconciliation_output_policy = str( runtime_requirements.get("reconciliation_output_policy") or "none" ) + runtime_target = build_runtime_target( + platform_id=IBKR_PLATFORM, + strategy_profile=definition.profile, + dry_run_only=False, + deployment_selector=None, + account_scope=None, + service_name="interactive-brokers-quant-service", + ) - set_env: dict[str, str] = {"STRATEGY_PROFILE": definition.profile} + set_env: dict[str, str] = { + "RUNTIME_TARGET_JSON": json.dumps(runtime_target.to_dict(), separators=(",", ":")) + } keep_env = [ "ACCOUNT_GROUP", "IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME", @@ -113,6 +124,7 @@ def build_switch_plan(profile: str) -> dict[str, object]: **runtime_requirements, "required_inputs": sorted(definition.required_inputs), "target_mode": definition.target_mode, + "runtime_target": runtime_target.to_dict(), "set_env": set_env, "keep_env": keep_env, "optional_env": sorted(optional_env), @@ -132,6 +144,7 @@ def _print_plan(plan: dict[str, object]) -> None: print(f"requires_snapshot_artifacts: {plan['requires_snapshot_artifacts']}") print(f"requires_strategy_config_path: {plan['requires_strategy_config_path']}") print(f"target_mode: {plan['target_mode']}") + print(f"runtime_target: {json.dumps(plan['runtime_target'], sort_keys=True)}") print("\nset_env:") for key, value in plan["set_env"].items(): print(f" {key}={value}") diff --git a/tests/conftest.py b/tests/conftest.py index 4acf171..fba3933 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ import importlib +import json import sys import types from pathlib import Path @@ -31,6 +32,23 @@ def load_strategy_module(**env_overrides): "GLOBAL_TELEGRAM_CHAT_ID": None, } defaults.update(env_overrides) + if "RUNTIME_TARGET_JSON" not in defaults: + dry_run_only = str(defaults.get("IBKR_DRY_RUN_ONLY") or "").strip().lower() in { + "1", + "true", + "yes", + "y", + "on", + } + defaults["RUNTIME_TARGET_JSON"] = json.dumps( + { + "platform_id": "ibkr", + "strategy_profile": str(defaults.get("STRATEGY_PROFILE") or "global_etf_rotation"), + "dry_run_only": dry_run_only, + "execution_mode": "paper" if dry_run_only else "live", + }, + separators=(",", ":"), + ) for key, value in defaults.items(): if value is None: diff --git a/tests/test_account_group_examples.py b/tests/test_account_group_examples.py index 878d3b9..b786259 100644 --- a/tests/test_account_group_examples.py +++ b/tests/test_account_group_examples.py @@ -8,11 +8,11 @@ def test_default_account_group_example_is_valid(): Path(__file__).resolve().parents[1] / "docs" / "examples" - / "ibkr-account-groups.default.json" + / "ibkr-account-groups.paper.json" ) configs = parse_account_group_configs(example_path.read_text(encoding="utf-8")) - default_group = configs["default"] + default_group = configs["paper"] assert default_group.ib_gateway_instance_name == "interactive-brokers-quant-instance" assert default_group.ib_gateway_zone == "us-central1-c" diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 8a9b422..97c83f0 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -41,6 +41,8 @@ def test_strategy_display_name_translates_new_live_profiles(): zh_name = build_strategy_display_name(build_translator("zh")) en_name = build_strategy_display_name(build_translator("en")) + assert zh_name("global_etf_confidence_vol_gate") == "全球 ETF 置信波动门控" + assert en_name("global_etf_confidence_vol_gate") == "Global ETF Confidence Vol Gate" assert zh_name("mega_cap_leader_rotation_top50_balanced") == "Mega Cap Top50 平衡龙头轮动" assert en_name("mega_cap_leader_rotation_top50_balanced") == "Mega Cap Leader Rotation Top50 Balanced" diff --git a/tests/test_runtime_composer.py b/tests/test_runtime_composer.py index 82bbf74..2c848b1 100644 --- a/tests/test_runtime_composer.py +++ b/tests/test_runtime_composer.py @@ -7,6 +7,7 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) +from quant_platform_kit.common import build_runtime_target # noqa: E402 from application.runtime_composer import IBKRRuntimeComposer # noqa: E402 @@ -39,6 +40,13 @@ def fake_reporting_builder(**kwargs): status_icon="🐤", safe_haven="BIL", dry_run_only=True, + runtime_target=build_runtime_target( + platform_id="interactive_brokers", + strategy_profile="global_etf_rotation", + dry_run_only=True, + account_scope="default", + service_name="interactive-brokers-platform", + ), strategy_config_source="env", ib_gateway_host_resolver=lambda: "127.0.0.1", ib_gateway_port=4001, @@ -78,9 +86,12 @@ def fake_reporting_builder(**kwargs): assert notification_adapters.notification_port == "notification-port" assert observed["notification_builder"]["send_message"] - assert observed["reporting_builder"]["account_scope"] == "default" + assert observed["reporting_builder"]["runtime_assembly"].account_scope == "default" assert observed["reporting_builder"]["managed_symbols"] == ("AAA", "BIL") assert observed["reporting_builder"]["signal_effective_after_trading_days"] == 1 + assert observed["reporting_builder"]["runtime_assembly"].runtime_target.platform_id == "interactive_brokers" + assert observed["reporting_builder"]["runtime_assembly"].runtime_target.strategy_profile == "global_etf_rotation" + assert observed["reporting_builder"]["runtime_assembly"].runtime_target.execution_mode == "paper" assert runtime.connect_ib() == "ib-connection" assert runtime.portfolio_port_factory("ib").get_portfolio_snapshot() == ("portfolio-snapshot", "ib") assert runtime.compute_signals == "compute-signals" diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 49c3650..fabf810 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -20,7 +20,7 @@ MINIMAL_GROUP_JSON = ( - '{"groups":{"default":{"ib_gateway_instance_name":"ib-gateway",' + '{"groups":{"paper":{"ib_gateway_instance_name":"ib-gateway",' '"ib_gateway_mode":"paper","ib_client_id":1}}}' ) SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "print_strategy_profile_status.py" @@ -28,18 +28,43 @@ SAMPLE_STRATEGY_PROFILE = "global_etf_rotation" +def runtime_target_json( + strategy_profile: str, + *, + dry_run_only: bool = False, + platform_id: str = "ibkr", + deployment_selector: str = "default", + account_selector: list[str] | tuple[str, ...] | None = None, + account_scope: str = "default", + service_name: str | None = None, +) -> str: + payload: dict[str, object] = { + "platform_id": platform_id, + "strategy_profile": strategy_profile, + "dry_run_only": dry_run_only, + "deployment_selector": deployment_selector, + "account_scope": account_scope, + } + if account_selector is not None: + payload["account_selector"] = list(account_selector) + if service_name is not None: + payload["service_name"] = service_name + payload["execution_mode"] = "paper" if dry_run_only else "live" + return json.dumps(payload, separators=(",", ":")) + + def test_load_platform_runtime_settings_requires_strategy_profile(monkeypatch): - monkeypatch.setenv("ACCOUNT_GROUP", "default") + monkeypatch.setenv("ACCOUNT_GROUP", "paper") monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON) - monkeypatch.delenv("STRATEGY_PROFILE", raising=False) + monkeypatch.delenv("RUNTIME_TARGET_JSON", raising=False) - with pytest.raises(EnvironmentError, match="STRATEGY_PROFILE is required"): + with pytest.raises(EnvironmentError, match="RUNTIME_TARGET_JSON is required"): load_platform_runtime_settings(project_id_resolver=lambda: "project-1") def test_load_platform_runtime_settings_requires_account_group(monkeypatch): - monkeypatch.setenv("STRATEGY_PROFILE", SAMPLE_STRATEGY_PROFILE) + monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json(SAMPLE_STRATEGY_PROFILE)) monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON) monkeypatch.delenv("ACCOUNT_GROUP", raising=False) @@ -49,8 +74,8 @@ def test_load_platform_runtime_settings_requires_account_group(monkeypatch): def test_load_platform_runtime_settings_requires_account_group_config_source(monkeypatch): - monkeypatch.setenv("STRATEGY_PROFILE", SAMPLE_STRATEGY_PROFILE) - monkeypatch.setenv("ACCOUNT_GROUP", "default") + monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json(SAMPLE_STRATEGY_PROFILE)) + monkeypatch.setenv("ACCOUNT_GROUP", "paper") monkeypatch.delenv("IB_ACCOUNT_GROUP_CONFIG_JSON", raising=False) monkeypatch.delenv("IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME", raising=False) @@ -63,8 +88,8 @@ def test_load_platform_runtime_settings_requires_account_group_config_source(mon def test_load_platform_runtime_settings_uses_minimal_group_config(monkeypatch): - monkeypatch.setenv("STRATEGY_PROFILE", SAMPLE_STRATEGY_PROFILE) - monkeypatch.setenv("ACCOUNT_GROUP", "default") + monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json(SAMPLE_STRATEGY_PROFILE)) + monkeypatch.setenv("ACCOUNT_GROUP", "paper") monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON) monkeypatch.delenv("IB_GATEWAY_ZONE", raising=False) monkeypatch.delenv("IB_GATEWAY_IP_MODE", raising=False) @@ -83,6 +108,8 @@ def test_load_platform_runtime_settings_uses_minimal_group_config(monkeypatch): assert settings.strategy_profile == SAMPLE_STRATEGY_PROFILE assert settings.strategy_display_name == "Global ETF Rotation" assert settings.strategy_domain == US_EQUITY_DOMAIN + assert settings.runtime_target.platform_id == "ibkr" + assert settings.runtime_target.execution_mode == "live" assert settings.strategy_target_mode == "weight" assert settings.strategy_artifact_root is None assert settings.strategy_artifact_dir is None @@ -94,7 +121,7 @@ def test_load_platform_runtime_settings_uses_minimal_group_config(monkeypatch): assert settings.dry_run_only is False assert settings.quantity_step == 1.0 assert settings.min_order_notional == 50.0 - assert settings.account_group == "default" + assert settings.account_group == "paper" assert settings.service_name is None assert settings.account_ids == () assert settings.notify_lang == "en" @@ -102,9 +129,35 @@ def test_load_platform_runtime_settings_uses_minimal_group_config(monkeypatch): assert settings.tg_chat_id is None +def test_load_platform_runtime_settings_prefers_runtime_target_json(monkeypatch): + monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json(SAMPLE_STRATEGY_PROFILE)) + monkeypatch.setenv("ACCOUNT_GROUP", "paper") + monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON) + monkeypatch.setenv( + "RUNTIME_TARGET_JSON", + runtime_target_json( + "tech_communication_pullback_enhancement", + dry_run_only=True, + account_selector=["U999"], + service_name="interactive-brokers-paper-service", + ), + ) + + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + assert settings.strategy_profile == "tech_communication_pullback_enhancement" + assert settings.runtime_target.strategy_profile == "tech_communication_pullback_enhancement" + assert settings.runtime_target.platform_id == "ibkr" + assert settings.runtime_target.dry_run_only is True + assert settings.runtime_target.execution_mode == "paper" + assert settings.runtime_target.deployment_selector == "default" + assert settings.runtime_target.account_selector == ("U999",) + assert settings.runtime_target.account_scope == "default" + + def test_load_platform_runtime_settings_supports_explicit_group_config_values(monkeypatch): - monkeypatch.setenv("STRATEGY_PROFILE", SAMPLE_STRATEGY_PROFILE) + monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json(SAMPLE_STRATEGY_PROFILE)) monkeypatch.setenv("ACCOUNT_GROUP", "taxable_main") monkeypatch.setenv( "IB_ACCOUNT_GROUP_CONFIG_JSON", @@ -140,8 +193,8 @@ def test_load_platform_runtime_settings_supports_explicit_group_config_values(mo def test_load_platform_runtime_settings_supports_fractional_quantity_step(monkeypatch): - monkeypatch.setenv("STRATEGY_PROFILE", SAMPLE_STRATEGY_PROFILE) - monkeypatch.setenv("ACCOUNT_GROUP", "default") + monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json(SAMPLE_STRATEGY_PROFILE)) + monkeypatch.setenv("ACCOUNT_GROUP", "paper") monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON) monkeypatch.setenv("IBKR_FRACTIONAL_SHARES_ENABLED", "true") monkeypatch.setenv("IBKR_MIN_ORDER_NOTIONAL_USD", "5") @@ -154,8 +207,8 @@ def test_load_platform_runtime_settings_supports_fractional_quantity_step(monkey def test_load_platform_runtime_settings_rejects_unknown_strategy_profile(monkeypatch): - monkeypatch.setenv("STRATEGY_PROFILE", "balanced_income") - monkeypatch.setenv("ACCOUNT_GROUP", "default") + monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json("balanced_income")) + monkeypatch.setenv("ACCOUNT_GROUP", "paper") monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON) with pytest.raises(ValueError, match="Unsupported STRATEGY_PROFILE"): @@ -189,8 +242,11 @@ def test_platform_eligible_profiles_are_exposed_by_capability_matrix(): def test_load_platform_runtime_settings_accepts_tech_communication_pullback_enhancement(monkeypatch): - monkeypatch.setenv("STRATEGY_PROFILE", "tech_communication_pullback_enhancement") - monkeypatch.setenv("ACCOUNT_GROUP", "default") + monkeypatch.setenv( + "RUNTIME_TARGET_JSON", + runtime_target_json("tech_communication_pullback_enhancement"), + ) + monkeypatch.setenv("ACCOUNT_GROUP", "paper") monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON) monkeypatch.setenv("IBKR_FEATURE_SNAPSHOT_PATH", "/tmp/cash-buffer.csv") @@ -210,8 +266,8 @@ def test_load_platform_runtime_settings_accepts_tech_communication_pullback_enha ), ) def test_load_platform_runtime_settings_rejects_removed_research_profiles(monkeypatch, archived_profile): - monkeypatch.setenv("STRATEGY_PROFILE", archived_profile) - monkeypatch.setenv("ACCOUNT_GROUP", "default") + monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json(archived_profile)) + monkeypatch.setenv("ACCOUNT_GROUP", "paper") monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON) monkeypatch.setenv("IBKR_FEATURE_SNAPSHOT_PATH", "/tmp/archive.csv") monkeypatch.setenv("IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH", "/tmp/archive.csv.manifest.json") @@ -221,8 +277,8 @@ def test_load_platform_runtime_settings_rejects_removed_research_profiles(monkey def test_load_platform_runtime_settings_accepts_tqqq_growth_income(monkeypatch): - monkeypatch.setenv("STRATEGY_PROFILE", "tqqq_growth_income") - monkeypatch.setenv("ACCOUNT_GROUP", "default") + monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json("tqqq_growth_income")) + monkeypatch.setenv("ACCOUNT_GROUP", "paper") monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON) settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") @@ -233,8 +289,8 @@ def test_load_platform_runtime_settings_accepts_tqqq_growth_income(monkeypatch): def test_load_platform_runtime_settings_rejects_legacy_qqq_tech_alias(monkeypatch): - monkeypatch.setenv("STRATEGY_PROFILE", "tech_pullback_cash_buffer") - monkeypatch.setenv("ACCOUNT_GROUP", "default") + monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json("tech_pullback_cash_buffer")) + monkeypatch.setenv("ACCOUNT_GROUP", "paper") monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON) monkeypatch.setenv("IBKR_FEATURE_SNAPSHOT_PATH", "/tmp/cash-buffer.csv") @@ -348,11 +404,15 @@ def test_print_strategy_switch_env_plan_for_tqqq_growth_income(): assert plan["canonical_profile"] == "tqqq_growth_income" assert plan["eligible"] is True assert plan["enabled"] is True + assert plan["runtime_target"]["platform_id"] == "ibkr" + assert plan["runtime_target"]["strategy_profile"] == "tqqq_growth_income" + assert plan["runtime_target"]["service_name"] == "interactive-brokers-quant-service" + assert plan["runtime_target"]["execution_mode"] == "live" assert plan["profile_group"] == "direct_runtime_inputs" assert plan["input_mode"] == "benchmark_history+portfolio_snapshot" assert plan["requires_snapshot_artifacts"] is False assert plan["requires_strategy_config_path"] is False - assert plan["set_env"]["STRATEGY_PROFILE"] == "tqqq_growth_income" + assert json.loads(plan["set_env"]["RUNTIME_TARGET_JSON"])["strategy_profile"] == "tqqq_growth_income" assert "ACCOUNT_GROUP" in plan["keep_env"] assert "IBKR_FEATURE_SNAPSHOT_PATH" in plan["remove_if_present"] @@ -434,8 +494,11 @@ def test_print_strategy_switch_env_plan_uses_manifest_contract_policy(): def test_load_platform_runtime_settings_reads_feature_snapshot_path(monkeypatch): - monkeypatch.setenv("STRATEGY_PROFILE", "russell_1000_multi_factor_defensive") - monkeypatch.setenv("ACCOUNT_GROUP", "default") + monkeypatch.setenv( + "RUNTIME_TARGET_JSON", + runtime_target_json("russell_1000_multi_factor_defensive"), + ) + monkeypatch.setenv("ACCOUNT_GROUP", "paper") monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON) monkeypatch.setenv("IBKR_FEATURE_SNAPSHOT_PATH", "/tmp/r1000-latest.csv") @@ -445,8 +508,11 @@ def test_load_platform_runtime_settings_reads_feature_snapshot_path(monkeypatch) def test_load_platform_runtime_settings_reads_tech_pullback_runtime_config(monkeypatch): - monkeypatch.setenv("STRATEGY_PROFILE", "tech_communication_pullback_enhancement") - monkeypatch.setenv("ACCOUNT_GROUP", "default") + monkeypatch.setenv( + "RUNTIME_TARGET_JSON", + runtime_target_json("tech_communication_pullback_enhancement"), + ) + monkeypatch.setenv("ACCOUNT_GROUP", "paper") monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON) monkeypatch.setenv("IBKR_FEATURE_SNAPSHOT_PATH", "/tmp/cash-buffer.csv") monkeypatch.setenv("IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH", "/tmp/cash-buffer.csv.manifest.json") @@ -465,8 +531,11 @@ def test_load_platform_runtime_settings_reads_tech_pullback_runtime_config(monke def test_load_platform_runtime_settings_uses_bundled_tech_pullback_config_when_env_missing(monkeypatch): - monkeypatch.setenv("STRATEGY_PROFILE", "tech_communication_pullback_enhancement") - monkeypatch.setenv("ACCOUNT_GROUP", "default") + monkeypatch.setenv( + "RUNTIME_TARGET_JSON", + runtime_target_json("tech_communication_pullback_enhancement"), + ) + monkeypatch.setenv("ACCOUNT_GROUP", "paper") monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON) monkeypatch.setenv("IBKR_FEATURE_SNAPSHOT_PATH", "/tmp/cash-buffer.csv") monkeypatch.delenv("IBKR_STRATEGY_CONFIG_PATH", raising=False) @@ -480,8 +549,11 @@ def test_load_platform_runtime_settings_uses_bundled_tech_pullback_config_when_e def test_load_platform_runtime_settings_derives_artifact_paths_from_root(monkeypatch, tmp_path): - monkeypatch.setenv("STRATEGY_PROFILE", "tech_communication_pullback_enhancement") - monkeypatch.setenv("ACCOUNT_GROUP", "default") + monkeypatch.setenv( + "RUNTIME_TARGET_JSON", + runtime_target_json("tech_communication_pullback_enhancement"), + ) + monkeypatch.setenv("ACCOUNT_GROUP", "paper") monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON) monkeypatch.setenv("IBKR_STRATEGY_ARTIFACT_ROOT", str(tmp_path)) monkeypatch.delenv("IBKR_FEATURE_SNAPSHOT_PATH", raising=False) @@ -512,20 +584,20 @@ def test_load_platform_runtime_settings_derives_artifact_paths_from_root(monkeyp def test_load_platform_runtime_settings_uses_account_group_secret(monkeypatch): - monkeypatch.setenv("STRATEGY_PROFILE", SAMPLE_STRATEGY_PROFILE) - monkeypatch.setenv("ACCOUNT_GROUP", "ira") + monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json(SAMPLE_STRATEGY_PROFILE)) + monkeypatch.setenv("ACCOUNT_GROUP", "paper") monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME", "ibkr-account-groups") payload = """ { "groups": { - "ira": { - "ib_gateway_instance_name": "ib-gateway-ira", + "paper": { + "ib_gateway_instance_name": "ib-gateway-paper", "ib_gateway_zone": "us-central1-a", "ib_gateway_mode": "live", "ib_gateway_ip_mode": "external", "ib_client_id": 9, - "service_name": "interactive-brokers-quant-ira-service", + "service_name": "interactive-brokers-paper-service", "account_ids": ["U1234567", "U7654321"] } } @@ -546,20 +618,20 @@ def access_secret_version(self, request): secret_client_factory=FakeSecretClient, ) - assert settings.ib_gateway_instance_name == "ib-gateway-ira" + assert settings.ib_gateway_instance_name == "ib-gateway-paper" assert settings.ib_gateway_zone == "us-central1-a" assert settings.ib_gateway_mode == "live" assert settings.ib_gateway_ip_mode == "external" assert settings.ib_client_id == 9 - assert settings.account_group == "ira" - assert settings.service_name == "interactive-brokers-quant-ira-service" + assert settings.account_group == "paper" + assert settings.service_name == "interactive-brokers-paper-service" assert settings.account_ids == ("U1234567", "U7654321") def test_load_platform_runtime_settings_requires_project_for_secret_source(monkeypatch): - monkeypatch.setenv("STRATEGY_PROFILE", SAMPLE_STRATEGY_PROFILE) - monkeypatch.setenv("ACCOUNT_GROUP", "default") + monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json(SAMPLE_STRATEGY_PROFILE)) + monkeypatch.setenv("ACCOUNT_GROUP", "paper") monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME", "ibkr-account-groups") with pytest.raises( @@ -571,7 +643,7 @@ def test_load_platform_runtime_settings_requires_project_for_secret_source(monke def test_load_platform_runtime_settings_rejects_unknown_account_group(monkeypatch): - monkeypatch.setenv("STRATEGY_PROFILE", SAMPLE_STRATEGY_PROFILE) + monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json(SAMPLE_STRATEGY_PROFILE)) monkeypatch.setenv("ACCOUNT_GROUP", "missing") monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON) @@ -581,11 +653,11 @@ def test_load_platform_runtime_settings_rejects_unknown_account_group(monkeypatc def test_load_platform_runtime_settings_requires_key_group_fields(monkeypatch): - monkeypatch.setenv("STRATEGY_PROFILE", SAMPLE_STRATEGY_PROFILE) - monkeypatch.setenv("ACCOUNT_GROUP", "default") + monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json(SAMPLE_STRATEGY_PROFILE)) + monkeypatch.setenv("ACCOUNT_GROUP", "paper") monkeypatch.setenv( "IB_ACCOUNT_GROUP_CONFIG_JSON", - '{"groups":{"default":{"ib_gateway_mode":"paper","ib_client_id":1}}}', + '{"groups":{"paper":{"ib_gateway_mode":"paper","ib_client_id":1}}}', ) with pytest.raises(EnvironmentError, match="requires ib_gateway_instance_name"): @@ -595,18 +667,18 @@ def test_load_platform_runtime_settings_requires_key_group_fields(monkeypatch): def test_parse_account_group_configs_supports_top_level_mapping(): configs = parse_account_group_configs( - '{"default": {"ib_gateway_instance_name":"ib-gateway","ib_gateway_mode":"paper",' + '{"paper": {"ib_gateway_instance_name":"ib-gateway","ib_gateway_mode":"paper",' '"ib_client_id":"4","account_ids":["U1"],"service_name":"svc"}}' ) - assert configs["default"].ib_client_id == 4 - assert configs["default"].account_ids == ("U1",) - assert configs["default"].service_name == "svc" + assert configs["paper"].ib_client_id == 4 + assert configs["paper"].account_ids == ("U1",) + assert configs["paper"].service_name == "svc" def test_load_platform_runtime_settings_rejects_legacy_cash_buffer_profile(monkeypatch): - monkeypatch.setenv("STRATEGY_PROFILE", "cash_buffer_branch_default") - monkeypatch.setenv("ACCOUNT_GROUP", "default") + monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json("cash_buffer_branch_default")) + monkeypatch.setenv("ACCOUNT_GROUP", "paper") monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON) with pytest.raises(ValueError, match="Unsupported STRATEGY_PROFILE"): diff --git a/tests/test_runtime_reporting_adapters.py b/tests/test_runtime_reporting_adapters.py new file mode 100644 index 0000000..090ca35 --- /dev/null +++ b/tests/test_runtime_reporting_adapters.py @@ -0,0 +1,77 @@ +import sys +from datetime import datetime, timezone +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from quant_platform_kit.common import build_runtime_assembly, build_runtime_target # noqa: E402 +from application.runtime_reporting_adapters import build_runtime_reporting_adapters # noqa: E402 + + +def test_runtime_reporting_adapters_start_run_builds_report_with_runtime_target(): + observed = {} + + def fake_report_builder(**kwargs): + observed["report_builder"] = kwargs + return {"run_id": kwargs["run_id"]} + + adapters = build_runtime_reporting_adapters( + runtime_assembly=build_runtime_assembly( + platform="interactive_brokers", + deploy_target="cloud_run", + service_name="interactive-brokers-platform", + strategy_profile="global_etf_rotation", + runtime_target=build_runtime_target( + platform_id="interactive_brokers", + strategy_profile="global_etf_rotation", + dry_run_only=True, + account_scope="default", + service_name="interactive-brokers-platform", + ), + account_scope="default", + account_group="default", + project_id="project-1", + instance_name="ib-gateway", + ), + strategy_domain="us_equity", + extra_context_fields={"account_ids": ["U123456"]}, + managed_symbols=("AAA", "BIL"), + signal_source="market_data", + status_icon="🐤", + safe_haven="BIL", + strategy_display_name="Global ETF Rotation", + strategy_display_name_localized="全球 ETF 轮动", + dry_run=True, + signal_effective_after_trading_days=1, + strategy_config_source="env", + ib_gateway_host_resolver=lambda: "127.0.0.1", + ib_gateway_port=4001, + ib_gateway_mode="live", + ib_gateway_ip_mode="internal", + ib_client_id=1, + ib_connect_timeout_seconds=60, + feature_snapshot_path="/tmp/snapshot.csv", + feature_snapshot_manifest_path="/tmp/snapshot.manifest.json", + strategy_config_path="/tmp/config.json", + reconciliation_output_path="/tmp/reconciliation.json", + report_base_dir="/tmp/reports", + report_gcs_prefix_uri="gs://bucket/reports", + run_id_builder=lambda: "run-001", + event_logger=lambda *_args, **_kwargs: {}, + report_builder=fake_report_builder, + report_persister=lambda *_args, **_kwargs: None, + trace_extractor=lambda *_args, **_kwargs: "trace-001", + printer=lambda *_args, **_kwargs: None, + clock=lambda: datetime(2026, 4, 21, tzinfo=timezone.utc), + ) + + log_context, report = adapters.start_request_run(trace_header="trace-001") + + assert log_context.run_id == "run-001" + assert log_context.runtime_target.platform_id == "interactive_brokers" + assert observed["report_builder"]["runtime_target"].platform_id == "interactive_brokers" + assert observed["report_builder"]["runtime_target"].execution_mode == "paper" + assert report == {"run_id": "run-001"} diff --git a/tests/test_sync_cloud_run_env_workflow.sh b/tests/test_sync_cloud_run_env_workflow.sh index a570e10..89151d1 100644 --- a/tests/test_sync_cloud_run_env_workflow.sh +++ b/tests/test_sync_cloud_run_env_workflow.sh @@ -22,6 +22,7 @@ grep -Fq 'requires_snapshot_manifest_path=' "$workflow_file" grep -Fq 'requires_strategy_config_path=' "$workflow_file" grep -Fq 'config_source_policy=' "$workflow_file" grep -Fq 'reconciliation_output_policy=' "$workflow_file" +grep -Fq 'runtime_target_json=' "$workflow_file" grep -Fq 'Wait for Cloud Run deployment of current commit' "$workflow_file" grep -Fq 'target_sha="${GITHUB_SHA}"' "$workflow_file" grep -Fq "gcloud run services describe \"\${CLOUD_RUN_SERVICE}\" --region \"\${CLOUD_RUN_REGION}\" --format='value(spec.template.metadata.labels.commit-sha)'" "$workflow_file" @@ -30,7 +31,7 @@ grep -Fq 'ENABLE_GITHUB_ENV_SYNC: ${{ vars.ENABLE_GITHUB_ENV_SYNC }}' "$workflow grep -Fq 'CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }}' "$workflow_file" grep -Fq 'CLOUD_RUN_SERVICE: ${{ vars.CLOUD_RUN_SERVICE }}' "$workflow_file" grep -Fq 'TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }}' "$workflow_file" -grep -Fq 'STRATEGY_PROFILE: ${{ vars.STRATEGY_PROFILE }}' "$workflow_file" +grep -Fq 'RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON }}' "$workflow_file" grep -Fq 'ACCOUNT_GROUP: ${{ vars.ACCOUNT_GROUP }}' "$workflow_file" grep -Fq 'IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME: ${{ vars.IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME }}' "$workflow_file" grep -Fq 'IBKR_FEATURE_SNAPSHOT_PATH: ${{ vars.IBKR_FEATURE_SNAPSHOT_PATH }}' "$workflow_file" @@ -51,14 +52,15 @@ grep -Fq 'env_pairs=(' "$workflow_file" grep -Fq 'secret_pairs=(' "$workflow_file" grep -Fq 'remove_env_vars=(' "$workflow_file" grep -Fq 'remove_secret_vars=(' "$workflow_file" -grep -Fq 'STRATEGY_PROFILE=${STRATEGY_PROFILE}' "$workflow_file" grep -Fq 'ACCOUNT_GROUP=${ACCOUNT_GROUP}' "$workflow_file" grep -Fq 'IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME=${IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME}' "$workflow_file" +grep -Fq 'RUNTIME_TARGET_JSON=${RUNTIME_TARGET_JSON}' "$workflow_file" grep -Fq 'REQUIRES_SNAPSHOT_ARTIFACTS: ${{ steps.strategy_requirements.outputs.requires_snapshot_artifacts }}' "$workflow_file" grep -Fq 'REQUIRES_SNAPSHOT_MANIFEST_PATH: ${{ steps.strategy_requirements.outputs.requires_snapshot_manifest_path }}' "$workflow_file" grep -Fq 'REQUIRES_STRATEGY_CONFIG_PATH: ${{ steps.strategy_requirements.outputs.requires_strategy_config_path }}' "$workflow_file" grep -Fq 'CONFIG_SOURCE_POLICY: ${{ steps.strategy_requirements.outputs.config_source_policy }}' "$workflow_file" grep -Fq 'RECONCILIATION_OUTPUT_POLICY: ${{ steps.strategy_requirements.outputs.reconciliation_output_policy }}' "$workflow_file" +grep -Fq 'RUNTIME_TARGET_JSON: ${{ steps.strategy_requirements.outputs.runtime_target_json }}' "$workflow_file" grep -Fq 'if [ "${REQUIRES_SNAPSHOT_ARTIFACTS:-}" = "true" ]; then' "$workflow_file" grep -Fq 'if [ "${REQUIRES_SNAPSHOT_MANIFEST_PATH:-}" = "true" ]; then' "$workflow_file" grep -Fq 'if [ "${REQUIRES_STRATEGY_CONFIG_PATH:-}" = "true" ] && [ "${CONFIG_SOURCE_POLICY:-}" = "env_only" ]; then' "$workflow_file" @@ -84,9 +86,10 @@ grep -Fq 'remove_env_vars+=("IB_GATEWAY_IP_MODE")' "$workflow_file" grep -Fq '"IB_CLIENT_ID"' "$workflow_file" grep -Fq '"IB_GATEWAY_INSTANCE_NAME"' "$workflow_file" grep -Fq '"IB_GATEWAY_MODE"' "$workflow_file" +grep -Fq 'join_by_delimiter()' "$workflow_file" grep -Fq -- '--remove-secrets "$(IFS=,; echo "${remove_secret_vars[*]}")"' "$workflow_file" grep -Fq -- '--update-secrets "$(IFS=,; echo "${secret_pairs[*]}")"' "$workflow_file" grep -Fq -- '--concurrency 1' "$workflow_file" grep -Fq -- '--max-instances 1' "$workflow_file" grep -Fq -- '--remove-env-vars "$(IFS=,; echo "${remove_env_vars[*]}")"' "$workflow_file" -grep -Fq -- '--update-env-vars "$(IFS=,; echo "${env_pairs[*]}")"' "$workflow_file" +grep -Fq -- '--update-env-vars "^|^$(join_by_delimiter "|" "${env_pairs[@]}")"' "$workflow_file"