diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index ddb9959..e291aae 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -27,6 +27,7 @@ jobs: IBKR_FEATURE_SNAPSHOT_PATH: ${{ vars.IBKR_FEATURE_SNAPSHOT_PATH }} IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH }} IBKR_STRATEGY_CONFIG_PATH: ${{ vars.IBKR_STRATEGY_CONFIG_PATH }} + IBKR_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.IBKR_STRATEGY_PLUGIN_MOUNTS_JSON }} IBKR_RECONCILIATION_OUTPUT_PATH: ${{ vars.IBKR_RECONCILIATION_OUTPUT_PATH }} IBKR_DRY_RUN_ONLY: ${{ vars.IBKR_DRY_RUN_ONLY }} # Strategy-owned defaults continue to come from UsEquityStrategies; this workflow only syncs platform/runtime inputs. @@ -295,6 +296,12 @@ jobs: remove_env_vars+=("IBKR_STRATEGY_CONFIG_PATH") fi + if [ -n "${IBKR_STRATEGY_PLUGIN_MOUNTS_JSON:-}" ]; then + env_pairs+=("IBKR_STRATEGY_PLUGIN_MOUNTS_JSON=${IBKR_STRATEGY_PLUGIN_MOUNTS_JSON}") + else + remove_env_vars+=("IBKR_STRATEGY_PLUGIN_MOUNTS_JSON") + fi + if [ -n "${IBKR_RECONCILIATION_OUTPUT_PATH:-}" ]; then env_pairs+=("IBKR_RECONCILIATION_OUTPUT_PATH=${IBKR_RECONCILIATION_OUTPUT_PATH}") else diff --git a/README.md b/README.md index 40af098..7f7b2b6 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ For IBKR, keep `paper` as a single account-group entry. If you later add live ac | `STRATEGY_PROFILE` | Yes | Strategy profile selector. Supported `us_equity` values: `global_etf_rotation`, `russell_1000_multi_factor_defensive`, `tqqq_growth_income`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, `mega_cap_leader_rotation_top50_balanced` | | `ACCOUNT_GROUP` | Yes | Account-group selector. Set explicitly for each deployment. | | `IBKR_FEATURE_SNAPSHOT_PATH` | Conditionally required | Required for snapshot-backed profiles such as `russell_1000_multi_factor_defensive`, `tech_communication_pullback_enhancement`, and `mega_cap_leader_rotation_top50_balanced`. Path to the latest feature snapshot file (`.csv`, `.json`, `.jsonl`, `.parquet`). | +| `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` | No | Optional IBKR-side strategy plugin mount JSON. The plugin artifact controls mode; platform config must not set `mode`. | | `IBKR_FRACTIONAL_SHARES_ENABLED` | No | Defaults to `false`; set `true` only after verifying fractional order support for this account/API path. | | `IBKR_ORDER_QUANTITY_STEP` | No | Explicit order quantity step override; e.g. `1` for whole shares or `0.0001` for fractional sizing. Takes precedence over `IBKR_FRACTIONAL_SHARES_ENABLED`. | | `IBKR_MIN_ORDER_NOTIONAL_USD` | No | Minimum buy notional for fractional sizing; defaults to `50.0`. | @@ -221,6 +222,7 @@ Recommended setup: - `STRATEGY_PROFILE` (set explicitly to one enabled profile, such as `soxl_soxx_trend_income`) - `ACCOUNT_GROUP` (recommended: `paper`) - `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` + - Optional: `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` - `GLOBAL_TELEGRAM_CHAT_ID` - `NOTIFY_LANG` - **Repository Secrets** @@ -340,6 +342,7 @@ IBKR 账户 | `STRATEGY_PROFILE` | 是 | 策略档位选择。当前可用的 `us_equity` 值:`global_etf_rotation`、`russell_1000_multi_factor_defensive`、`tqqq_growth_income`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_top50_balanced` | | `ACCOUNT_GROUP` | 是 | 账号组选择器,每个部署都要显式设置。 | | `IBKR_FEATURE_SNAPSHOT_PATH` | 条件必填 | `russell_1000_multi_factor_defensive`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_top50_balanced` 等快照策略需要。指向最新特征快照文件(`.csv`、`.json`、`.jsonl`、`.parquet`)。 | +| `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` | 否 | 可选的 IBKR 侧策略插件挂载 JSON。插件 artifact 自带模式;平台配置不要设置 `mode`。 | | `IBKR_FRACTIONAL_SHARES_ENABLED` | 否 | 默认 `false`;只有确认当前账户/API 路径支持碎股单后再设为 `true`。 | | `IBKR_ORDER_QUANTITY_STEP` | 否 | 显式覆盖下单数量步进;如 `1` 表示整数股,`0.0001` 表示碎股数量步进。优先级高于 `IBKR_FRACTIONAL_SHARES_ENABLED`。 | | `IBKR_MIN_ORDER_NOTIONAL_USD` | 否 | 碎股买入的最小名义金额;默认 `50.0`。 | @@ -421,6 +424,7 @@ IB_GATEWAY_IP_MODE=internal - `STRATEGY_PROFILE`(显式设置为任一已启用 profile,例如 `soxl_soxx_trend_income`) - `ACCOUNT_GROUP`(建议设为 `paper`) - `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` + - 可选:`IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` - `GLOBAL_TELEGRAM_CHAT_ID` - `NOTIFY_LANG` - **仓库级 Secrets** diff --git a/application/rebalance_service.py b/application/rebalance_service.py index 65bad42..2581cc4 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -643,6 +643,7 @@ def run_strategy_core( translator=config.translator, separator=config.separator, strategy_display_name=config.strategy_display_name, + extra_notification_lines=config.extra_notification_lines, ) ) return StrategyCycleResult( @@ -703,6 +704,7 @@ def run_strategy_core( translator=config.translator, separator=config.separator, strategy_display_name=config.strategy_display_name, + extra_notification_lines=config.extra_notification_lines, ) ) return StrategyCycleResult( diff --git a/application/runtime_composer.py b/application/runtime_composer.py index 81eb52a..43e4086 100644 --- a/application/runtime_composer.py +++ b/application/runtime_composer.py @@ -136,12 +136,13 @@ def build_rebalance_runtime(self): notifications=notification_adapters.notification_port, ) - def build_rebalance_config(self): + def build_rebalance_config(self, *, extra_notification_lines=()): return IBKRRebalanceConfig( translator=self.translator, separator=self.separator, strategy_display_name=self.strategy_display_name_localized, reconciliation_output_path=self.reconciliation_output_path, + extra_notification_lines=tuple(extra_notification_lines or ()), ) diff --git a/application/runtime_dependencies.py b/application/runtime_dependencies.py index bb90ff5..5deeb56 100644 --- a/application/runtime_dependencies.py +++ b/application/runtime_dependencies.py @@ -16,6 +16,7 @@ class IBKRRebalanceConfig: separator: str strategy_display_name: str | None = None reconciliation_output_path: str | Path | 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 cf42805..09b74e6 100644 --- a/application/runtime_strategy_adapters.py +++ b/application/runtime_strategy_adapters.py @@ -18,6 +18,54 @@ class IBKRRuntimeStrategyAdapters: fetch_historical_price_series_fn: Any fetch_historical_price_candles_fn: Any map_strategy_decision_fn: Any + build_strategy_plugin_report_payload_fn: Any = None + load_configured_strategy_plugin_signals_fn: Any = None + parse_strategy_plugin_mounts_fn: Any = 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 get_historical_close(self, ib, symbol, duration="2 Y", bar_size="1 day"): series = self.fetch_historical_price_series_fn( @@ -68,6 +116,9 @@ def build_runtime_strategy_adapters( fetch_historical_price_series_fn, fetch_historical_price_candles_fn, map_strategy_decision_fn, + build_strategy_plugin_report_payload_fn=None, + load_configured_strategy_plugin_signals_fn=None, + parse_strategy_plugin_mounts_fn=None, ) -> IBKRRuntimeStrategyAdapters: return IBKRRuntimeStrategyAdapters( strategy_runtime=strategy_runtime, @@ -78,4 +129,7 @@ def build_runtime_strategy_adapters( fetch_historical_price_series_fn=fetch_historical_price_series_fn, fetch_historical_price_candles_fn=fetch_historical_price_candles_fn, map_strategy_decision_fn=map_strategy_decision_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 51104c3..742b98d 100644 --- a/main.py +++ b/main.py @@ -32,6 +32,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.ibkr import ( connect_ib as ibkr_connect_ib, ensure_event_loop as ibkr_ensure_event_loop, @@ -287,6 +292,9 @@ def build_strategy_adapters(): fetch_historical_price_series_fn=fetch_historical_price_series, fetch_historical_price_candles_fn=fetch_historical_price_candles, map_strategy_decision_fn=map_strategy_decision, + 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, ) @@ -442,6 +450,20 @@ def compute_signals(ib, current_holdings): return build_strategy_adapters().compute_signals(ib, current_holdings) +def load_strategy_plugin_signals(): + return build_strategy_adapters().load_strategy_plugin_signals( + getattr(RUNTIME_SETTINGS, "strategy_plugin_mounts_json", None) + ) + + +def attach_strategy_plugin_report(report, *, signals, error: str | None = None): + build_strategy_adapters().attach_strategy_plugin_report(report, signals=signals, error=error) + + +def build_strategy_plugin_notification_lines(signals) -> tuple[str, ...]: + return build_strategy_adapters().build_strategy_plugin_notification_lines(signals) + + def get_current_portfolio(ib): return build_broker_adapters().get_current_portfolio(ib) @@ -494,13 +516,15 @@ def run_paper_liquidation_cycle(): ) -def run_strategy_core(): +def run_strategy_core(*, strategy_plugin_signals=()): if PAPER_LIQUIDATE_ONLY: return run_paper_liquidation_cycle() composer = build_composer() return run_rebalance_cycle( runtime=composer.build_rebalance_runtime(), - config=composer.build_rebalance_config(), + config=composer.build_rebalance_config( + extra_notification_lines=build_strategy_plugin_notification_lines(strategy_plugin_signals) + ), ) @@ -511,6 +535,12 @@ def handle_request(): log_context = build_request_log_context() report = build_execution_report(log_context) + strategy_plugin_signals, strategy_plugin_error = load_strategy_plugin_signals() + attach_strategy_plugin_report( + report, + signals=strategy_plugin_signals, + error=strategy_plugin_error, + ) lock_acquired = STRATEGY_RUN_LOCK.acquire(blocking=False) try: log_runtime_event( @@ -549,7 +579,9 @@ def handle_request(): "strategy_cycle_started", message="Starting strategy execution", ) - cycle_result = coerce_strategy_cycle_result(run_strategy_core()) + cycle_result = coerce_strategy_cycle_result( + run_strategy_core(strategy_plugin_signals=strategy_plugin_signals) + ) execution_summary = dict(cycle_result.execution_summary or {}) reconciliation_record = dict(cycle_result.reconciliation_record or {}) finalize_runtime_report( diff --git a/notifications/renderers.py b/notifications/renderers.py index 302f2a8..18984b7 100644 --- a/notifications/renderers.py +++ b/notifications/renderers.py @@ -42,6 +42,10 @@ def _translator_uses_zh(translator) -> bool: return _base_translator_uses_zh(translator) +def _extra_notification_lines(extra_notification_lines) -> list[str]: + return [str(line).strip() for line in extra_notification_lines or () if str(line).strip()] + + def _localize_notification_text(text: str, *, translator) -> str: return _base_localize_notification_text( text, @@ -395,10 +399,12 @@ def _build_compact_message( separator: str, body_lines, dashboard_text: str = "", + extra_notification_lines=(), ) -> str: lines = [title] strategy_name = _format_text(strategy_display_name, fallback="") lines.append(translator("strategy_label", name=strategy_name)) + lines.extend(_extra_notification_lines(extra_notification_lines)) dashboard = _format_dashboard_text(dashboard_text) if dashboard: lines.append(separator) @@ -427,8 +433,11 @@ def render_heartbeat_notification( translator, separator, strategy_display_name, + extra_notification_lines=(), ) -> RenderedNotification: - detailed_text = f"{translator('heartbeat_title')}\n{dashboard}\n{separator}\n{no_op_text}" + extra_lines = _extra_notification_lines(extra_notification_lines) + detailed_parts = [translator("heartbeat_title"), *extra_lines, dashboard, separator, no_op_text] + detailed_text = "\n".join(str(part) for part in detailed_parts if str(part).strip()) compact_text = _build_compact_message( title=translator("heartbeat_title"), strategy_display_name=strategy_display_name, @@ -439,6 +448,7 @@ def render_heartbeat_notification( separator=separator, body_lines=[no_op_text], dashboard_text=strategy_dashboard, + extra_notification_lines=extra_lines, ) return RenderedNotification(detailed_text=detailed_text, compact_text=compact_text) @@ -455,7 +465,9 @@ def render_trade_notification( translator, separator, strategy_display_name, + extra_notification_lines=(), ) -> RenderedNotification: + extra_lines = _extra_notification_lines(extra_notification_lines) if trade_logs: notification_trade_lines = _build_notification_trade_lines( trade_logs, @@ -464,6 +476,7 @@ def render_trade_notification( ) detailed_text = ( f"{translator('rebalance_title')}\n" + f"{chr(10).join(extra_lines) + chr(10) if extra_lines else ''}" f"{dashboard}\n" f"{separator}\n" f"{chr(10).join(notification_trade_lines)}" @@ -478,10 +491,12 @@ def render_trade_notification( separator=separator, body_lines=notification_trade_lines, dashboard_text=strategy_dashboard, + extra_notification_lines=extra_lines, ) return RenderedNotification(detailed_text=detailed_text, compact_text=compact_text) - detailed_text = f"{translator('heartbeat_title')}\n{dashboard}\n{separator}\n{translator('no_trades')}" + detailed_parts = [translator("heartbeat_title"), *extra_lines, dashboard, separator, translator("no_trades")] + detailed_text = "\n".join(str(part) for part in detailed_parts if str(part).strip()) compact_text = _build_compact_message( title=translator("heartbeat_title"), strategy_display_name=strategy_display_name, @@ -492,5 +507,6 @@ def render_trade_notification( separator=separator, body_lines=[translator("no_trades")], dashboard_text=strategy_dashboard, + extra_notification_lines=extra_lines, ) return RenderedNotification(detailed_text=detailed_text, compact_text=compact_text) diff --git a/notifications/telegram.py b/notifications/telegram.py index 3106ddc..dfa2bdb 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -66,6 +66,25 @@ "market_status_blend_gate_defensive": "🛡️ 降杠杆({asset})", "signal_blend_gate_risk_on": "{trend_symbol} 站上 {window} 日门槛线,持有 SOXL {soxl_ratio} + SOXX {soxx_ratio}", "signal_blend_gate_defensive": "{trend_symbol} 跌破门槛线,防守持有 SOXX {soxx_ratio}", + "market_status_blend_gate_overlay_capped": "🧯 过热降档({asset})", + "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}", + "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": "未知建议", "no_trades": "✅ 无需调仓", "emergency": "🛡️ 金丝雀应急: {n_bad}/4 坏, 全部转入 {safe}", "quarterly": "📊 季度调仓: 前 {n} 名轮动", @@ -154,6 +173,25 @@ "market_status_blend_gate_defensive": "🛡️ DE-LEVER ({asset})", "signal_blend_gate_risk_on": "{trend_symbol} above {window}d gated entry, hold SOXL {soxl_ratio} + SOXX {soxx_ratio}", "signal_blend_gate_defensive": "{trend_symbol} below gated entry, hold defensive SOXX {soxx_ratio}", + "market_status_blend_gate_overlay_capped": "🧯 HEAT-CAP ({asset})", + "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}", + "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", "no_trades": "✅ No rebalance needed", "emergency": "🛡️ Canary Emergency: {n_bad}/4 bad, rotating to {safe}", "quarterly": "📊 Quarterly Rebalance: Top {n} rotation", diff --git a/requirements.txt b/requirements.txt index 683a6cc..7918e27 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 numpy requests diff --git a/runtime_config_support.py b/runtime_config_support.py index 912037d..9cd512e 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -66,6 +66,7 @@ class PlatformRuntimeSettings: tg_token: str | None = None tg_chat_id: str | None = None notify_lang: str = "en" + strategy_plugin_mounts_json: str | None = None runtime_target: RuntimeTarget | None = None @@ -163,6 +164,10 @@ def load_platform_runtime_settings( tg_token=os.getenv("TELEGRAM_TOKEN"), tg_chat_id=os.getenv("GLOBAL_TELEGRAM_CHAT_ID"), notify_lang=os.getenv("NOTIFY_LANG", "en"), + strategy_plugin_mounts_json=( + os.getenv("IBKR_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 97c83f0..3dbeee2 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -14,6 +14,7 @@ def test_build_translator_supports_chinese(): assert translate("paper_liquidation_only") == "IBKR 模拟账户清仓模式" assert translate("paper_liquidation_positions_seen", count=4) == "识别持仓=4" assert translate("market_status_blend_gate_risk_on", asset="SOXX+SOXL") == "🚀 风险开启(SOXX+SOXL)" + assert translate("market_status_blend_gate_overlay_capped", asset="SOXX") == "🧯 过热降档(SOXX)" assert ( translate( "signal_blend_gate_risk_on", @@ -24,6 +25,27 @@ def test_build_translator_supports_chinese(): ) == "SOXX 站上 140 日门槛线,持有 SOXL 70.0% + SOXX 20.0%" ) + assert ( + 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" + ) + assert ( + 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_watch_only"), + ) + == "🧩 插件:危机响应观察 | 模式:影子观察 | 路由:不操作 | 建议:仅观察" + ) assert ( translate( "small_account_warning_note", diff --git a/tests/test_request_handling.py b/tests/test_request_handling.py index a68d67d..e92cbc1 100644 --- a/tests/test_request_handling.py +++ b/tests/test_request_handling.py @@ -17,7 +17,7 @@ def fail_if_called(): def test_handle_request_post_executes_on_market_day(strategy_module, monkeypatch): observed = {"called": False} - def fake_run_strategy_core(): + def fake_run_strategy_core(**_kwargs): observed["called"] = True return "OK - executed" @@ -68,7 +68,7 @@ def test_handle_request_emits_structured_runtime_events(strategy_module, monkeyp lambda context, event, **fields: observed.append((context.run_id, event, fields)), ) monkeypatch.setattr(strategy_module, "is_market_open_today", lambda: True) - monkeypatch.setattr(strategy_module, "run_strategy_core", lambda: "OK - executed") + monkeypatch.setattr(strategy_module, "run_strategy_core", lambda **_kwargs: "OK - executed") with strategy_module.app.test_request_context( "/", @@ -93,7 +93,7 @@ def test_handle_request_persists_machine_readable_report(strategy_module, monkey monkeypatch.setattr(strategy_module, "build_run_id", lambda: "run-001") monkeypatch.setattr(strategy_module, "is_market_open_today", lambda: True) - monkeypatch.setattr(strategy_module, "run_strategy_core", lambda: "OK - executed") + monkeypatch.setattr(strategy_module, "run_strategy_core", lambda **_kwargs: "OK - executed") monkeypatch.setattr( strategy_module, "persist_execution_report", @@ -132,7 +132,7 @@ def test_handle_request_enriches_runtime_report_with_cycle_details(strategy_modu monkeypatch.setattr(strategy_module, "build_run_id", lambda: "run-001") monkeypatch.setattr(strategy_module, "is_market_open_today", lambda: True) - def fake_run_strategy_core(): + def fake_run_strategy_core(**_kwargs): return StrategyCycleResult( result="OK - executed", execution_summary={ @@ -198,7 +198,7 @@ def test_handle_request_error_persists_machine_readable_report(strategy_module, monkeypatch.setattr( strategy_module, "run_strategy_core", - lambda: (_ for _ in ()).throw(RuntimeError("boom")), + lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("boom")), ) monkeypatch.setattr( strategy_module, diff --git a/tests/test_runtime_composer.py b/tests/test_runtime_composer.py index 2c848b1..43b6b6d 100644 --- a/tests/test_runtime_composer.py +++ b/tests/test_runtime_composer.py @@ -82,7 +82,7 @@ def fake_reporting_builder(**kwargs): notification_adapters = composer.build_notification_adapters() reporting_adapters = composer.build_reporting_adapters() runtime = composer.build_rebalance_runtime() - config = composer.build_rebalance_config() + config = composer.build_rebalance_config(extra_notification_lines=("plugin-line",)) assert notification_adapters.notification_port == "notification-port" assert observed["notification_builder"]["send_message"] @@ -100,4 +100,5 @@ def fake_reporting_builder(**kwargs): assert config.separator == "━━━━━━━━━━━━━━━━━━" assert config.strategy_display_name == "全球 ETF 轮动" assert config.reconciliation_output_path == "/tmp/reconciliation.json" + assert config.extra_notification_lines == ("plugin-line",) assert reporting_adapters == "reporting-adapters" diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index fabf810..f06bf6c 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -127,6 +127,7 @@ def test_load_platform_runtime_settings_uses_minimal_group_config(monkeypatch): assert settings.notify_lang == "en" assert settings.tg_token is None assert settings.tg_chat_id is None + assert settings.strategy_plugin_mounts_json is None def test_load_platform_runtime_settings_prefers_runtime_target_json(monkeypatch): @@ -205,6 +206,19 @@ def test_load_platform_runtime_settings_supports_fractional_quantity_step(monkey assert settings.min_order_notional == 5.0 +def test_load_platform_runtime_settings_reads_ibkr_strategy_plugin_mounts(monkeypatch): + mount_config = '{"strategy_plugins":[{"strategy":"soxl_soxx_trend_income","plugin":"crisis_response_shadow","signal_path":"gs://bucket/latest_signal.json"}]}' + monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json("soxl_soxx_trend_income")) + monkeypatch.setenv("ACCOUNT_GROUP", "paper") + monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON) + monkeypatch.setenv("STRATEGY_PLUGIN_MOUNTS_JSON", '{"strategy_plugins":[{"plugin":"global"}]}') + monkeypatch.setenv("IBKR_STRATEGY_PLUGIN_MOUNTS_JSON", mount_config) + + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + assert settings.strategy_plugin_mounts_json == mount_config + + def test_load_platform_runtime_settings_rejects_unknown_strategy_profile(monkeypatch): monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json("balanced_income")) diff --git a/tests/test_runtime_strategy_adapters.py b/tests/test_runtime_strategy_adapters.py new file mode 100644 index 0000000..c49296d --- /dev/null +++ b/tests/test_runtime_strategy_adapters.py @@ -0,0 +1,79 @@ +from types import SimpleNamespace + +from application.runtime_strategy_adapters import build_runtime_strategy_adapters + + +def test_strategy_plugin_signals_are_loaded_reported_and_rendered(): + observed = {} + + 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 ( + SimpleNamespace( + plugin="crisis_response_shadow", + effective_mode="shadow", + canonical_route="no_action", + suggested_action="watch_only", + ), + ) + + translations = { + "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_watch_only": "watch only", + } + + adapters = build_runtime_strategy_adapters( + strategy_runtime=SimpleNamespace(), + strategy_profile="soxl_soxx_trend_income", + translator=lambda key, **kwargs: translations.get(key, key).format(**kwargs) if kwargs else translations.get(key, key), + pacing_sec=0.0, + resolve_run_as_of_date_fn=lambda: None, + fetch_historical_price_series_fn=lambda *_args, **_kwargs: SimpleNamespace(points=()), + fetch_historical_price_candles_fn=lambda *_args, **_kwargs: (), + map_strategy_decision_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 observed["raw_mounts"] == '{"strategy_plugins":[]}' + assert observed["load_call"] == (("mount-1",), "soxl_soxx_trend_income") + assert report["summary"]["strategy_plugins"] == list(signals) + assert adapters.build_strategy_plugin_notification_lines(signals) == ( + "plugin=Crisis|mode=shadow|route=no action|action=watch only", + ) + + +def test_strategy_plugin_load_error_is_non_blocking(): + adapters = build_runtime_strategy_adapters( + strategy_runtime=SimpleNamespace(), + strategy_profile="soxl_soxx_trend_income", + translator=lambda key, **_kwargs: key, + pacing_sec=0.0, + resolve_run_as_of_date_fn=lambda: None, + fetch_historical_price_series_fn=lambda *_args, **_kwargs: SimpleNamespace(points=()), + fetch_historical_price_candles_fn=lambda *_args, **_kwargs: (), + map_strategy_decision_fn=lambda *_args, **_kwargs: (), + load_configured_strategy_plugin_signals_fn=lambda *_args, **_kwargs: (), + parse_strategy_plugin_mounts_fn=lambda _raw: (_ for _ in ()).throw(ValueError("bad config")), + ) + + signals, error = adapters.load_strategy_plugin_signals('{"strategy_plugins":[]}') + report = {} + adapters.attach_strategy_plugin_report(report, signals=signals, error=error) + + assert signals == () + assert error == "ValueError: bad config" + assert report["diagnostics"]["strategy_plugin_error"] == "ValueError: bad config" diff --git a/tests/test_sync_cloud_run_env_workflow.sh b/tests/test_sync_cloud_run_env_workflow.sh index 89151d1..a475eca 100644 --- a/tests/test_sync_cloud_run_env_workflow.sh +++ b/tests/test_sync_cloud_run_env_workflow.sh @@ -37,6 +37,7 @@ grep -Fq 'IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME: ${{ vars.IB_ACCOUNT_GROUP_CONFIG_ grep -Fq 'IBKR_FEATURE_SNAPSHOT_PATH: ${{ vars.IBKR_FEATURE_SNAPSHOT_PATH }}' "$workflow_file" grep -Fq 'IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH }}' "$workflow_file" grep -Fq 'IBKR_STRATEGY_CONFIG_PATH: ${{ vars.IBKR_STRATEGY_CONFIG_PATH }}' "$workflow_file" +grep -Fq 'IBKR_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.IBKR_STRATEGY_PLUGIN_MOUNTS_JSON }}' "$workflow_file" grep -Fq 'IBKR_RECONCILIATION_OUTPUT_PATH: ${{ vars.IBKR_RECONCILIATION_OUTPUT_PATH }}' "$workflow_file" grep -Fq 'IBKR_DRY_RUN_ONLY: ${{ vars.IBKR_DRY_RUN_ONLY }}' "$workflow_file" grep -Fq 'IB_GATEWAY_ZONE: ${{ vars.IB_GATEWAY_ZONE }}' "$workflow_file" @@ -72,11 +73,13 @@ grep -Fq 'required_vars+=(IBKR_RECONCILIATION_OUTPUT_PATH)' "$workflow_file" grep -Fq 'env_pairs+=("IBKR_FEATURE_SNAPSHOT_PATH=${IBKR_FEATURE_SNAPSHOT_PATH}")' "$workflow_file" grep -Fq 'env_pairs+=("IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH=${IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH}")' "$workflow_file" grep -Fq 'env_pairs+=("IBKR_STRATEGY_CONFIG_PATH=${IBKR_STRATEGY_CONFIG_PATH}")' "$workflow_file" +grep -Fq 'env_pairs+=("IBKR_STRATEGY_PLUGIN_MOUNTS_JSON=${IBKR_STRATEGY_PLUGIN_MOUNTS_JSON}")' "$workflow_file" grep -Fq 'env_pairs+=("IBKR_RECONCILIATION_OUTPUT_PATH=${IBKR_RECONCILIATION_OUTPUT_PATH}")' "$workflow_file" grep -Fq 'env_pairs+=("IBKR_DRY_RUN_ONLY=${IBKR_DRY_RUN_ONLY}")' "$workflow_file" grep -Fq 'remove_env_vars+=("IBKR_FEATURE_SNAPSHOT_PATH")' "$workflow_file" grep -Fq 'remove_env_vars+=("IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH")' "$workflow_file" grep -Fq 'remove_env_vars+=("IBKR_STRATEGY_CONFIG_PATH")' "$workflow_file" +grep -Fq 'remove_env_vars+=("IBKR_STRATEGY_PLUGIN_MOUNTS_JSON")' "$workflow_file" grep -Fq 'remove_env_vars+=("IBKR_RECONCILIATION_OUTPUT_PATH")' "$workflow_file" grep -Fq 'remove_env_vars+=("IBKR_DRY_RUN_ONLY")' "$workflow_file" grep -Fq 'secret_pairs+=("TELEGRAM_TOKEN=${TELEGRAM_TOKEN_SECRET_NAME}:latest")' "$workflow_file"