diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index 9157780..e85ff69 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -32,6 +32,8 @@ jobs: LONGBRIDGE_FEATURE_SNAPSHOT_PATH: ${{ vars.LONGBRIDGE_FEATURE_SNAPSHOT_PATH }} LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH }} LONGBRIDGE_STRATEGY_CONFIG_PATH: ${{ vars.LONGBRIDGE_STRATEGY_CONFIG_PATH }} + INCOME_THRESHOLD_USD: ${{ vars.INCOME_THRESHOLD_USD }} + QQQI_INCOME_RATIO: ${{ vars.QQQI_INCOME_RATIO }} NOTIFY_LANG: ${{ vars.NOTIFY_LANG }} EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }} LONGBRIDGE_DRY_RUN_ONLY: ${{ vars.LONGBRIDGE_DRY_RUN_ONLY }} @@ -289,6 +291,18 @@ jobs: remove_env_vars+=("LONGBRIDGE_DRY_RUN_ONLY") fi + if [ -n "${INCOME_THRESHOLD_USD:-}" ]; then + env_pairs+=("INCOME_THRESHOLD_USD=${INCOME_THRESHOLD_USD}") + else + remove_env_vars+=("INCOME_THRESHOLD_USD") + fi + + if [ -n "${QQQI_INCOME_RATIO:-}" ]; then + env_pairs+=("QQQI_INCOME_RATIO=${QQQI_INCOME_RATIO}") + else + remove_env_vars+=("QQQI_INCOME_RATIO") + fi + gcloud_args=( run services update "${CLOUD_RUN_SERVICE}" --region "${CLOUD_RUN_REGION}" @@ -328,6 +342,8 @@ jobs: LONGBRIDGE_FEATURE_SNAPSHOT_PATH: ${{ vars.LONGBRIDGE_FEATURE_SNAPSHOT_PATH }} LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH }} LONGBRIDGE_STRATEGY_CONFIG_PATH: ${{ vars.LONGBRIDGE_STRATEGY_CONFIG_PATH }} + INCOME_THRESHOLD_USD: ${{ vars.INCOME_THRESHOLD_USD }} + QQQI_INCOME_RATIO: ${{ vars.QQQI_INCOME_RATIO }} NOTIFY_LANG: ${{ vars.NOTIFY_LANG }} EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }} LONGBRIDGE_DRY_RUN_ONLY: ${{ vars.LONGBRIDGE_DRY_RUN_ONLY }} @@ -585,6 +601,18 @@ jobs: remove_env_vars+=("LONGBRIDGE_DRY_RUN_ONLY") fi + if [ -n "${INCOME_THRESHOLD_USD:-}" ]; then + env_pairs+=("INCOME_THRESHOLD_USD=${INCOME_THRESHOLD_USD}") + else + remove_env_vars+=("INCOME_THRESHOLD_USD") + fi + + if [ -n "${QQQI_INCOME_RATIO:-}" ]; then + env_pairs+=("QQQI_INCOME_RATIO=${QQQI_INCOME_RATIO}") + else + remove_env_vars+=("QQQI_INCOME_RATIO") + fi + gcloud_args=( run services update "${CLOUD_RUN_SERVICE}" --region "${CLOUD_RUN_REGION}" diff --git a/README.md b/README.md index dfc5e0f..c3042e4 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ Telegram notifications include structured execution and heartbeat messages, with | `STRATEGY_PROFILE` | No | Strategy profile selector (default: `soxl_soxx_trend_income`; enabled values include `dynamic_mega_leveraged_pullback`, `global_etf_rotation`, `mega_cap_leader_rotation_dynamic_top20`, `russell_1000_multi_factor_defensive`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, and `tqqq_growth_income`) | | `ACCOUNT_REGION` | No | Account region marker for platform-style deployment (e.g. `HK`, `SG`; defaults to `ACCOUNT_PREFIX` / `DEFAULT`) | | `LONGBRIDGE_DRY_RUN_ONLY` | No | Set to `true` to keep the selected deployment in dry-run mode. | +| `INCOME_THRESHOLD_USD` | No | Optional override for the `tqqq_growth_income` income-layer threshold. Leave unset to use the strategy package default. | +| `QQQI_INCOME_RATIO` | No | Optional override for QQQI's share of the `tqqq_growth_income` income layer, 0–1. | | `NOTIFY_LANG` | No | Notification language: `en` (English, default) or `zh` (Chinese) | | `GOOGLE_CLOUD_PROJECT` | No | GCP project ID (defaults to ADC project when unset) | @@ -108,12 +110,12 @@ Recommended setup: - Optional fallback only: `TELEGRAM_TOKEN` - **GitHub Environment: `longbridge-hk`** - Variables: `CLOUD_RUN_REGION`, `CLOUD_RUN_SERVICE`, `ACCOUNT_PREFIX`, `ACCOUNT_REGION`, `STRATEGY_PROFILE`, `LONGPORT_SECRET_NAME`, `LONGPORT_APP_KEY_SECRET_NAME`, `LONGPORT_APP_SECRET_SECRET_NAME` - - Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_DRY_RUN_ONLY` + - Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_DRY_RUN_ONLY`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO` - Current live example: `STRATEGY_PROFILE=tech_communication_pullback_enhancement` - Recommended secret-name values: `longport-app-key-hk`, `longport-app-secret-hk` - **GitHub Environment: `longbridge-sg`** - Variables: `CLOUD_RUN_REGION`, `CLOUD_RUN_SERVICE`, `ACCOUNT_PREFIX`, `ACCOUNT_REGION`, `STRATEGY_PROFILE`, `LONGPORT_SECRET_NAME`, `LONGPORT_APP_KEY_SECRET_NAME`, `LONGPORT_APP_SECRET_SECRET_NAME` - - Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_DRY_RUN_ONLY` + - Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_DRY_RUN_ONLY`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO` - Current live example: `STRATEGY_PROFILE=tqqq_growth_income` - Recommended secret-name values: `longport-app-key-sg`, `longport-app-secret-sg` @@ -209,6 +211,8 @@ Telegram 通知包含结构化的调仓和心跳消息,支持中英文切换 | `STRATEGY_PROFILE` | 否 | 策略档位选择(默认: `soxl_soxx_trend_income`;已启用值还包括 `dynamic_mega_leveraged_pullback`、`global_etf_rotation`、`mega_cap_leader_rotation_dynamic_top20`、`russell_1000_multi_factor_defensive`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement` 和 `tqqq_growth_income`) | | `ACCOUNT_REGION` | 否 | 平台化部署时的账户区域标记(如 `HK`、`SG`;默认按 `ACCOUNT_PREFIX` / `DEFAULT` 推断) | | `LONGBRIDGE_DRY_RUN_ONLY` | 否 | 设为 `true` 时,该部署保持 dry-run。 | +| `INCOME_THRESHOLD_USD` | 否 | 可选的 `tqqq_growth_income` 收入层启动阈值覆盖。不填时使用策略包默认值。 | +| `QQQI_INCOME_RATIO` | 否 | 可选的 QQQI 收入层占比覆盖,0–1。 | | `NOTIFY_LANG` | 否 | 通知语言: `en`(英文,默认)或 `zh`(中文) | | `GOOGLE_CLOUD_PROJECT` | 否 | GCP 项目 ID(未设置时使用 ADC 默认项目) | @@ -251,12 +255,12 @@ Secret Manager 中需存在 `LONGPORT_SECRET_NAME` 指定的密钥(默认: `lo - 仅保留为 fallback:`TELEGRAM_TOKEN` - **GitHub Environment: `longbridge-hk`** - Variables: `CLOUD_RUN_REGION`、`CLOUD_RUN_SERVICE`、`ACCOUNT_PREFIX`、`ACCOUNT_REGION`、`STRATEGY_PROFILE`、`LONGPORT_SECRET_NAME`、`LONGPORT_APP_KEY_SECRET_NAME`、`LONGPORT_APP_SECRET_SECRET_NAME` - - 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_DRY_RUN_ONLY` + - 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_DRY_RUN_ONLY`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO` - 当前线上示例:`STRATEGY_PROFILE=tech_communication_pullback_enhancement` - 建议的 secret-name 值:`longport-app-key-hk`、`longport-app-secret-hk` - **GitHub Environment: `longbridge-sg`** - Variables: `CLOUD_RUN_REGION`、`CLOUD_RUN_SERVICE`、`ACCOUNT_PREFIX`、`ACCOUNT_REGION`、`STRATEGY_PROFILE`、`LONGPORT_SECRET_NAME`、`LONGPORT_APP_KEY_SECRET_NAME`、`LONGPORT_APP_SECRET_SECRET_NAME` - - 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_DRY_RUN_ONLY` + - 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_DRY_RUN_ONLY`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO` - 当前线上示例:`STRATEGY_PROFILE=tqqq_growth_income` - 建议的 secret-name 值:`longport-app-key-sg`、`longport-app-secret-sg` diff --git a/runtime_config_support.py b/runtime_config_support.py index f0af7be..b5417e2 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -32,6 +32,8 @@ class PlatformRuntimeSettings: tg_token: str | None tg_chat_id: str | None dry_run_only: bool + income_threshold_usd: float | None = None + qqqi_income_ratio: float | None = None feature_snapshot_path: str | None = None feature_snapshot_manifest_path: str | None = None strategy_config_path: str | None = None @@ -109,6 +111,8 @@ def load_platform_runtime_settings( tg_token=os.getenv("TELEGRAM_TOKEN"), tg_chat_id=os.getenv("GLOBAL_TELEGRAM_CHAT_ID"), dry_run_only=_resolve_bool_env("LONGBRIDGE_DRY_RUN_ONLY"), + income_threshold_usd=_optional_float_env("INCOME_THRESHOLD_USD"), + qqqi_income_ratio=_qqqi_income_ratio_env(), feature_snapshot_path=_first_non_empty( os.getenv("LONGBRIDGE_FEATURE_SNAPSHOT_PATH"), os.getenv("FEATURE_SNAPSHOT_PATH"), @@ -159,6 +163,20 @@ def _resolve_bool_env(name: str) -> bool: return str(raw_value).strip().lower() in {"1", "true", "yes", "on"} +def _optional_float_env(name: str) -> float | None: + raw_value = os.getenv(name) + if raw_value is None or raw_value.strip() == "": + return None + return float(raw_value) + + +def _qqqi_income_ratio_env() -> float | None: + value = _optional_float_env("QQQI_INCOME_RATIO") + if value is not None and not (0.0 <= value <= 1.0): + raise ValueError(f"QQQI_INCOME_RATIO must be in [0,1], got {value}") + return value + + def _first_non_empty(*values: str | None) -> str | None: for value in values: text = str(value or "").strip() diff --git a/strategy_runtime.py b/strategy_runtime.py index fda6db9..f1b7aed 100644 --- a/strategy_runtime.py +++ b/strategy_runtime.py @@ -278,10 +278,21 @@ def _default_runtime_settings(profile: str, display_name: str) -> PlatformRuntim ) +def _build_runtime_overrides(profile: str, runtime_settings: PlatformRuntimeSettings) -> dict[str, Any]: + overrides: dict[str, Any] = {} + if profile == "tqqq_growth_income": + if runtime_settings.income_threshold_usd is not None: + overrides["income_threshold_usd"] = runtime_settings.income_threshold_usd + if runtime_settings.qqqi_income_ratio is not None: + overrides["qqqi_income_ratio"] = runtime_settings.qqqi_income_ratio + return overrides + + def load_strategy_runtime( raw_profile: str | None, *, runtime_settings: PlatformRuntimeSettings | None = None, + runtime_overrides: Mapping[str, Any] | None = None, logger: Callable[[str], None] = print, ) -> LoadedStrategyRuntime: entrypoint = load_strategy_entrypoint_for_profile(raw_profile) @@ -290,19 +301,24 @@ def load_strategy_runtime( entrypoint.manifest.profile, entrypoint.manifest.display_name, ) + overrides = _build_runtime_overrides(entrypoint.manifest.profile, resolved_runtime_settings) + overrides.update(runtime_overrides or {}) runtime = LoadedStrategyRuntime( entrypoint=entrypoint, runtime_adapter=runtime_adapter, runtime_settings=resolved_runtime_settings, + runtime_overrides=overrides, logger=logger, ) runtime_config = runtime.load_runtime_parameters() merged_runtime_config = dict(entrypoint.manifest.default_config) merged_runtime_config.update(runtime_config) + merged_runtime_config.update(overrides) return LoadedStrategyRuntime( entrypoint=entrypoint, runtime_adapter=runtime_adapter, runtime_settings=resolved_runtime_settings, + runtime_overrides=overrides, runtime_config=runtime_config, merged_runtime_config=merged_runtime_config, logger=logger, diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index b8c03b7..ff2bfde 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -50,6 +50,8 @@ def test_load_platform_runtime_settings_uses_defaults(self): self.assertIsNone(settings.tg_token) self.assertIsNone(settings.tg_chat_id) self.assertFalse(settings.dry_run_only) + self.assertIsNone(settings.income_threshold_usd) + self.assertIsNone(settings.qqqi_income_ratio) self.assertIsNone(settings.feature_snapshot_path) self.assertIsNone(settings.strategy_config_path) @@ -91,6 +93,31 @@ def test_dry_run_only_is_loaded_from_env(self): self.assertTrue(settings.dry_run_only) + def test_income_layer_overrides_are_loaded_from_env(self): + with patch.dict( + os.environ, + { + "STRATEGY_PROFILE": "tqqq_growth_income", + "INCOME_THRESHOLD_USD": "100000", + "QQQI_INCOME_RATIO": "0.5", + }, + clear=True, + ): + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + self.assertEqual(settings.strategy_profile, "tqqq_growth_income") + self.assertEqual(settings.income_threshold_usd, 100000.0) + self.assertEqual(settings.qqqi_income_ratio, 0.5) + + def test_rejects_invalid_qqqi_income_ratio(self): + with patch.dict( + os.environ, + {"STRATEGY_PROFILE": "tqqq_growth_income", "QQQI_INCOME_RATIO": "1.5"}, + clear=True, + ): + with self.assertRaisesRegex(ValueError, "QQQI_INCOME_RATIO"): + load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + def test_rejects_human_readable_alias(self): with patch.dict(os.environ, {"STRATEGY_PROFILE": "semiconductor_trend_income"}, clear=True): with self.assertRaises(ValueError): diff --git a/tests/test_strategy_runtime.py b/tests/test_strategy_runtime.py index 25e778c..84841d0 100644 --- a/tests/test_strategy_runtime.py +++ b/tests/test_strategy_runtime.py @@ -12,6 +12,26 @@ from runtime_config_support import PlatformRuntimeSettings +class _TqqqEntrypoint: + manifest = StrategyManifest( + profile="tqqq_growth_income", + domain="us_equity", + display_name="TQQQ Growth Income", + description="test entrypoint", + required_inputs=frozenset({"benchmark_history", "portfolio_snapshot"}), + default_config={ + "benchmark_symbol": "QQQ", + "managed_symbols": ("TQQQ", "QQQ", "BOXX", "SPYI", "QQQI"), + "income_threshold_usd": 1_000_000_000.0, + "qqqi_income_ratio": 0.5, + }, + ) + + def evaluate(self, ctx): + self.ctx = ctx + return StrategyDecision(diagnostics={"signal_description": "tqqq"}) + + class _SemiconductorEntrypoint: def __init__(self): self.manifest = StrategyManifest( @@ -73,7 +93,13 @@ def evaluate(self, ctx): return StrategyDecision(diagnostics={"signal_description": "leveraged pullback"}) -def _build_runtime_settings(profile: str, *, feature_snapshot_path: str | None = None) -> PlatformRuntimeSettings: +def _build_runtime_settings( + profile: str, + *, + feature_snapshot_path: str | None = None, + income_threshold_usd: float | None = None, + qqqi_income_ratio: float | None = None, +) -> PlatformRuntimeSettings: return PlatformRuntimeSettings( project_id=None, secret_name="longport_token_hk", @@ -88,6 +114,8 @@ def _build_runtime_settings(profile: str, *, feature_snapshot_path: str | None = tg_token=None, tg_chat_id=None, dry_run_only=False, + income_threshold_usd=income_threshold_usd, + qqqi_income_ratio=qqqi_income_ratio, feature_snapshot_path=feature_snapshot_path, feature_snapshot_manifest_path=None, strategy_config_path=None, @@ -185,6 +213,29 @@ def test_load_strategy_runtime_uses_entrypoint_default_config(self): self.assertIs(runtime.entrypoint, entrypoint) self.assertEqual(runtime.managed_symbols, ("SOXL", "SOXX", "BOXX", "QQQI", "SPYI")) + def test_load_strategy_runtime_applies_tqqq_income_overrides_from_settings(self): + entrypoint = _TqqqEntrypoint() + + with patch.object(strategy_runtime_module, "load_strategy_entrypoint_for_profile", return_value=entrypoint): + with patch.object( + strategy_runtime_module, + "load_strategy_runtime_adapter_for_profile", + return_value=StrategyRuntimeAdapter(portfolio_input_name="portfolio_snapshot"), + ): + runtime = strategy_runtime_module.load_strategy_runtime( + "tqqq_growth_income", + runtime_settings=_build_runtime_settings( + "tqqq_growth_income", + income_threshold_usd=100000.0, + qqqi_income_ratio=0.5, + ), + ) + + self.assertEqual(runtime.runtime_overrides["income_threshold_usd"], 100000.0) + self.assertEqual(runtime.runtime_overrides["qqqi_income_ratio"], 0.5) + self.assertEqual(runtime.merged_runtime_config["income_threshold_usd"], 100000.0) + self.assertEqual(runtime.merged_runtime_config["qqqi_income_ratio"], 0.5) + def test_feature_snapshot_runtime_loads_snapshot_into_context(self): entrypoint = _TechEntrypoint() runtime = strategy_runtime_module.LoadedStrategyRuntime( diff --git a/tests/test_sync_cloud_run_env_workflow.sh b/tests/test_sync_cloud_run_env_workflow.sh index 4ba69f7..5826cb2 100644 --- a/tests/test_sync_cloud_run_env_workflow.sh +++ b/tests/test_sync_cloud_run_env_workflow.sh @@ -38,6 +38,8 @@ grep -Fq 'LONGPORT_SECRET_NAME: ${{ vars.LONGPORT_SECRET_NAME }}' "$workflow_fil grep -Fq 'LONGBRIDGE_FEATURE_SNAPSHOT_PATH: ${{ vars.LONGBRIDGE_FEATURE_SNAPSHOT_PATH }}' "$workflow_file" grep -Fq 'LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH }}' "$workflow_file" grep -Fq 'LONGBRIDGE_STRATEGY_CONFIG_PATH: ${{ vars.LONGBRIDGE_STRATEGY_CONFIG_PATH }}' "$workflow_file" +grep -Fq 'INCOME_THRESHOLD_USD: ${{ vars.INCOME_THRESHOLD_USD }}' "$workflow_file" +grep -Fq 'QQQI_INCOME_RATIO: ${{ vars.QQQI_INCOME_RATIO }}' "$workflow_file" grep -Fq 'LONGBRIDGE_DRY_RUN_ONLY: ${{ vars.LONGBRIDGE_DRY_RUN_ONLY }}' "$workflow_file" grep -Fq "STRATEGY_PROFILE: \${{ vars.STRATEGY_PROFILE || 'soxl_soxx_trend_income' }}" "$workflow_file" grep -Fq "ACCOUNT_REGION: \${{ vars.ACCOUNT_REGION || 'HK' }}" "$workflow_file" @@ -73,6 +75,8 @@ grep -Fq 'LONGBRIDGE_FEATURE_SNAPSHOT_PATH=${LONGBRIDGE_FEATURE_SNAPSHOT_PATH}' grep -Fq 'LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH=${LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH}' "$workflow_file" grep -Fq 'LONGBRIDGE_STRATEGY_CONFIG_PATH=${LONGBRIDGE_STRATEGY_CONFIG_PATH}' "$workflow_file" grep -Fq 'LONGBRIDGE_DRY_RUN_ONLY=${LONGBRIDGE_DRY_RUN_ONLY}' "$workflow_file" +grep -Fq 'INCOME_THRESHOLD_USD=${INCOME_THRESHOLD_USD}' "$workflow_file" +grep -Fq 'QQQI_INCOME_RATIO=${QQQI_INCOME_RATIO}' "$workflow_file" grep -Fq 'STRATEGY_PROFILE=${STRATEGY_PROFILE}' "$workflow_file" grep -Fq 'ACCOUNT_REGION=${ACCOUNT_REGION}' "$workflow_file" grep -Fq '"SERVICE_NAME"' "$workflow_file"