diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index c45f454..0c3a5a2 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -32,6 +32,7 @@ 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 }} + LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.LONGBRIDGE_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 }} @@ -300,6 +301,12 @@ jobs: remove_env_vars+=("LONGBRIDGE_STRATEGY_CONFIG_PATH") fi + if [ -n "${LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON:-}" ]; then + env_pairs+=("LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON=${LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON}") + else + remove_env_vars+=("LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON") + fi + if [ -n "${LONGBRIDGE_DRY_RUN_ONLY:-}" ]; then env_pairs+=("LONGBRIDGE_DRY_RUN_ONLY=${LONGBRIDGE_DRY_RUN_ONLY}") else @@ -362,6 +369,7 @@ 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 }} + LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON }} INCOME_THRESHOLD_USD: ${{ vars.INCOME_THRESHOLD_USD }} QQQI_INCOME_RATIO: ${{ vars.QQQI_INCOME_RATIO }} NOTIFY_LANG: ${{ vars.NOTIFY_LANG }} @@ -634,6 +642,12 @@ jobs: remove_env_vars+=("LONGBRIDGE_STRATEGY_CONFIG_PATH") fi + if [ -n "${LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON:-}" ]; then + env_pairs+=("LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON=${LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON}") + else + remove_env_vars+=("LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON") + fi + if [ -n "${LONGBRIDGE_DRY_RUN_ONLY:-}" ]; then env_pairs+=("LONGBRIDGE_DRY_RUN_ONLY=${LONGBRIDGE_DRY_RUN_ONLY}") else diff --git a/README.md b/README.md index f06f1d7..b365db5 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ Telegram notifications include structured execution and heartbeat messages, with | `LONGBRIDGE_DRY_RUN_ONLY` | No | Set to `true` to keep the selected deployment in dry-run mode. | | `LONGBRIDGE_FRACTIONAL_LIMIT_BUY_FALLBACK_TO_MARKET` | No | Set to `true` to convert fractional `limit buy` orders to `market buy` orders instead of skipping them. | | `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT` | No | Set to `true` to log raw LongBridge position quantity and available quantity for troubleshooting. | +| `LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON` | No | Optional LongBridge-side strategy plugin mount JSON. The plugin artifact controls mode; platform config must not set `mode`. | | `INCOME_THRESHOLD_USD` | No | Optional strategy override for the `tqqq_growth_income` income-layer threshold. Leave unset to use the strategy package default. | | `QQQI_INCOME_RATIO` | No | Optional strategy 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) | @@ -229,6 +230,7 @@ Telegram 通知包含结构化的调仓和心跳消息,支持中英文切换 | `ACCOUNT_REGION` | 否 | 平台化部署时的账户区域标记(如 `PAPER`、`HK`、`SG`;默认按 `ACCOUNT_PREFIX` / `DEFAULT` 推断) | | `LONGBRIDGE_DRY_RUN_ONLY` | 否 | 设为 `true` 时,该部署保持 dry-run。 | | `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT` | 否 | 设为 `true` 时输出 LongBridge 原始持仓数量和可卖数量,便于排查。 | +| `LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON` | 否 | 可选的 LongBridge 侧策略插件挂载 JSON。插件 artifact 自带模式;平台配置不要设置 `mode`。 | | `INCOME_THRESHOLD_USD` | 否 | 可选的 `tqqq_growth_income` 收入层启动阈值覆盖(策略 override)。不填时使用策略包默认值。 | | `QQQI_INCOME_RATIO` | 否 | 可选的 QQQI 收入层占比覆盖,0–1(策略 override)。 | | `NOTIFY_LANG` | 否 | 通知语言: `en`(英文,默认)或 `zh`(中文) | diff --git a/application/rebalance_service.py b/application/rebalance_service.py index 509c1ea..9c8fd9f 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -233,6 +233,7 @@ def fetch_replanned_state(): separator=config.separator, strategy_display_name=config.strategy_display_name, dry_run_only=config.dry_run_only, + extra_notification_lines=config.extra_notification_lines, ) ) else: @@ -245,5 +246,6 @@ def fetch_replanned_state(): separator=config.separator, strategy_display_name=config.strategy_display_name, dry_run_only=config.dry_run_only, + extra_notification_lines=config.extra_notification_lines, ) ) diff --git a/application/runtime_composer.py b/application/runtime_composer.py index 0536013..d2e1855 100644 --- a/application/runtime_composer.py +++ b/application/runtime_composer.py @@ -168,7 +168,7 @@ def build_rebalance_runtime(self) -> LongBridgeRebalanceRuntime: post_submit_order=notification_adapters.post_submit_order, ) - def build_rebalance_config(self) -> LongBridgeRebalanceConfig: + def build_rebalance_config(self, *, strategy_plugin_signals=()) -> LongBridgeRebalanceConfig: return LongBridgeRebalanceConfig( limit_sell_discount=self.limit_sell_discount, limit_buy_premium=self.limit_buy_premium, @@ -181,6 +181,24 @@ def build_rebalance_config(self) -> LongBridgeRebalanceConfig: post_sell_refresh_attempts=self.order_poll_max_attempts, post_sell_refresh_interval_sec=self.order_poll_interval_sec, sleeper=self.sleeper, + extra_notification_lines=getattr( + self.strategy_adapters, + "build_strategy_plugin_notification_lines", + lambda _signals: (), + )(strategy_plugin_signals), + ) + + def load_strategy_plugin_signals(self, raw_mounts): + return getattr(self.strategy_adapters, "load_strategy_plugin_signals", lambda _raw_mounts: ((), None))(raw_mounts) + + def attach_strategy_plugin_report(self, report, *, signals, error: str | None = None): + attach = getattr(self.strategy_adapters, "attach_strategy_plugin_report", None) + if attach is None: + return None + return attach( + report, + signals=signals, + error=error, ) diff --git a/application/runtime_dependencies.py b/application/runtime_dependencies.py index 57e5524..55a4b89 100644 --- a/application/runtime_dependencies.py +++ b/application/runtime_dependencies.py @@ -22,6 +22,7 @@ class LongBridgeRebalanceConfig: post_sell_refresh_attempts: int = 1 post_sell_refresh_interval_sec: float = 0.0 sleeper: Callable[[float], None] | None = None + extra_notification_lines: tuple[str, ...] = () @dataclass(frozen=True) diff --git a/application/runtime_strategy_adapters.py b/application/runtime_strategy_adapters.py index 26a508f..28bf0ed 100644 --- a/application/runtime_strategy_adapters.py +++ b/application/runtime_strategy_adapters.py @@ -20,6 +20,54 @@ class LongBridgeRuntimeStrategyAdapters: calculate_rotation_indicators_fn: Callable[..., Any] build_strategy_evaluation_inputs_fn: Callable[..., dict[str, Any]] map_strategy_decision_to_plan_fn: Callable[..., dict[str, Any]] + build_strategy_plugin_report_payload_fn: Callable[..., dict[str, Any]] | None = None + load_configured_strategy_plugin_signals_fn: Callable[..., Any] | None = None + parse_strategy_plugin_mounts_fn: Callable[..., Any] | None = None + + def load_strategy_plugin_signals(self, raw_mounts): + if not raw_mounts or self.parse_strategy_plugin_mounts_fn is None or self.load_configured_strategy_plugin_signals_fn is None: + return (), None + try: + mounts = self.parse_strategy_plugin_mounts_fn(raw_mounts) + if not mounts: + return (), None + return ( + self.load_configured_strategy_plugin_signals_fn( + mounts, + strategy_profile=self.strategy_profile, + ), + None, + ) + except Exception as exc: + return (), f"{type(exc).__name__}: {exc}" + + def attach_strategy_plugin_report(self, report, *, signals, error: str | None = None): + if signals and self.build_strategy_plugin_report_payload_fn is not None: + report.setdefault("summary", {}).update(self.build_strategy_plugin_report_payload_fn(signals)) + if error: + report.setdefault("diagnostics", {})["strategy_plugin_error"] = error + + def translate_strategy_plugin_value(self, category: str, raw_value: str | None) -> str: + value = str(raw_value or "").strip() or "unknown" + key = f"strategy_plugin_{category}_{value}" + translated = self.translator(key) + return translated if translated != key else value + + def build_strategy_plugin_notification_lines(self, signals) -> tuple[str, ...]: + lines = [] + for signal in signals: + route = signal.canonical_route or "unknown_route" + action = signal.suggested_action or "unknown_action" + lines.append( + self.translator( + "strategy_plugin_line", + plugin=self.translate_strategy_plugin_value("name", signal.plugin), + mode=self.translate_strategy_plugin_value("mode", signal.effective_mode), + route=self.translate_strategy_plugin_value("route", route), + action=self.translate_strategy_plugin_value("action", action), + ) + ) + return tuple(lines) def calculate_strategy_indicators(self, quote_context): available_inputs = set(self.available_inputs) @@ -99,6 +147,9 @@ def build_runtime_strategy_adapters( calculate_rotation_indicators_fn: Callable[..., Any], build_strategy_evaluation_inputs_fn: Callable[..., dict[str, Any]], map_strategy_decision_to_plan_fn: Callable[..., dict[str, Any]], + build_strategy_plugin_report_payload_fn: Callable[..., dict[str, Any]] | None = None, + load_configured_strategy_plugin_signals_fn: Callable[..., Any] | None = None, + parse_strategy_plugin_mounts_fn: Callable[..., Any] | None = None, ) -> LongBridgeRuntimeStrategyAdapters: return LongBridgeRuntimeStrategyAdapters( strategy_runtime=strategy_runtime, @@ -112,4 +163,7 @@ def build_runtime_strategy_adapters( calculate_rotation_indicators_fn=calculate_rotation_indicators_fn, build_strategy_evaluation_inputs_fn=build_strategy_evaluation_inputs_fn, map_strategy_decision_to_plan_fn=map_strategy_decision_to_plan_fn, + build_strategy_plugin_report_payload_fn=build_strategy_plugin_report_payload_fn, + load_configured_strategy_plugin_signals_fn=load_configured_strategy_plugin_signals_fn, + parse_strategy_plugin_mounts_fn=parse_strategy_plugin_mounts_fn, ) diff --git a/main.py b/main.py index 7e815ef..2564e67 100644 --- a/main.py +++ b/main.py @@ -24,6 +24,11 @@ finalize_runtime_report, persist_runtime_report, ) +from quant_platform_kit.common.strategy_plugins import ( + build_strategy_plugin_report_payload, + load_configured_strategy_plugin_signals, + parse_strategy_plugin_mounts, +) from quant_platform_kit.strategy_contracts import build_strategy_evaluation_inputs from runtime_logging import build_run_id, emit_runtime_log from quant_platform_kit.longbridge import ( @@ -133,6 +138,9 @@ def log_position_snapshot(message): calculate_rotation_indicators_fn=calculate_rotation_indicators, build_strategy_evaluation_inputs_fn=build_strategy_evaluation_inputs, map_strategy_decision_to_plan_fn=map_strategy_decision_to_plan, + build_strategy_plugin_report_payload_fn=build_strategy_plugin_report_payload, + load_configured_strategy_plugin_signals_fn=load_configured_strategy_plugin_signals, + parse_strategy_plugin_mounts_fn=parse_strategy_plugin_mounts, ) @@ -184,6 +192,14 @@ def run_strategy(): reporting_adapters = composer.build_reporting_adapters() log_context, report = reporting_adapters.start_run() notification_adapters = composer.build_notification_adapters() + strategy_plugin_signals, strategy_plugin_error = composer.load_strategy_plugin_signals( + getattr(RUNTIME_SETTINGS, "strategy_plugin_mounts_json", None) + ) + composer.attach_strategy_plugin_report( + report, + signals=strategy_plugin_signals, + error=strategy_plugin_error, + ) try: reporting_adapters.log_event( log_context, @@ -220,7 +236,7 @@ def run_strategy(): return run_rebalance_cycle( runtime=composer.build_rebalance_runtime(), - config=composer.build_rebalance_config(), + config=composer.build_rebalance_config(strategy_plugin_signals=strategy_plugin_signals), ) finalize_runtime_report(report, status="ok") reporting_adapters.log_event( diff --git a/notifications/renderers.py b/notifications/renderers.py index 9524e78..832bf93 100644 --- a/notifications/renderers.py +++ b/notifications/renderers.py @@ -171,6 +171,13 @@ def _append_strategy_line(lines, *, strategy_display_name, translator): lines.append(translator("strategy_label", name=name)) +def _append_extra_notification_lines(lines, extra_notification_lines) -> None: + for line in extra_notification_lines or (): + text = str(line or "").strip() + if text: + lines.append(text) + + def render_rebalance_notification( *, execution, @@ -181,12 +188,14 @@ def render_rebalance_notification( separator, strategy_display_name, dry_run_only, + extra_notification_lines=(), ) -> RenderedNotification: formatted_logs = "\n".join(f" - {log}" for log in [*logs, *skip_logs, *note_logs]) detailed_lines = [translator("rebalance_title")] _append_strategy_line(detailed_lines, strategy_display_name=strategy_display_name, translator=translator) if dry_run_only: detailed_lines.append(translator("dry_run_banner")) + _append_extra_notification_lines(detailed_lines, extra_notification_lines) _append_dashboard_lines(detailed_lines, execution=execution) _append_timing_lines(detailed_lines, execution=execution, translator=translator) _append_status_lines( @@ -201,6 +210,7 @@ def render_rebalance_notification( _append_strategy_line(compact_lines, strategy_display_name=strategy_display_name, translator=translator) if dry_run_only: compact_lines.append(translator("dry_run_banner")) + _append_extra_notification_lines(compact_lines, extra_notification_lines) _append_dashboard_lines(compact_lines, execution=execution) _append_timing_lines(compact_lines, execution=execution, translator=translator) _append_compact_status_lines( @@ -225,11 +235,13 @@ def render_heartbeat_notification( separator, strategy_display_name, dry_run_only, + extra_notification_lines=(), ) -> RenderedNotification: detailed_lines = [translator("heartbeat_title")] _append_strategy_line(detailed_lines, strategy_display_name=strategy_display_name, translator=translator) if dry_run_only: detailed_lines.append(translator("dry_run_banner")) + _append_extra_notification_lines(detailed_lines, extra_notification_lines) _append_dashboard_lines(detailed_lines, execution=execution) _append_timing_lines(detailed_lines, execution=execution, translator=translator) detailed_lines.append(separator) @@ -263,6 +275,7 @@ def render_heartbeat_notification( _append_strategy_line(compact_lines, strategy_display_name=strategy_display_name, translator=translator) if dry_run_only: compact_lines.append(translator("dry_run_banner")) + _append_extra_notification_lines(compact_lines, extra_notification_lines) _append_dashboard_lines(compact_lines, execution=execution) _append_timing_lines(compact_lines, execution=execution, translator=translator) _append_compact_status_lines( diff --git a/notifications/telegram.py b/notifications/telegram.py index 79b1e39..094d4fe 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -82,6 +82,7 @@ "signal_blend_gate_overlay_capped": "{trend_symbol} 仍在 {window} 日门槛线上方,但触发过热降档({reasons}),目标仓位 {allocation_text}", "blend_gate_reason_rsi_cap": "RSI 超阈值", "blend_gate_reason_bollinger_cap": "突破布林上轨", + "blend_gate_reason_volatility_delever": "{symbol} {window} 日年化波动率 {volatility} 高于 {threshold},SOXL 转向 {redirect_symbol}", "signal_hold": "趋势持有", "signal_entry": "入场信号", "signal_reduce": "减仓信号", @@ -95,6 +96,20 @@ "strategy_name_tech_communication_pullback_enhancement": "科技通信回调增强", "strategy_name_qqq_tech_enhancement": "科技通信回调增强", "strategy_name_mega_cap_leader_rotation_top50_balanced": "Mega Cap Top50 平衡龙头轮动", + "strategy_plugin_line": "🧩 插件:{plugin} | 模式:{mode} | 路由:{route} | 建议:{action}", + "strategy_plugin_name_crisis_response_shadow": "危机响应观察", + "strategy_plugin_mode_shadow": "影子观察", + "strategy_plugin_route_no_action": "不操作", + "strategy_plugin_route_true_crisis": "真危机", + "strategy_plugin_route_taco_fake_crisis": "TACO 假危机", + "strategy_plugin_route_unknown_route": "未知路由", + "strategy_plugin_action_no_action": "不操作", + "strategy_plugin_action_watch_only": "仅观察", + "strategy_plugin_action_small_taco": "小仓 TACO", + "strategy_plugin_action_defend": "防守", + "strategy_plugin_action_blocked": "已阻断", + "strategy_plugin_action_monitor": "监控", + "strategy_plugin_action_unknown_action": "未知建议", }, "en": { "rebalance_title": "🔔 【Trade Execution Report】", @@ -163,6 +178,7 @@ "signal_blend_gate_overlay_capped": "{trend_symbol} stays above the {window}d gate, but overlay cap ({reasons}) cuts exposure to {allocation_text}", "blend_gate_reason_rsi_cap": "RSI over threshold", "blend_gate_reason_bollinger_cap": "price above upper band", + "blend_gate_reason_volatility_delever": "{symbol} {window}d annualized volatility {volatility} is above {threshold}; redirect SOXL to {redirect_symbol}", "signal_hold": "Trend Hold", "signal_entry": "Entry Signal", "signal_reduce": "Reduce Signal", @@ -176,6 +192,20 @@ "strategy_name_tech_communication_pullback_enhancement": "Tech/Communication Pullback Enhancement", "strategy_name_qqq_tech_enhancement": "Tech/Communication Pullback Enhancement", "strategy_name_mega_cap_leader_rotation_top50_balanced": "Mega Cap Leader Rotation Top50 Balanced", + "strategy_plugin_line": "🧩 Plugin: {plugin} | mode: {mode} | route: {route} | action: {action}", + "strategy_plugin_name_crisis_response_shadow": "Crisis Response Shadow", + "strategy_plugin_mode_shadow": "shadow", + "strategy_plugin_route_no_action": "no action", + "strategy_plugin_route_true_crisis": "true crisis", + "strategy_plugin_route_taco_fake_crisis": "TACO fake crisis", + "strategy_plugin_route_unknown_route": "unknown route", + "strategy_plugin_action_no_action": "no action", + "strategy_plugin_action_watch_only": "watch only", + "strategy_plugin_action_small_taco": "small TACO", + "strategy_plugin_action_defend": "defend", + "strategy_plugin_action_blocked": "blocked", + "strategy_plugin_action_monitor": "monitor", + "strategy_plugin_action_unknown_action": "unknown action", }, } diff --git a/requirements.txt b/requirements.txt index 1fd3f71..67c1c0d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ flask gunicorn -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@573fc9e9917cf1f2c1acda9232c5a23a8a05d797 -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@53911cbe32f6932e759522e54aa38ca5350aa44e +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@8769362096227320bc05c791b5244d4b3e88db50 +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@ed55a6af0245323dbed82060e89be96d8f77f756 pandas requests pytz diff --git a/runtime_config_support.py b/runtime_config_support.py index 7a5b7c1..eb5c5f8 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -47,6 +47,7 @@ class PlatformRuntimeSettings: feature_snapshot_manifest_path: str | None = None strategy_config_path: str | None = None strategy_config_source: str | None = None + strategy_plugin_mounts_json: str | None = None runtime_target: RuntimeTarget | None = None @@ -125,6 +126,10 @@ def load_platform_runtime_settings( feature_snapshot_manifest_path=runtime_paths.feature_snapshot_manifest_path, strategy_config_path=runtime_paths.strategy_config_path, strategy_config_source=runtime_paths.strategy_config_source, + strategy_plugin_mounts_json=( + os.getenv("LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON") + or os.getenv("STRATEGY_PLUGIN_MOUNTS_JSON") + ), runtime_target=runtime_target, ) diff --git a/tests/test_notifications.py b/tests/test_notifications.py index dc7a08f..7a7bf3c 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -61,6 +61,27 @@ def test_build_translator_supports_chinese(self): ), "SOXX 仍在 140 日门槛线上方,但触发过热降档(RSI 超阈值 + 突破布林上轨),目标仓位 SOXX 15.0%", ) + self.assertEqual( + translate( + "blend_gate_reason_volatility_delever", + symbol="SOXX", + window=20, + volatility="55.0%", + threshold="50.0%", + redirect_symbol="SOXX", + ), + "SOXX 20 日年化波动率 55.0% 高于 50.0%,SOXL 转向 SOXX", + ) + self.assertEqual( + translate( + "strategy_plugin_line", + plugin=translate("strategy_plugin_name_crisis_response_shadow"), + mode=translate("strategy_plugin_mode_shadow"), + route=translate("strategy_plugin_route_no_action"), + action=translate("strategy_plugin_action_monitor"), + ), + "🧩 插件:危机响应观察 | 模式:影子观察 | 路由:不操作 | 建议:监控", + ) self.assertEqual( translate( "small_account_warning_note", diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 0cc867d..8b2277b 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -108,6 +108,7 @@ def test_load_platform_runtime_settings_uses_defaults_with_explicit_strategy_pro self.assertIsNone(settings.qqqi_income_ratio) self.assertIsNone(settings.feature_snapshot_path) self.assertIsNone(settings.strategy_config_path) + self.assertIsNone(settings.strategy_plugin_mounts_json) def test_load_platform_runtime_settings_prefers_runtime_target_json(self): with patch.dict( @@ -189,6 +190,21 @@ def test_debug_position_snapshot_is_loaded_from_env(self): self.assertTrue(settings.debug_position_snapshot) + def test_strategy_plugin_mounts_are_loaded_from_env(self): + mount_config = '{"strategy_plugins":[{"strategy":"soxl_soxx_trend_income","plugin":"crisis_response_shadow","signal_path":"gs://bucket/latest_signal.json"}]}' + with patch.dict( + os.environ, + { + "RUNTIME_TARGET_JSON": runtime_target_json(SAMPLE_STRATEGY_PROFILE), + "STRATEGY_PLUGIN_MOUNTS_JSON": '{"strategy_plugins":[{"plugin":"global"}]}', + "LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON": mount_config, + }, + clear=True, + ): + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + self.assertEqual(settings.strategy_plugin_mounts_json, mount_config) + def test_income_layer_overrides_are_loaded_from_env(self): with patch.dict( os.environ, diff --git a/tests/test_runtime_strategy_adapters.py b/tests/test_runtime_strategy_adapters.py index c754ef0..7f1343f 100644 --- a/tests/test_runtime_strategy_adapters.py +++ b/tests/test_runtime_strategy_adapters.py @@ -138,3 +138,57 @@ def fake_map_plan(decision, **kwargs): }, ) assert result == {"plan": True} + + +def test_runtime_strategy_adapters_loads_and_reports_plugin_signals(): + observed = {} + signal = SimpleNamespace( + plugin="crisis_response_shadow", + effective_mode="shadow", + canonical_route="no_action", + suggested_action="monitor", + ) + + def fake_parse(raw_mounts): + observed["raw_mounts"] = raw_mounts + return ("mount-1",) + + def fake_load(mounts, *, strategy_profile): + observed["load_call"] = (mounts, strategy_profile) + return (signal,) + + adapters = build_runtime_strategy_adapters( + strategy_runtime=SimpleNamespace(evaluate=lambda **_kwargs: None), + strategy_profile="soxl_soxx_trend_income", + strategy_runtime_config={}, + available_inputs=(), + benchmark_symbol="SOXX", + signal_text_fn=lambda icon: f"signal:{icon}", + translator=lambda key, **kwargs: { + "strategy_plugin_line": "plugin={plugin}|mode={mode}|route={route}|action={action}", + "strategy_plugin_name_crisis_response_shadow": "Crisis", + "strategy_plugin_mode_shadow": "shadow", + "strategy_plugin_route_no_action": "no action", + "strategy_plugin_action_monitor": "monitor", + }.get(key, key).format(**kwargs), + broker_adapters=SimpleNamespace(), + calculate_rotation_indicators_fn=lambda *_args, **_kwargs: {}, + build_strategy_evaluation_inputs_fn=lambda **_kwargs: {}, + map_strategy_decision_to_plan_fn=lambda *_args, **_kwargs: {}, + build_strategy_plugin_report_payload_fn=lambda signals: {"strategy_plugins": list(signals)}, + load_configured_strategy_plugin_signals_fn=fake_load, + parse_strategy_plugin_mounts_fn=fake_parse, + ) + + signals, error = adapters.load_strategy_plugin_signals('{"strategy_plugins":[]}') + report = {} + adapters.attach_strategy_plugin_report(report, signals=signals, error=error) + + assert error is None + assert signals == (signal,) + assert observed["raw_mounts"] == '{"strategy_plugins":[]}' + assert observed["load_call"] == (("mount-1",), "soxl_soxx_trend_income") + assert report["summary"]["strategy_plugins"] == [signal] + assert adapters.build_strategy_plugin_notification_lines(signals) == ( + "plugin=Crisis|mode=shadow|route=no action|action=monitor", + ) diff --git a/tests/test_sync_cloud_run_env_workflow.sh b/tests/test_sync_cloud_run_env_workflow.sh index 9e602fb..8c30532 100644 --- a/tests/test_sync_cloud_run_env_workflow.sh +++ b/tests/test_sync_cloud_run_env_workflow.sh @@ -38,6 +38,7 @@ 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 'LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON }}' "$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" @@ -77,6 +78,7 @@ grep -Fq 'LONGPORT_SECRET_NAME=${LONGPORT_SECRET_NAME}' "$workflow_file" grep -Fq 'LONGBRIDGE_FEATURE_SNAPSHOT_PATH=${LONGBRIDGE_FEATURE_SNAPSHOT_PATH}' "$workflow_file" 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_STRATEGY_PLUGIN_MOUNTS_JSON=${LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON}' "$workflow_file" grep -Fq 'LONGBRIDGE_DRY_RUN_ONLY=${LONGBRIDGE_DRY_RUN_ONLY}' "$workflow_file" grep -Fq 'LONGBRIDGE_FRACTIONAL_LIMIT_BUY_FALLBACK_TO_MARKET=${LONGBRIDGE_FRACTIONAL_LIMIT_BUY_FALLBACK_TO_MARKET}' "$workflow_file" grep -Fq 'INCOME_THRESHOLD_USD=${INCOME_THRESHOLD_USD}' "$workflow_file"