diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index 92a1c04..2ab4772 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -24,12 +24,13 @@ jobs: TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }} SCHWAB_API_KEY_SECRET_NAME: ${{ vars.SCHWAB_API_KEY_SECRET_NAME }} SCHWAB_APP_SECRET_SECRET_NAME: ${{ vars.SCHWAB_APP_SECRET_SECRET_NAME }} - STRATEGY_PROFILE: ${{ vars.STRATEGY_PROFILE || 'tqqq_growth_income' }} + RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON }} SCHWAB_DRY_RUN_ONLY: ${{ vars.SCHWAB_DRY_RUN_ONLY }} SCHWAB_FEATURE_SNAPSHOT_PATH: ${{ vars.SCHWAB_FEATURE_SNAPSHOT_PATH }} SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH }} SCHWAB_STRATEGY_CONFIG_PATH: ${{ vars.SCHWAB_STRATEGY_CONFIG_PATH }} SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON }} + # Optional strategy overrides; leave unset to inherit the UsEquityStrategies profile defaults. INCOME_THRESHOLD_USD: ${{ vars.INCOME_THRESHOLD_USD }} QQQI_INCOME_RATIO: ${{ vars.QQQI_INCOME_RATIO }} DUAL_DRIVE_UNLEVERED_SYMBOL: ${{ vars.DUAL_DRIVE_UNLEVERED_SYMBOL }} @@ -82,9 +83,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( @@ -113,6 +118,7 @@ jobs: output.write( f"config_source_policy={str(selected.get('config_source_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,6 +128,7 @@ jobs: REQUIRES_SNAPSHOT_MANIFEST_PATH: ${{ steps.strategy_requirements.outputs.requires_snapshot_manifest_path }} REQUIRES_STRATEGY_CONFIG_PATH: ${{ steps.strategy_requirements.outputs.requires_strategy_config_path }} CONFIG_SOURCE_POLICY: ${{ steps.strategy_requirements.outputs.config_source_policy }} + RUNTIME_TARGET_JSON: ${{ steps.strategy_requirements.outputs.runtime_target_json }} run: | set -euo pipefail @@ -165,6 +172,10 @@ jobs: missing_vars+=("SCHWAB_STRATEGY_CONFIG_PATH") fi + if [ -z "${RUNTIME_TARGET_JSON:-}" ]; then + missing_vars+=("RUNTIME_TARGET_JSON") + fi + if [ "${#missing_vars[@]}" -gt 0 ]; then echo "Cloud Run env sync is enabled, but these values are missing:" >&2 printf ' - %s\n' "${missing_vars[@]}" >&2 @@ -212,13 +223,15 @@ 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 env_pairs=( "GLOBAL_TELEGRAM_CHAT_ID=${GLOBAL_TELEGRAM_CHAT_ID}" "NOTIFY_LANG=${NOTIFY_LANG}" - "STRATEGY_PROFILE=${STRATEGY_PROFILE}" + "RUNTIME_TARGET_JSON=${RUNTIME_TARGET_JSON}" ) secret_pairs=() remove_env_vars=( diff --git a/README.md b/README.md index b64d8e9..9b51168 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,14 @@ ## English Automated trading service for Charles Schwab accounts, deployed on GCP Cloud Run. This repository runs shared `us_equity` strategy profiles from `UsEquityStrategies`; strategy logic, cadence, asset universes, parameters, and research/backtest notes live in that strategy repository. +The runtime now carries a structured `RuntimeTarget` / `RUNTIME_TARGET_JSON` alongside the compatibility `STRATEGY_PROFILE` selector. Strategy-owned defaults come from `UsEquityStrategies`; platform variables are only explicit overrides. This repository uses `QuantPlatformKit` for Schwab client bootstrap, account snapshot access, market data, and order submission. Cloud Run deploys this repository directly. The Schwab runtime can execute the six current `runtime_enabled` `us_equity` profiles from `UsEquityStrategies`. Full strategy documentation now lives in [`UsEquityStrategies`](https://github.com/QuantStrategyLab/UsEquityStrategies). The sections below focus on Schwab runtime behavior, profile enablement, deployment, and credentials. This runtime matrix is the authoritative enablement source for Schwab. `UsEquityStrategies` carries strategy-layer logic, cadence, compatibility, and metadata. +`STRATEGY_PROFILE` remains the compatibility selector for strategy routing, while `RuntimeTarget` describes the running service identity. ### Execution boundary @@ -72,9 +74,9 @@ Each HTTP request runs one broker execution cycle. The Cloud Scheduler cron shou | `SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON` | Optional Schwab-side strategy plugin mount JSON. Prefer this Schwab-specific variable; `STRATEGY_PLUGIN_MOUNTS_JSON` is only a shared fallback. | | `SCHWAB_MIN_RESERVED_CASH_USD` | Optional platform cash-reserve floor in USD. Default policy: keep `150 USD` or `3% of total equity`, whichever is larger. Runtime formula: `max(floor, ratio * total_equity)`. | | `SCHWAB_RESERVED_CASH_RATIO` | Optional platform cash-reserve ratio. Default policy: keep `150 USD` or `3% of total equity`, whichever is larger. Runtime formula: `max(floor, ratio * total_equity)`. | -| `INCOME_THRESHOLD_USD` | Optional override for the strategy income-layer threshold. Leave unset to use the `UsEquityStrategies` live default, which disables the income layer for normal account sizes. | -| `QQQI_INCOME_RATIO` | Optional override for QQQI share of the income layer, 0–1. Only relevant when the income layer is enabled. | -| `DUAL_DRIVE_UNLEVERED_SYMBOL` | Optional `tqqq_growth_income` override for the tradable unlevered growth sleeve. Leave unset for `QQQ`; set to `QQQM` for smaller Schwab accounts while retaining `QQQ` as the signal source. | +| `INCOME_THRESHOLD_USD` | Optional strategy override for the income-layer threshold. Leave unset to use the `UsEquityStrategies` live default, which disables the income layer for normal account sizes. | +| `QQQI_INCOME_RATIO` | Optional strategy override for QQQI share of the income layer, 0–1. Only relevant when the income layer is enabled. | +| `DUAL_DRIVE_UNLEVERED_SYMBOL` | Optional strategy override for the tradable unlevered growth sleeve. Leave unset for `QQQ`; set to `QQQM` only when the deployment intentionally uses QQQM instead of QQQ. | | `NOTIFY_LANG` | Notification language: `en` (English, default) or `zh` (Chinese) | Strategy plugin mount JSON belongs to platform/deployment configuration, not strategy code. It decides which plugin artifacts this runtime reads, and must not set `mode`; the plugin artifact is self-identifying and carries the effective mode. Invalid plugin mount config is recorded in the runtime report diagnostics and does not block the base strategy cycle. @@ -106,9 +108,9 @@ Recommended setup: - `STRATEGY_PROFILE` (set explicitly to one enabled profile: `global_etf_rotation`, `mega_cap_leader_rotation_top50_balanced`, `russell_1000_multi_factor_defensive`, `tqqq_growth_income`, `soxl_soxx_trend_income`, or `tech_communication_pullback_enhancement`) - Optional: `SCHWAB_FEATURE_SNAPSHOT_PATH`, `SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH`, `SCHWAB_STRATEGY_CONFIG_PATH` for feature-snapshot profiles - Optional: `SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON` for strategy plugin artifact mounts. Do not include `mode` in this platform mount JSON. - - Optional: `INCOME_THRESHOLD_USD` - - Optional: `QQQI_INCOME_RATIO` - - Optional: `DUAL_DRIVE_UNLEVERED_SYMBOL` + - Optional: `INCOME_THRESHOLD_USD` (strategy override only) + - Optional: `QQQI_INCOME_RATIO` (strategy override only) + - Optional: `DUAL_DRIVE_UNLEVERED_SYMBOL` (strategy override only) - Optional: `GOOGLE_CLOUD_PROJECT` - **Repository Secrets** - Optional fallback only: `TELEGRAM_TOKEN` @@ -125,7 +127,7 @@ Important: - The workflow only becomes strict when `ENABLE_GITHUB_ENV_SYNC=true`. If this variable is unset, the sync job is skipped and the old Google Cloud Trigger + manual Cloud Run env setup keeps working. When enabled, it resolves the selected profile's snapshot/config requirements from `scripts/print_strategy_profile_status.py --json` instead of a hard-coded strategy-name list. - `STRATEGY_PROFILE` is driven by the platform capability matrix plus a rollout allowlist derived from `runtime_enabled` strategy metadata. Today `enabled` includes six live `us_equity` profiles: `global_etf_rotation`, `mega_cap_leader_rotation_top50_balanced`, `russell_1000_multi_factor_defensive`, `tqqq_growth_income`, `soxl_soxx_trend_income`, and `tech_communication_pullback_enhancement`; archived research-only profiles remain eligible in the capability matrix but are not enabled. - The current strategy domain is `us_equity`, and the repo now keeps a thin strategy registry so future expansion can grow by domain + profile instead of mixing strategy and platform in one layer. -- `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO`, and `DUAL_DRIVE_UNLEVERED_SYMBOL` are optional in env sync. Leave them unset to inherit the `UsEquityStrategies` profile defaults; the current `tqqq_growth_income` live default is the no-income QQQ/TQQQ dual-drive mode. Set `DUAL_DRIVE_UNLEVERED_SYMBOL=QQQM` when the Schwab account should trade QQQM instead of whole-share QQQ. +- `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO`, and `DUAL_DRIVE_UNLEVERED_SYMBOL` are optional env-sync overrides, not platform defaults. Leave them unset to inherit the `UsEquityStrategies` profile defaults; the current `tqqq_growth_income` live default is the no-income QQQ/TQQQ dual-drive mode. Set `DUAL_DRIVE_UNLEVERED_SYMBOL=QQQM` only when the deployment intentionally uses QQQM instead of whole-share QQQ. - GitHub now authenticates to Google Cloud with OIDC + Workload Identity Federation. `GCP_SA_KEY` is no longer required for this workflow. - If you deploy with `gcloud run deploy --source` or a Cloud Run source trigger, also grant `roles/storage.objectViewer` on `gs://run-sources--` to the build service account, the deploy service account, and the default compute service account. Without that bucket access, source deploy fails before Cloud Build starts with `storage.objects.get` denied. - The Telegram token and Schwab API credentials should live in Secret Manager and be referenced by the secret-name variables above. Across multiple quant repos, only `GLOBAL_TELEGRAM_CHAT_ID` and `NOTIFY_LANG` are good cross-project shared settings. @@ -205,7 +207,7 @@ Schwab OAuth token payload 当前从 Secret Manager 的 `schwab_token` 里读取 ### GitHub 统一管理 Cloud Run 环境变量 -如果代码部署继续走 Google Cloud Trigger,但你想把运行时环境变量统一放在 GitHub 管理,这个仓库现在提供了 `.github/workflows/sync-cloud-run-env.yml`。 +如果代码部署继续走 Google Cloud Trigger,但你想把运行时环境变量统一放在 GitHub 管理,这个仓库现在提供了 `.github/workflows/sync-cloud-run-env.yml`。这个 workflow 现在也会发出 `RUNTIME_TARGET_JSON`,让控制面带上结构化运行目标,而不是只看 `STRATEGY_PROFILE`。 推荐配置方式: @@ -219,9 +221,9 @@ Schwab OAuth token payload 当前从 Secret Manager 的 `schwab_token` 里读取 - `STRATEGY_PROFILE`(显式设置为任一已启用 profile:`global_etf_rotation`、`mega_cap_leader_rotation_top50_balanced`、`russell_1000_multi_factor_defensive`、`tqqq_growth_income`、`soxl_soxx_trend_income` 或 `tech_communication_pullback_enhancement`) - 可选:`SCHWAB_FEATURE_SNAPSHOT_PATH`、`SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH`、`SCHWAB_STRATEGY_CONFIG_PATH`,用于 feature-snapshot 策略 - 可选:`SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON`,用于策略插件 artifact 挂载。不要在这个平台挂载 JSON 里放 `mode` - - 可选:`INCOME_THRESHOLD_USD` - - 可选:`QQQI_INCOME_RATIO` - - 可选:`DUAL_DRIVE_UNLEVERED_SYMBOL` + - 可选:`INCOME_THRESHOLD_USD`(仅策略 override) + - 可选:`QQQI_INCOME_RATIO`(仅策略 override) + - 可选:`DUAL_DRIVE_UNLEVERED_SYMBOL`(仅策略 override) - 可选:`GOOGLE_CLOUD_PROJECT` - **仓库级 Secrets** - 仅保留为 fallback:`TELEGRAM_TOKEN` @@ -236,9 +238,9 @@ Schwab OAuth token payload 当前从 Secret Manager 的 `schwab_token` 里读取 注意: - 只有在 `ENABLE_GITHUB_ENV_SYNC=true` 时,这个 workflow 才会严格校验并执行同步。没打开时会直接跳过,不影响原来 Google Cloud Trigger + 手工 Cloud Run env 的老流程。打开后,它会通过 `scripts/print_strategy_profile_status.py --json` 动态解析目标策略需要的 snapshot/config 输入,不再维护硬编码策略名列表。 -- `STRATEGY_PROFILE` 现在由平台能力矩阵和从 `runtime_enabled` 策略元数据派生的 rollout allowlist 一起决定。当前 `enabled` 包含 6 条 live `us_equity` 策略:`global_etf_rotation`、`mega_cap_leader_rotation_top50_balanced`、`russell_1000_multi_factor_defensive`、`tqqq_growth_income`、`soxl_soxx_trend_income` 和 `tech_communication_pullback_enhancement`;research-only 存档 profile 仍保留能力矩阵兼容性,但不会启用。 -- 当前策略域是 `us_equity`,本地策略注册表只用于域和 profile 校验。 -- `INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO` 和 `DUAL_DRIVE_UNLEVERED_SYMBOL` 在 env-sync 里是可选项。不填时会继承 `UsEquityStrategies` 的 profile 默认值;当前 `tqqq_growth_income` 实盘默认是不带收入层的 QQQ/TQQQ 双轮模式。Schwab 小账户需要用 QQQM 替代整股 QQQ 时,设置 `DUAL_DRIVE_UNLEVERED_SYMBOL=QQQM`。 +- `STRATEGY_PROFILE` 现在由平台能力矩阵和从 `runtime_enabled` 策略元数据派生的 rollout allowlist 一起决定。当前 `enabled` 包含 6 条 live `us_equity` 策略:`global_etf_rotation`、`mega_cap_leader_rotation_top50_balanced`、`russell_1000_multi_factor_defensive`、`tqqq_growth_income`、`soxl_soxx_trend_income` 和 `tech_communication_pullback_enhancement`;research-only 存档 profile 仍保留能力矩阵兼容性,但不会启用。`RUNTIME_TARGET_JSON` 则表示实际运行目标,`STRATEGY_PROFILE` 继续只负责兼容选择策略实现。 +- 当前策略域是 `us_equity`,本地策略注册表只用于域和 profile 校验;结构化运行目标会通过 `RUNTIME_TARGET_JSON` 往下传。 +- `INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO` 和 `DUAL_DRIVE_UNLEVERED_SYMBOL` 在 env-sync 里是可选 override,不是平台默认值来源。不填时会继承 `UsEquityStrategies` 的 profile 默认值;当前 `tqqq_growth_income` 实盘默认是不带收入层的 QQQ/TQQQ 双轮模式。只有在明确要用 QQQM 替代整股 QQQ 时,才设置 `DUAL_DRIVE_UNLEVERED_SYMBOL=QQQM`。 - GitHub 现在通过 OIDC + Workload Identity Federation 登录 Google Cloud,这个 workflow 不再需要 `GCP_SA_KEY`。 - 如果你用 `gcloud run deploy --source` 或 Cloud Run source trigger 部署,还要确保 `gs://run-sources--` 这个 staging bucket 给 build service account、deploy service account、默认 compute service account 都加上 `roles/storage.objectViewer`。少了这层权限,会在 Cloud Build 启动前直接报 `storage.objects.get denied`。 - Telegram token 和 Schwab API 凭据建议放到 Secret Manager,并通过上面的 secret-name 变量引用。对多个 quant 仓库来说,真正适合跨项目共享的通常只有 `GLOBAL_TELEGRAM_CHAT_ID` 和 `NOTIFY_LANG`。 diff --git a/application/runtime_composer.py b/application/runtime_composer.py index e70a64a..cce1d69 100644 --- a/application/runtime_composer.py +++ b/application/runtime_composer.py @@ -3,12 +3,15 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any from application.runtime_dependencies import SchwabRebalanceConfig, SchwabRebalanceRuntime 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.runtime_target import RuntimeTarget from notifications.telegram import build_sender @@ -48,6 +51,8 @@ class SchwabRuntimeComposer: sender_builder: Callable[..., Callable[[str], None]] = build_sender notification_builder: Callable[..., Any] = build_runtime_notification_adapters reporting_builder: Callable[..., Any] = build_runtime_reporting_adapters + runtime_target: RuntimeTarget | None = None + extra_reporting_fields: dict[str, Any] = field(default_factory=dict) def send_tg_message(self, message: str) -> None: sender = self.sender_builder(self.tg_token, self.tg_chat_id) @@ -60,17 +65,24 @@ def build_notification_adapters(self): ) def build_reporting_adapters(self): - return self.reporting_builder( + runtime_assembly = build_runtime_assembly( platform="charles_schwab", deploy_target="cloud_run", service_name=self.service_name, strategy_profile=self.strategy_profile, - strategy_domain=self.strategy_domain, + runtime_target=self.runtime_target, project_id=self.project_id, - extra_context_fields={ - "strategy_display_name": self.strategy_display_name, - "strategy_display_name_localized": self.strategy_display_name_localized, - }, + extra_context_fields=build_runtime_context_fields( + { + "strategy_display_name": self.strategy_display_name, + "strategy_display_name_localized": self.strategy_display_name_localized, + **dict(self.extra_reporting_fields), + }, + ), + ) + return self.reporting_builder( + runtime_assembly=runtime_assembly, + strategy_domain=self.strategy_domain, managed_symbols=self.managed_symbols, benchmark_symbol=self.benchmark_symbol, strategy_display_name=self.strategy_display_name, @@ -169,6 +181,8 @@ def build_runtime_composer( env_reader: Callable[[str, str], str | None], sleeper: Callable[[float], None] | None = None, printer: Callable[..., Any] = print, + extra_reporting_fields: dict[str, Any] | None = None, + runtime_target: RuntimeTarget | None = None, ) -> SchwabRuntimeComposer: return SchwabRuntimeComposer( project_id=project_id, @@ -202,4 +216,6 @@ def build_runtime_composer( env_reader=env_reader, sleeper=sleeper, 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 00763f1..8000aae 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,12 +19,9 @@ def _utcnow() -> datetime: @dataclass(frozen=True) class SchwabRuntimeReportingAdapters: - platform: str - deploy_target: str - service_name: str - strategy_profile: str + runtime_assembly: RuntimeAssembly strategy_domain: str | None - project_id: str | None + runtime_target: RuntimeTarget | None = None extra_context_fields: Mapping[str, Any] = field(default_factory=dict) managed_symbols: tuple[str, ...] = () benchmark_symbol: str = "" @@ -51,14 +50,10 @@ def __post_init__(self) -> None: raise ValueError(f"Missing reporting adapter dependencies: {', '.join(missing)}") def build_log_context(self) -> RuntimeLogContext: - return RuntimeLogContext( - platform=self.platform, - deploy_target=self.deploy_target, - service_name=self.service_name, - strategy_profile=self.strategy_profile, - project_id=self.project_id, - extra_fields=dict(self.extra_context_fields), - ).with_run(self.run_id_builder()) + 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()) def build_report(self, log_context: RuntimeLogContext) -> dict[str, Any]: started_at = self.clock() @@ -67,15 +62,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, - 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={ "managed_symbols": list(self.managed_symbols), "benchmark_symbol": self.benchmark_symbol, @@ -102,7 +97,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 @@ -111,12 +106,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, - project_id: str | None, + runtime_target: RuntimeTarget | None = None, extra_context_fields: Mapping[str, Any] | None = None, managed_symbols: tuple[str, ...], benchmark_symbol: str, @@ -134,12 +126,9 @@ def build_runtime_reporting_adapters( clock: Callable[[], datetime] = _utcnow, ) -> SchwabRuntimeReportingAdapters: return SchwabRuntimeReportingAdapters( - platform=platform, - deploy_target=deploy_target, - service_name=service_name, - strategy_profile=strategy_profile, + runtime_assembly=runtime_assembly, strategy_domain=strategy_domain, - project_id=project_id, + runtime_target=runtime_target, extra_context_fields=dict(extra_context_fields or {}), managed_symbols=tuple(managed_symbols), benchmark_symbol=str(benchmark_symbol or ""), diff --git a/main.py b/main.py index 7f65457..112b55d 100644 --- a/main.py +++ b/main.py @@ -209,6 +209,7 @@ def build_composer(): env_reader=os.getenv, sleeper=time.sleep, printer=print, + runtime_target=RUNTIME_SETTINGS.runtime_target, ) diff --git a/notifications/telegram.py b/notifications/telegram.py index f272a68..99e0e23 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -2,9 +2,6 @@ from __future__ import annotations -import requests - - SIGNAL_ICONS = { "hold": "💎", "entry": "🚀", @@ -206,7 +203,10 @@ def strategy_display_name(profile: str, *, fallback_name: str | None = None) -> return strategy_display_name -def build_sender(token, chat_id, *, requests_module=requests): +def build_sender(token, chat_id, *, requests_module=None): + if requests_module is None: + import requests as requests_module + def send_tg_message(message): if not token or not chat_id: return diff --git a/requirements.txt b/requirements.txt index 592e96d..98f5bbb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ flask gunicorn -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@c24d4c52e84c8c696006532590b15e9be92c8d89 -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@c0cf04f002fd6348c9af7ebd95c9c0ad03c36bcd +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@573fc9e9917cf1f2c1acda9232c5a23a8a05d797 +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@53911cbe32f6932e759522e54aa38ca5350aa44e pandas requests pandas_market_calendars diff --git a/runtime_config_support.py b/runtime_config_support.py index 1522891..a95c71b 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -8,6 +8,10 @@ resolve_bool_value, resolve_strategy_runtime_path_settings, ) +from quant_platform_kit.common.runtime_target import ( + RuntimeTarget, + resolve_runtime_target_from_env, +) from strategy_registry import ( SCHWAB_PLATFORM, resolve_strategy_definition, @@ -16,7 +20,7 @@ from us_equity_strategies import get_strategy_catalog DEFAULT_NOTIFY_LANG = "en" -DEFAULT_RESERVED_CASH_FLOOR_USD = 300.0 +DEFAULT_RESERVED_CASH_FLOOR_USD = 150.0 DEFAULT_RESERVED_CASH_RATIO = 0.03 @@ -34,6 +38,7 @@ class PlatformRuntimeSettings: strategy_config_path: str | None = None strategy_config_source: str | None = None strategy_plugin_mounts_json: str | None = None + runtime_target: RuntimeTarget | None = None def _resolve_non_negative_float_env(name: str, *, default: float) -> float: @@ -61,8 +66,9 @@ def resolve_strategy_profile(raw_value: str | None = None) -> str: def load_platform_runtime_settings() -> PlatformRuntimeSettings: + runtime_target = resolve_runtime_target_from_env(env=os.environ, expected_platform_id=SCHWAB_PLATFORM) strategy_definition = resolve_strategy_definition( - os.getenv("STRATEGY_PROFILE"), + runtime_target.strategy_profile, platform_id=SCHWAB_PLATFORM, ) strategy_metadata = resolve_strategy_metadata( @@ -99,4 +105,5 @@ def load_platform_runtime_settings() -> PlatformRuntimeSettings: os.getenv("SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON") or os.getenv("STRATEGY_PLUGIN_MOUNTS_JSON") ), + 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 cd788fe..29fc0ca 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 SCHWAB_PLATFORM, @@ -47,8 +48,18 @@ def build_switch_plan(profile: str) -> dict[str, object]: ) requires_strategy_config_path = bool(runtime_requirements["requires_strategy_config_path"]) config_source_policy = str(runtime_requirements.get("config_source_policy") or "none") + runtime_target = build_runtime_target( + platform_id=SCHWAB_PLATFORM, + strategy_profile=definition.profile, + dry_run_only=False, + deployment_selector=None, + account_scope=None, + service_name="charles-schwab-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: list[str] = [] optional_env = [ "SCHWAB_DRY_RUN_ONLY", @@ -57,7 +68,7 @@ def build_switch_plan(profile: str) -> dict[str, object]: ] remove_if_present: list[str] = [] notes = [ - "Schwab has a single service identity, so strategy switching mainly changes STRATEGY_PROFILE and any snapshot-config envs.", + "Schwab has a single service identity, so strategy switching mainly changes the structured runtime target and any snapshot-config envs.", ] if requires_feature_snapshot: @@ -104,6 +115,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), @@ -123,6 +135,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/test_decision_mapper.py b/tests/test_decision_mapper.py index db08e8b..54d8605 100644 --- a/tests/test_decision_mapper.py +++ b/tests/test_decision_mapper.py @@ -109,13 +109,13 @@ def test_applies_platform_reserved_cash_floor_for_weight_targets(self): strategy_profile="tech_communication_pullback_enhancement", runtime_metadata={ "schwab_execution_policy": { - "reserved_cash_floor_usd": 300.0, + "reserved_cash_floor_usd": 150.0, "reserved_cash_ratio": 0.03, } }, ) - self.assertEqual(plan["execution"]["reserved_cash"], 300.0) + self.assertEqual(plan["execution"]["reserved_cash"], 150.0) def test_platform_reserved_cash_floor_can_raise_strategy_reserve(self): snapshot = SimpleNamespace( @@ -140,7 +140,7 @@ def test_platform_reserved_cash_floor_can_raise_strategy_reserve(self): strategy_profile="tqqq_growth_income", runtime_metadata={ "schwab_execution_policy": { - "reserved_cash_floor_usd": 300.0, + "reserved_cash_floor_usd": 150.0, "reserved_cash_ratio": 0.03, } }, diff --git a/tests/test_request_handling.py b/tests/test_request_handling.py index bec7868..568d48b 100644 --- a/tests/test_request_handling.py +++ b/tests/test_request_handling.py @@ -63,9 +63,10 @@ def run(self, *args, **kwargs): strategy_domain="us_equity", notify_lang=notify_lang, dry_run_only=False, - reserved_cash_floor_usd=300.0, + reserved_cash_floor_usd=150.0, reserved_cash_ratio=0.03, strategy_plugin_mounts_json=strategy_plugin_mounts_json, + runtime_target=None, ) strategy_runtime_module = types.ModuleType("strategy_runtime") diff --git a/tests/test_runtime_composer.py b/tests/test_runtime_composer.py index 7879549..40ae50b 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 SchwabRuntimeComposer @@ -39,6 +40,12 @@ def fake_reporting_builder(**kwargs): benchmark_symbol="QQQ", signal_effective_after_trading_days=1, dry_run_only=True, + runtime_target=build_runtime_target( + platform_id="charles_schwab", + strategy_profile="tqqq_growth_income", + dry_run_only=True, + service_name="charles-schwab-platform", + ), limit_buy_premium=1.005, sell_settle_delay_sec=3.0, post_sell_refresh_attempts=5, @@ -88,6 +95,9 @@ def fake_reporting_builder(**kwargs): assert observed["notification_builder"]["send_message"] assert observed["reporting_builder"]["managed_symbols"] == ("TQQQ", "BOXX", "SPYI", "QQQI") assert observed["reporting_builder"]["signal_effective_after_trading_days"] == 1 + assert observed["reporting_builder"]["runtime_assembly"].runtime_target.platform_id == "charles_schwab" + assert observed["reporting_builder"]["runtime_assembly"].runtime_target.strategy_profile == "tqqq_growth_income" + assert observed["reporting_builder"]["runtime_assembly"].runtime_target.execution_mode == "paper" assert runtime.fetch_reference_history() == ("reference-history", ("market-data-port", "client")) assert runtime.portfolio_port == ("portfolio-port", "client") assert runtime.execution_port_factory("hash-1") == ("execution-port", "client", "hash-1") diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 00e02cf..2a7a70e 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -52,9 +52,40 @@ def expected_schwab_profiles(actual_profiles) -> frozenset[str]: return BASE_SCHWAB_PROFILES | (OPTIONAL_SCHWAB_PROFILES & actual) +def runtime_target_json( + strategy_profile: str, + *, + dry_run_only: bool = False, + platform_id: str = "schwab", + deployment_selector: str | None = "schwab", + account_selector: list[str] | tuple[str, ...] | None = None, + account_scope: str | None = "schwab", + service_name: str | None = None, +) -> str: + payload: dict[str, object] = { + "platform_id": platform_id, + "strategy_profile": strategy_profile, + "dry_run_only": dry_run_only, + } + if deployment_selector is not None: + payload["deployment_selector"] = deployment_selector + if account_selector is not None: + payload["account_selector"] = list(account_selector) + if account_scope is not None: + payload["account_scope"] = account_scope + 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=(",", ":")) + + class RuntimeConfigSupportTests(unittest.TestCase): def test_defaults_with_explicit_strategy_profile(self): - with patch.dict(os.environ, {"STRATEGY_PROFILE": SAMPLE_STRATEGY_PROFILE}, clear=True): + with patch.dict( + os.environ, + {"RUNTIME_TARGET_JSON": runtime_target_json(SAMPLE_STRATEGY_PROFILE)}, + clear=True, + ): settings = load_platform_runtime_settings() self.assertEqual(settings.strategy_profile, SAMPLE_STRATEGY_PROFILE) @@ -62,6 +93,8 @@ def test_defaults_with_explicit_strategy_profile(self): self.assertEqual(settings.strategy_domain, US_EQUITY_DOMAIN) self.assertEqual(settings.notify_lang, DEFAULT_NOTIFY_LANG) self.assertFalse(settings.dry_run_only) + self.assertEqual(settings.runtime_target.platform_id, "schwab") + self.assertEqual(settings.runtime_target.execution_mode, "live") self.assertEqual(settings.reserved_cash_floor_usd, DEFAULT_RESERVED_CASH_FLOOR_USD) self.assertEqual(settings.reserved_cash_ratio, DEFAULT_RESERVED_CASH_RATIO) self.assertIsNone(settings.feature_snapshot_path) @@ -70,19 +103,48 @@ def test_defaults_with_explicit_strategy_profile(self): self.assertIsNone(settings.strategy_config_source) self.assertIsNone(settings.strategy_plugin_mounts_json) + def test_defaults_prefers_runtime_target_json(self): + with patch.dict( + os.environ, + { + "RUNTIME_TARGET_JSON": ( + '{"platform_id":"schwab","strategy_profile":"global_etf_rotation",' + '"dry_run_only":true,"service_name":"charles-schwab-quant-service",' + '"execution_mode":"paper"}' + ), + }, + clear=True, + ): + settings = load_platform_runtime_settings() + + self.assertEqual(settings.strategy_profile, "global_etf_rotation") + self.assertEqual(settings.runtime_target.strategy_profile, "global_etf_rotation") + self.assertEqual(settings.runtime_target.platform_id, "schwab") + self.assertTrue(settings.runtime_target.dry_run_only) + self.assertEqual(settings.runtime_target.execution_mode, "paper") + self.assertEqual(settings.runtime_target.service_name, "charles-schwab-quant-service") + def test_requires_strategy_profile(self): with patch.dict(os.environ, {}, clear=True): - with self.assertRaisesRegex(EnvironmentError, "STRATEGY_PROFILE is required"): + with self.assertRaisesRegex(EnvironmentError, "RUNTIME_TARGET_JSON is required"): load_platform_runtime_settings() def test_uses_explicit_strategy_profile(self): - with patch.dict(os.environ, {"STRATEGY_PROFILE": SAMPLE_STRATEGY_PROFILE}, clear=True): + with patch.dict( + os.environ, + {"RUNTIME_TARGET_JSON": runtime_target_json(SAMPLE_STRATEGY_PROFILE)}, + clear=True, + ): settings = load_platform_runtime_settings() self.assertEqual(settings.strategy_profile, SAMPLE_STRATEGY_PROFILE) def test_rejects_unknown_strategy_profile(self): - with patch.dict(os.environ, {"STRATEGY_PROFILE": "balanced_income"}, clear=True): + with patch.dict( + os.environ, + {"RUNTIME_TARGET_JSON": runtime_target_json("balanced_income")}, + clear=True, + ): with self.assertRaisesRegex(ValueError, "Unsupported STRATEGY_PROFILE"): load_platform_runtime_settings() @@ -95,14 +157,24 @@ def test_platform_eligible_profiles_are_exposed_by_capability_matrix(self): self.assertEqual(profiles, expected_schwab_profiles(profiles)) def test_rejects_human_readable_alias(self): - with patch.dict(os.environ, {"STRATEGY_PROFILE": "qqq_tqqq_growth_income"}, clear=True): + with patch.dict( + os.environ, + {"RUNTIME_TARGET_JSON": runtime_target_json("qqq_tqqq_growth_income")}, + clear=True, + ): with self.assertRaises(ValueError): load_platform_runtime_settings() def test_reads_schwab_dry_run_only_flag(self): with patch.dict( os.environ, - {"STRATEGY_PROFILE": SAMPLE_STRATEGY_PROFILE, "SCHWAB_DRY_RUN_ONLY": "true"}, + { + "RUNTIME_TARGET_JSON": runtime_target_json( + SAMPLE_STRATEGY_PROFILE, + dry_run_only=True, + ), + "SCHWAB_DRY_RUN_ONLY": "true", + }, clear=True, ): settings = load_platform_runtime_settings() @@ -113,7 +185,7 @@ def test_reads_reserved_cash_policy_overrides(self): with patch.dict( os.environ, { - "STRATEGY_PROFILE": SAMPLE_STRATEGY_PROFILE, + "RUNTIME_TARGET_JSON": runtime_target_json(SAMPLE_STRATEGY_PROFILE), "SCHWAB_MIN_RESERVED_CASH_USD": "80", "SCHWAB_RESERVED_CASH_RATIO": "0.025", }, @@ -128,7 +200,7 @@ def test_rejects_invalid_reserved_cash_ratio(self): with patch.dict( os.environ, { - "STRATEGY_PROFILE": SAMPLE_STRATEGY_PROFILE, + "RUNTIME_TARGET_JSON": runtime_target_json(SAMPLE_STRATEGY_PROFILE), "SCHWAB_RESERVED_CASH_RATIO": "1.25", }, clear=True, @@ -140,7 +212,7 @@ def test_reads_strategy_plugin_mounts_from_global_env(self): with patch.dict( os.environ, { - "STRATEGY_PROFILE": SAMPLE_STRATEGY_PROFILE, + "RUNTIME_TARGET_JSON": runtime_target_json(SAMPLE_STRATEGY_PROFILE), "STRATEGY_PLUGIN_MOUNTS_JSON": '{"strategy_plugins":[]}', }, clear=True, @@ -153,7 +225,7 @@ def test_schwab_strategy_plugin_mounts_env_overrides_global_env(self): with patch.dict( os.environ, { - "STRATEGY_PROFILE": SAMPLE_STRATEGY_PROFILE, + "RUNTIME_TARGET_JSON": runtime_target_json(SAMPLE_STRATEGY_PROFILE), "STRATEGY_PLUGIN_MOUNTS_JSON": '{"strategy_plugins":[{"plugin":"global"}]}', "SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON": '{"strategy_plugins":[{"plugin":"schwab"}]}', }, @@ -309,11 +381,18 @@ def test_print_strategy_switch_env_plan_for_global_etf_rotation(self): self.assertEqual(plan["canonical_profile"], "global_etf_rotation") self.assertTrue(plan["eligible"]) self.assertTrue(plan["enabled"]) + self.assertEqual(plan["runtime_target"]["platform_id"], "schwab") + self.assertEqual(plan["runtime_target"]["strategy_profile"], "global_etf_rotation") + self.assertEqual(plan["runtime_target"]["service_name"], "charles-schwab-quant-service") + self.assertEqual(plan["runtime_target"]["execution_mode"], "live") self.assertEqual(plan["profile_group"], "direct_runtime_inputs") self.assertEqual(plan["input_mode"], "market_history") self.assertFalse(plan["requires_snapshot_artifacts"]) self.assertFalse(plan["requires_strategy_config_path"]) - self.assertEqual(plan["set_env"]["STRATEGY_PROFILE"], "global_etf_rotation") + self.assertEqual( + json.loads(plan["set_env"]["RUNTIME_TARGET_JSON"])["strategy_profile"], + "global_etf_rotation", + ) self.assertIn("SCHWAB_MIN_RESERVED_CASH_USD", plan["optional_env"]) self.assertIn("SCHWAB_RESERVED_CASH_RATIO", plan["optional_env"]) self.assertIn("SCHWAB_FEATURE_SNAPSHOT_PATH", plan["remove_if_present"]) @@ -342,7 +421,9 @@ def test_loads_feature_snapshot_env_for_tech_profile(self): with patch.dict( os.environ, { - "STRATEGY_PROFILE": "tech_communication_pullback_enhancement", + "RUNTIME_TARGET_JSON": runtime_target_json( + "tech_communication_pullback_enhancement" + ), "SCHWAB_FEATURE_SNAPSHOT_PATH": "gs://bucket/tech.csv", "SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH": "gs://bucket/tech.csv.manifest.json", "SCHWAB_STRATEGY_CONFIG_PATH": "/workspace/configs/tech.json", diff --git a/tests/test_runtime_reporting_adapters.py b/tests/test_runtime_reporting_adapters.py new file mode 100644 index 0000000..e102aef --- /dev/null +++ b/tests/test_runtime_reporting_adapters.py @@ -0,0 +1,58 @@ +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="charles_schwab", + deploy_target="cloud_run", + service_name="charles-schwab-platform", + strategy_profile="tqqq_growth_income", + runtime_target=build_runtime_target( + platform_id="charles_schwab", + strategy_profile="tqqq_growth_income", + dry_run_only=True, + service_name="charles-schwab-platform", + ), + project_id="project-1", + ), + strategy_domain="us_equity", + managed_symbols=("TQQQ", "BOXX", "SPYI", "QQQI"), + benchmark_symbol="QQQ", + strategy_display_name="TQQQ Growth Income", + strategy_display_name_localized="TQQQ 增长收益", + dry_run=True, + signal_effective_after_trading_days=1, + 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, + printer=lambda *_args, **_kwargs: None, + clock=lambda: datetime(2026, 4, 21, tzinfo=timezone.utc), + ) + + log_context, report = adapters.start_run() + + assert log_context.run_id == "run-001" + assert log_context.runtime_target.platform_id == "charles_schwab" + assert observed["report_builder"]["runtime_target"].platform_id == "charles_schwab" + assert observed["report_builder"]["runtime_target"].execution_mode == "paper" + assert report == {"run_id": "run-001"} diff --git a/tests/test_shared_chat_id_fallback.py b/tests/test_shared_chat_id_fallback.py index 1189f71..4c60afe 100644 --- a/tests/test_shared_chat_id_fallback.py +++ b/tests/test_shared_chat_id_fallback.py @@ -74,7 +74,7 @@ def run(self, *args, **kwargs): strategy_domain="us_equity", notify_lang="en", dry_run_only=False, - reserved_cash_floor_usd=300.0, + reserved_cash_floor_usd=150.0, reserved_cash_ratio=0.03, ) diff --git a/tests/test_sync_cloud_run_env_workflow.sh b/tests/test_sync_cloud_run_env_workflow.sh index a0c1840..0ea7fdb 100644 --- a/tests/test_sync_cloud_run_env_workflow.sh +++ b/tests/test_sync_cloud_run_env_workflow.sh @@ -21,6 +21,7 @@ grep -Fq 'requires_snapshot_artifacts=' "$workflow_file" 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 '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" @@ -32,7 +33,7 @@ grep -Fq 'GOOGLE_CLOUD_PROJECT: ${{ vars.GOOGLE_CLOUD_PROJECT }}' "$workflow_fil grep -Fq 'TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }}' "$workflow_file" grep -Fq 'SCHWAB_API_KEY_SECRET_NAME: ${{ vars.SCHWAB_API_KEY_SECRET_NAME }}' "$workflow_file" grep -Fq 'SCHWAB_APP_SECRET_SECRET_NAME: ${{ vars.SCHWAB_APP_SECRET_SECRET_NAME }}' "$workflow_file" -grep -Fq "STRATEGY_PROFILE: \${{ vars.STRATEGY_PROFILE || 'tqqq_growth_income' }}" "$workflow_file" +grep -Fq 'RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON }}' "$workflow_file" grep -Fq 'SCHWAB_DRY_RUN_ONLY: ${{ vars.SCHWAB_DRY_RUN_ONLY }}' "$workflow_file" grep -Fq 'SCHWAB_FEATURE_SNAPSHOT_PATH: ${{ vars.SCHWAB_FEATURE_SNAPSHOT_PATH }}' "$workflow_file" grep -Fq 'SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH }}' "$workflow_file" @@ -58,6 +59,7 @@ grep -Fq 'REQUIRES_SNAPSHOT_ARTIFACTS: ${{ steps.strategy_requirements.outputs.r 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 'RUNTIME_TARGET_JSON: ${{ steps.strategy_requirements.outputs.runtime_target_json }}' "$workflow_file" grep -Fq 'if [ "${REQUIRES_SNAPSHOT_ARTIFACTS:-}" = "true" ] && [ -z "${SCHWAB_FEATURE_SNAPSHOT_PATH:-}" ]; then' "$workflow_file" grep -Fq 'if [ "${REQUIRES_SNAPSHOT_MANIFEST_PATH:-}" = "true" ] && [ -z "${SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH:-}" ]; then' "$workflow_file" grep -Fq 'if [ "${REQUIRES_STRATEGY_CONFIG_PATH:-}" = "true" ] \' "$workflow_file" @@ -79,7 +81,7 @@ grep -Fq 'remove_env_vars+=("DUAL_DRIVE_UNLEVERED_SYMBOL")' "$workflow_file" grep -Fq 'env_pairs+=("GOOGLE_CLOUD_PROJECT=${GOOGLE_CLOUD_PROJECT}")' "$workflow_file" grep -Fq 'join_by_delimiter()' "$workflow_file" grep -Fq -- '--update-env-vars "^|^$(join_by_delimiter "|" "${env_pairs[@]}")"' "$workflow_file" -grep -Fq '"STRATEGY_PROFILE=${STRATEGY_PROFILE}"' "$workflow_file" +grep -Fq '"RUNTIME_TARGET_JSON=${RUNTIME_TARGET_JSON}"' "$workflow_file" grep -Fq 'secret_pairs+=("TELEGRAM_TOKEN=${TELEGRAM_TOKEN_SECRET_NAME}:latest")' "$workflow_file" grep -Fq 'secret_pairs+=("SCHWAB_API_KEY=${SCHWAB_API_KEY_SECRET_NAME}:latest")' "$workflow_file" grep -Fq 'secret_pairs+=("SCHWAB_APP_SECRET=${SCHWAB_APP_SECRET_SECRET_NAME}:latest")' "$workflow_file"