diff --git a/README.md b/README.md index 86fa541..18d3b1d 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ Important: 3. **Cloud Run**: Deploy or update this Flask app with Direct VPC egress. Set `STRATEGY_PROFILE`, `ACCOUNT_GROUP`, and `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME`. Keep `IB_GATEWAY_ZONE` / `IB_GATEWAY_IP_MODE` only as transition fallbacks if the selected account-group payload does not already contain them. The workflow emits `RUNTIME_TARGET_JSON` to describe the structured deployment target. The runtime service account needs `roles/secretmanager.secretAccessor` and, for instance-name resolution, `roles/compute.viewer`. - For Cloud Run source deploy, also grant `roles/storage.objectViewer` on `gs://run-sources-${PROJECT_ID}-${REGION}` to the build service account, the deploy service account, and `${PROJECT_NUMBER}-compute@developer.gserviceaccount.com`. 4. **Firewall**: Allow TCP `4001` (`live`) or `4002` (`paper`) from the Cloud Run egress subnet CIDR to the GCE instance. -5. **Cloud Scheduler**: Create a job that POSTs to the Cloud Run URL. Choose the cron from the strategy-layer cadence in `UsEquityStrategies`; daily profiles can still use a near-close weekday schedule such as `45 15 * * 1-5` in `America/New_York`. +5. **Cloud Scheduler**: Create two jobs that POST to the Cloud Run URL. Use `"/precheck"` after the open window and `"/"` near the close window. Choose both crons from the strategy-layer cadence in `UsEquityStrategies`; daily profiles can still use a near-close weekday schedule such as `45 15 * * 1-5` in `America/New_York`. 6. **Optional public-IP mode**: Only if you cannot use VPC, set `IB_GATEWAY_IP_MODE=external`, expose the GCE public IP deliberately, and restrict source ranges tightly. This is not the default path. Example deploy/update command: diff --git a/main.py b/main.py index ac4ed31..29638a1 100644 --- a/main.py +++ b/main.py @@ -335,7 +335,8 @@ def build_broker_adapters(): ) -def build_composer(): +def build_composer(*, dry_run_only_override: bool | None = None): + effective_dry_run_only = RUNTIME_SETTINGS.dry_run_only if dry_run_only_override is None else bool(dry_run_only_override) return build_runtime_composer( service_name=SERVICE_NAME or os.getenv("K_SERVICE", "interactive-brokers-platform"), strategy_profile=STRATEGY_PROFILE, @@ -353,7 +354,7 @@ def build_composer(): signal_source=STRATEGY_SIGNAL_SOURCE, status_icon=STRATEGY_STATUS_ICON, safe_haven=SAFE_HAVEN, - dry_run_only=RUNTIME_SETTINGS.dry_run_only, + dry_run_only=effective_dry_run_only, strategy_config_source=FEATURE_RUNTIME_CONFIG_SOURCE, ib_gateway_host_resolver=get_ib_host, ib_gateway_port=IB_PORT, @@ -407,12 +408,12 @@ def log_runtime_event(log_context, event, **fields): return build_composer().build_reporting_adapters().log_event(log_context, event, **fields) -def build_execution_report(log_context): - return build_composer().build_reporting_adapters().build_report(log_context) +def build_execution_report(log_context, *, dry_run_only_override: bool | None = None): + return build_composer(dry_run_only_override=dry_run_only_override).build_reporting_adapters().build_report(log_context) -def persist_execution_report(report): - return build_composer().build_reporting_adapters().persist_execution_report(report) +def persist_execution_report(report, *, dry_run_only_override: bool | None = None): + return build_composer(dry_run_only_override=dry_run_only_override).build_reporting_adapters().persist_execution_report(report) def build_request_log_context(): @@ -530,23 +531,24 @@ def run_paper_liquidation_cycle(): ) -def run_strategy_core(*, strategy_plugin_signals=()): - if PAPER_LIQUIDATE_ONLY: +def run_strategy_core(*, strategy_plugin_signals=(), dry_run_only_override: bool | None = None): + if PAPER_LIQUIDATE_ONLY and dry_run_only_override is None: return run_paper_liquidation_cycle() - composer = build_composer() + composer = build_composer(dry_run_only_override=dry_run_only_override) return run_rebalance_cycle( runtime=composer.build_rebalance_runtime(), config=composer.build_rebalance_config(extra_notification_lines=build_extra_notification_lines(strategy_plugin_signals)), ) -@app.route("/", methods=["POST", "GET"]) -def handle_request(): +def _handle_request(*, dry_run_only_override: bool | None = None, response_body: str = "OK"): if request.method == "GET": - return "OK - use POST to execute strategy", 200 + if dry_run_only_override is None: + return "OK - use POST to execute strategy", 200 + return f"{response_body} - use POST to run precheck", 200 log_context = build_request_log_context() - report = build_execution_report(log_context) + report = build_execution_report(log_context, dry_run_only_override=dry_run_only_override) strategy_plugin_signals, strategy_plugin_error = load_strategy_plugin_signals() attach_strategy_plugin_report( report, @@ -558,8 +560,9 @@ def handle_request(): log_runtime_event( log_context, "strategy_cycle_received", - message="Received strategy execution request", + message="Received strategy precheck request" if dry_run_only_override else "Received strategy execution request", http_method=request.method, + execution_window="precheck" if dry_run_only_override else "execution", ) if not lock_acquired: log_runtime_event( @@ -579,6 +582,7 @@ def handle_request(): log_context, "market_closed", message="Market closed; skip strategy execution", + execution_window="precheck" if dry_run_only_override else "execution", ) finalize_runtime_report( report, @@ -589,10 +593,14 @@ def handle_request(): log_runtime_event( log_context, "strategy_cycle_started", - message="Starting strategy execution", + message="Starting strategy precheck" if dry_run_only_override else "Starting strategy execution", + execution_window="precheck" if dry_run_only_override else "execution", ) cycle_result = coerce_strategy_cycle_result( - run_strategy_core(strategy_plugin_signals=strategy_plugin_signals) + run_strategy_core( + strategy_plugin_signals=strategy_plugin_signals, + dry_run_only_override=dry_run_only_override, + ) ) execution_summary = dict(cycle_result.execution_summary or {}) reconciliation_record = dict(cycle_result.reconciliation_record or {}) @@ -637,10 +645,11 @@ def handle_request(): log_runtime_event( log_context, "strategy_cycle_completed", - message="Strategy execution completed", + message="Strategy precheck completed" if dry_run_only_override else "Strategy execution completed", + execution_window="precheck" if dry_run_only_override else "execution", result=cycle_result.result, ) - return cycle_result.result, 200 + return (response_body if dry_run_only_override else cycle_result.result), 200 except TimeoutError as exc: append_runtime_report_error( report, @@ -683,12 +692,25 @@ def handle_request(): if lock_acquired: STRATEGY_RUN_LOCK.release() try: - report_path = persist_execution_report(report) + if dry_run_only_override is None: + report_path = persist_execution_report(report) + else: + report_path = persist_execution_report(report, dry_run_only_override=dry_run_only_override) print(f"execution_report {report_path}", flush=True) except Exception as persist_exc: print(f"failed to persist execution report: {persist_exc}", flush=True) +@app.route("/", methods=["POST", "GET"]) +def handle_request(): + return _handle_request() + + +@app.route("/precheck", methods=["POST", "GET"]) +def handle_precheck(): + return _handle_request(dry_run_only_override=True, response_body="Precheck OK") + + @app.route("/health", methods=["GET"]) def health(): return "OK", 200 diff --git a/tests/test_request_handling.py b/tests/test_request_handling.py index fe909d1..11a935c 100644 --- a/tests/test_request_handling.py +++ b/tests/test_request_handling.py @@ -1,3 +1,5 @@ +import types + from application.cycle_result import StrategyCycleResult @@ -32,6 +34,75 @@ def fake_run_strategy_core(**_kwargs): assert observed["called"] is True +def test_handle_precheck_post_uses_dry_run_override(strategy_module, monkeypatch): + observed = {"called": False, "dry_run_only_override": None, "events": []} + + monkeypatch.setattr(strategy_module, "build_request_log_context", lambda: types.SimpleNamespace(run_id="run-001")) + monkeypatch.setattr(strategy_module, "build_execution_report", lambda log_context, **_kwargs: {"status": "pending"}) + monkeypatch.setattr(strategy_module, "persist_execution_report", lambda report, **_kwargs: observed.setdefault("report", dict(report)) or "/tmp/runtime-report.json") + monkeypatch.setattr(strategy_module, "emit_runtime_log", lambda context, event, **fields: observed["events"].append((event, fields))) + monkeypatch.setattr(strategy_module, "is_market_open_today", lambda: True) + monkeypatch.setattr(strategy_module, "load_strategy_plugin_signals", lambda: ((), None)) + monkeypatch.setattr(strategy_module, "attach_strategy_plugin_report", lambda *args, **kwargs: None) + + def fake_run_strategy_core(**kwargs): + observed["called"] = True + observed["dry_run_only_override"] = kwargs.get("dry_run_only_override") + return "OK - precheck" + + monkeypatch.setattr(strategy_module, "run_strategy_core", fake_run_strategy_core) + + with strategy_module.app.test_request_context("/precheck", method="POST"): + body, status = strategy_module.handle_precheck() + + assert status == 200 + assert body == "Precheck OK" + assert observed["called"] is True + assert observed["dry_run_only_override"] is True + assert observed["events"][0][0] == "strategy_cycle_received" + assert observed["events"][0][1]["execution_window"] == "precheck" + + +def test_handle_precheck_ignores_paper_liquidate_only(strategy_module, monkeypatch): + observed = {"called": False} + + monkeypatch.setattr(strategy_module, "build_request_log_context", lambda: types.SimpleNamespace(run_id="run-001")) + monkeypatch.setattr(strategy_module, "build_execution_report", lambda log_context, **_kwargs: {"status": "pending"}) + monkeypatch.setattr(strategy_module, "persist_execution_report", lambda report, **_kwargs: "/tmp/runtime-report.json") + monkeypatch.setattr(strategy_module, "emit_runtime_log", lambda *args, **kwargs: None) + monkeypatch.setattr(strategy_module, "is_market_open_today", lambda: True) + monkeypatch.setattr(strategy_module, "load_strategy_plugin_signals", lambda: ((), None)) + monkeypatch.setattr(strategy_module, "attach_strategy_plugin_report", lambda *args, **kwargs: None) + monkeypatch.setattr(strategy_module, "PAPER_LIQUIDATE_ONLY", True) + + def fake_run_strategy_core(**kwargs): + observed["called"] = True + assert kwargs.get("dry_run_only_override") is True + return "OK - precheck" + + monkeypatch.setattr(strategy_module, "run_strategy_core", fake_run_strategy_core) + + with strategy_module.app.test_request_context("/precheck", method="POST"): + body, status = strategy_module.handle_precheck() + + assert status == 200 + assert body == "Precheck OK" + assert observed["called"] is True + + +def test_handle_precheck_get_does_not_execute(strategy_module, monkeypatch): + observed = {"called": False} + + monkeypatch.setattr(strategy_module, "run_strategy_core", lambda **_kwargs: observed.__setitem__("called", True)) + + with strategy_module.app.test_request_context("/precheck", method="GET"): + body, status = strategy_module.handle_precheck() + + assert status == 200 + assert body == "Precheck OK - use POST to run precheck" + assert observed["called"] is False + + def test_build_extra_notification_lines_includes_account_id(strategy_module): lines = strategy_module.build_extra_notification_lines(()) assert any("U18308207" in line for line in lines)