diff --git a/README.md b/README.md index afc947a..c2f2dba 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ python3 scripts/runtime_settings.py apply --yes local/targets/longbridge/sg.json `RUNTIME_TARGET_JSON` is canonical. Compatibility variables such as `STRATEGY_PROFILE` are generated from it so they cannot drift independently. +For daily strategies that want both a precheck pass and an execution pass, declare them in `runtime_target.execution_windows`. Keep the strategy logic unchanged; let the platform layer decide whether a window is `notify_only`, `dry_run`, `paper`, or `live`. + ## Architecture This repo acts as a small bridge between strategy selection and platform deployment without exposing live assignments: @@ -116,6 +118,8 @@ python3 scripts/runtime_settings.py apply --yes local/targets/longbridge/sg.json `RUNTIME_TARGET_JSON` 是唯一 canonical source。兼容变量,例如 `STRATEGY_PROFILE`,由它生成,避免多个配置源互相漂移。 +对于希望同时有预检和执行两次运行的日频策略,可以在 `runtime_target.execution_windows` 里显式声明两个窗口。策略逻辑保持不变,由平台层决定某个窗口是 `notify_only`、`dry_run`、`paper` 还是 `live`。 + ## 架构 这个仓库在策略选择和平台部署之间提供一个轻量 bridge,同时避免公开真实运行分配: diff --git a/examples/targets/ibkr/default.example.json b/examples/targets/ibkr/default.example.json index 8e10609..ae65256 100644 --- a/examples/targets/ibkr/default.example.json +++ b/examples/targets/ibkr/default.example.json @@ -14,7 +14,19 @@ "account_selector": ["example-default"], "account_scope": "example-default", "service_name": "example-ibkr-service", - "execution_mode": "live" + "execution_mode": "live", + "execution_windows": { + "precheck": { + "enabled": true, + "offset_minutes": 15, + "mode": "notify_only" + }, + "execution": { + "enabled": true, + "offset_minutes": 15, + "mode": "live" + } + } }, "plugin_mounts_variable": "IBKR_STRATEGY_PLUGIN_MOUNTS_JSON", "plugin_mounts": [ diff --git a/examples/targets/longbridge/sg.example.json b/examples/targets/longbridge/sg.example.json index 2fb8fb2..09e68e3 100644 --- a/examples/targets/longbridge/sg.example.json +++ b/examples/targets/longbridge/sg.example.json @@ -15,7 +15,19 @@ "account_selector": ["EXAMPLE"], "account_scope": "EXAMPLE", "service_name": "example-longbridge-service", - "execution_mode": "live" + "execution_mode": "live", + "execution_windows": { + "precheck": { + "enabled": true, + "offset_minutes": 15, + "mode": "notify_only" + }, + "execution": { + "enabled": true, + "offset_minutes": 15, + "mode": "live" + } + } }, "plugin_mounts_variable": "LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON", "plugin_mounts": [ diff --git a/examples/targets/schwab/live.example.json b/examples/targets/schwab/live.example.json index 6174b95..eb4a05e 100644 --- a/examples/targets/schwab/live.example.json +++ b/examples/targets/schwab/live.example.json @@ -14,7 +14,19 @@ "account_selector": ["example-schwab"], "account_scope": "example-schwab", "service_name": "example-schwab-service", - "execution_mode": "live" + "execution_mode": "live", + "execution_windows": { + "precheck": { + "enabled": true, + "offset_minutes": 15, + "mode": "notify_only" + }, + "execution": { + "enabled": true, + "offset_minutes": 15, + "mode": "live" + } + } }, "plugin_mounts_variable": "SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON", "plugin_mounts": [ diff --git a/schemas/runtime-target.schema.json b/schemas/runtime-target.schema.json index 58e4827..26268d4 100644 --- a/schemas/runtime-target.schema.json +++ b/schemas/runtime-target.schema.json @@ -76,6 +76,46 @@ "execution_mode": { "type": "string", "enum": ["live", "paper", "dry_run"] + }, + "execution_windows": { + "type": "object", + "additionalProperties": false, + "properties": { + "precheck": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "offset_minutes": { + "type": "integer", + "minimum": 0 + }, + "mode": { + "type": "string", + "enum": ["notify_only", "dry_run"] + } + } + }, + "execution": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "offset_minutes": { + "type": "integer", + "minimum": 0 + }, + "mode": { + "type": "string", + "enum": ["live", "paper", "dry_run"] + } + } + } + } } } }, @@ -115,4 +155,3 @@ } } } - diff --git a/scripts/runtime_settings.py b/scripts/runtime_settings.py index 0addc0c..ab244a5 100644 --- a/scripts/runtime_settings.py +++ b/scripts/runtime_settings.py @@ -32,6 +32,10 @@ "service_name", "execution_mode", ) +WINDOW_MODES = { + "precheck": {"notify_only", "dry_run"}, + "execution": {"live", "paper", "dry_run"}, +} GENERATED_VARIABLES = {"RUNTIME_TARGET_JSON", "STRATEGY_PROFILE"} SECRET_MARKERS = ("PASSWORD", "PRIVATE_KEY", "TOKEN", "API_KEY") @@ -168,6 +172,45 @@ def validate_runtime_target(target: dict[str, Any], errors: list[str]) -> None: if execution_mode not in {"live", "paper", "dry_run"}: errors.append("runtime_target.execution_mode must be live, paper, or dry_run") + execution_windows = runtime_target.get("execution_windows") + if execution_windows is not None: + if not isinstance(execution_windows, dict): + errors.append("runtime_target.execution_windows must be an object when present") + else: + for window_name, allowed_modes in WINDOW_MODES.items(): + window = execution_windows.get(window_name) + if window is None: + continue + if not isinstance(window, dict): + errors.append(f"runtime_target.execution_windows.{window_name} must be an object") + continue + for field in window: + if field not in {"enabled", "offset_minutes", "mode"}: + errors.append( + f"runtime_target.execution_windows.{window_name}.{field} is unsupported" + ) + if "enabled" in window and not isinstance(window["enabled"], bool): + errors.append( + f"runtime_target.execution_windows.{window_name}.enabled must be boolean" + ) + if "offset_minutes" in window: + offset_minutes = window["offset_minutes"] + if not isinstance(offset_minutes, int) or offset_minutes < 0: + errors.append( + f"runtime_target.execution_windows.{window_name}.offset_minutes must be a non-negative integer" + ) + mode = window.get("mode") + if mode is not None and mode not in allowed_modes: + errors.append( + f"runtime_target.execution_windows.{window_name}.mode must be one of {sorted(allowed_modes)}" + ) + for window_name in execution_windows: + if window_name not in WINDOW_MODES: + errors.append( + "runtime_target.execution_windows only supports precheck and execution" + ) + break + def validate_plugin_mounts(target: dict[str, Any], errors: list[str]) -> None: runtime_target = target.get("runtime_target") if isinstance(target.get("runtime_target"), dict) else {}