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"