Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
60 changes: 41 additions & 19 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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 {})
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce dry-run in precheck execution path

Passing dry_run_only_override=True here does not actually force a dry run for order placement: the request eventually calls execute_rebalance() in main.py, which still builds broker adapters from RUNTIME_SETTINGS.dry_run_only. In environments where IBKR_DRY_RUN_ONLY=false, a POST to /precheck can submit real orders even though this route is documented as precheck-only, so the new scheduler split can trigger unintended executions.

Useful? React with 👍 / 👎.



@app.route("/health", methods=["GET"])
def health():
return "OK", 200
Expand Down
71 changes: 71 additions & 0 deletions tests/test_request_handling.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import types

from application.cycle_result import StrategyCycleResult


Expand Down Expand Up @@ -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"))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Import types before using SimpleNamespace in tests

The new precheck tests reference types.SimpleNamespace but this module is not imported in tests/test_request_handling.py. Once runtime dependencies are installed and these tests run, build_request_log_context lambdas will raise NameError: name 'types' is not defined, preventing the new precheck coverage from executing.

Useful? React with 👍 / 👎.

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)
Expand Down