Skip to content

Commit c1eda4a

Browse files
authored
Add Cloud Run precheck route
* Add Cloud Run precheck route * Fix precheck test import
1 parent 562b6e7 commit c1eda4a

3 files changed

Lines changed: 113 additions & 20 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ Important:
258258
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`.
259259
- 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`.
260260
4. **Firewall**: Allow TCP `4001` (`live`) or `4002` (`paper`) from the Cloud Run egress subnet CIDR to the GCE instance.
261-
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`.
261+
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`.
262262
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.
263263

264264
Example deploy/update command:

main.py

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,8 @@ def build_broker_adapters():
335335
)
336336

337337

338-
def build_composer():
338+
def build_composer(*, dry_run_only_override: bool | None = None):
339+
effective_dry_run_only = RUNTIME_SETTINGS.dry_run_only if dry_run_only_override is None else bool(dry_run_only_override)
339340
return build_runtime_composer(
340341
service_name=SERVICE_NAME or os.getenv("K_SERVICE", "interactive-brokers-platform"),
341342
strategy_profile=STRATEGY_PROFILE,
@@ -353,7 +354,7 @@ def build_composer():
353354
signal_source=STRATEGY_SIGNAL_SOURCE,
354355
status_icon=STRATEGY_STATUS_ICON,
355356
safe_haven=SAFE_HAVEN,
356-
dry_run_only=RUNTIME_SETTINGS.dry_run_only,
357+
dry_run_only=effective_dry_run_only,
357358
strategy_config_source=FEATURE_RUNTIME_CONFIG_SOURCE,
358359
ib_gateway_host_resolver=get_ib_host,
359360
ib_gateway_port=IB_PORT,
@@ -407,12 +408,12 @@ def log_runtime_event(log_context, event, **fields):
407408
return build_composer().build_reporting_adapters().log_event(log_context, event, **fields)
408409

409410

410-
def build_execution_report(log_context):
411-
return build_composer().build_reporting_adapters().build_report(log_context)
411+
def build_execution_report(log_context, *, dry_run_only_override: bool | None = None):
412+
return build_composer(dry_run_only_override=dry_run_only_override).build_reporting_adapters().build_report(log_context)
412413

413414

414-
def persist_execution_report(report):
415-
return build_composer().build_reporting_adapters().persist_execution_report(report)
415+
def persist_execution_report(report, *, dry_run_only_override: bool | None = None):
416+
return build_composer(dry_run_only_override=dry_run_only_override).build_reporting_adapters().persist_execution_report(report)
416417

417418

418419
def build_request_log_context():
@@ -530,23 +531,24 @@ def run_paper_liquidation_cycle():
530531
)
531532

532533

533-
def run_strategy_core(*, strategy_plugin_signals=()):
534-
if PAPER_LIQUIDATE_ONLY:
534+
def run_strategy_core(*, strategy_plugin_signals=(), dry_run_only_override: bool | None = None):
535+
if PAPER_LIQUIDATE_ONLY and dry_run_only_override is None:
535536
return run_paper_liquidation_cycle()
536-
composer = build_composer()
537+
composer = build_composer(dry_run_only_override=dry_run_only_override)
537538
return run_rebalance_cycle(
538539
runtime=composer.build_rebalance_runtime(),
539540
config=composer.build_rebalance_config(extra_notification_lines=build_extra_notification_lines(strategy_plugin_signals)),
540541
)
541542

542543

543-
@app.route("/", methods=["POST", "GET"])
544-
def handle_request():
544+
def _handle_request(*, dry_run_only_override: bool | None = None, response_body: str = "OK"):
545545
if request.method == "GET":
546-
return "OK - use POST to execute strategy", 200
546+
if dry_run_only_override is None:
547+
return "OK - use POST to execute strategy", 200
548+
return f"{response_body} - use POST to run precheck", 200
547549

548550
log_context = build_request_log_context()
549-
report = build_execution_report(log_context)
551+
report = build_execution_report(log_context, dry_run_only_override=dry_run_only_override)
550552
strategy_plugin_signals, strategy_plugin_error = load_strategy_plugin_signals()
551553
attach_strategy_plugin_report(
552554
report,
@@ -558,8 +560,9 @@ def handle_request():
558560
log_runtime_event(
559561
log_context,
560562
"strategy_cycle_received",
561-
message="Received strategy execution request",
563+
message="Received strategy precheck request" if dry_run_only_override else "Received strategy execution request",
562564
http_method=request.method,
565+
execution_window="precheck" if dry_run_only_override else "execution",
563566
)
564567
if not lock_acquired:
565568
log_runtime_event(
@@ -579,6 +582,7 @@ def handle_request():
579582
log_context,
580583
"market_closed",
581584
message="Market closed; skip strategy execution",
585+
execution_window="precheck" if dry_run_only_override else "execution",
582586
)
583587
finalize_runtime_report(
584588
report,
@@ -589,10 +593,14 @@ def handle_request():
589593
log_runtime_event(
590594
log_context,
591595
"strategy_cycle_started",
592-
message="Starting strategy execution",
596+
message="Starting strategy precheck" if dry_run_only_override else "Starting strategy execution",
597+
execution_window="precheck" if dry_run_only_override else "execution",
593598
)
594599
cycle_result = coerce_strategy_cycle_result(
595-
run_strategy_core(strategy_plugin_signals=strategy_plugin_signals)
600+
run_strategy_core(
601+
strategy_plugin_signals=strategy_plugin_signals,
602+
dry_run_only_override=dry_run_only_override,
603+
)
596604
)
597605
execution_summary = dict(cycle_result.execution_summary or {})
598606
reconciliation_record = dict(cycle_result.reconciliation_record or {})
@@ -637,10 +645,11 @@ def handle_request():
637645
log_runtime_event(
638646
log_context,
639647
"strategy_cycle_completed",
640-
message="Strategy execution completed",
648+
message="Strategy precheck completed" if dry_run_only_override else "Strategy execution completed",
649+
execution_window="precheck" if dry_run_only_override else "execution",
641650
result=cycle_result.result,
642651
)
643-
return cycle_result.result, 200
652+
return (response_body if dry_run_only_override else cycle_result.result), 200
644653
except TimeoutError as exc:
645654
append_runtime_report_error(
646655
report,
@@ -683,12 +692,25 @@ def handle_request():
683692
if lock_acquired:
684693
STRATEGY_RUN_LOCK.release()
685694
try:
686-
report_path = persist_execution_report(report)
695+
if dry_run_only_override is None:
696+
report_path = persist_execution_report(report)
697+
else:
698+
report_path = persist_execution_report(report, dry_run_only_override=dry_run_only_override)
687699
print(f"execution_report {report_path}", flush=True)
688700
except Exception as persist_exc:
689701
print(f"failed to persist execution report: {persist_exc}", flush=True)
690702

691703

704+
@app.route("/", methods=["POST", "GET"])
705+
def handle_request():
706+
return _handle_request()
707+
708+
709+
@app.route("/precheck", methods=["POST", "GET"])
710+
def handle_precheck():
711+
return _handle_request(dry_run_only_override=True, response_body="Precheck OK")
712+
713+
692714
@app.route("/health", methods=["GET"])
693715
def health():
694716
return "OK", 200

tests/test_request_handling.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import types
2+
13
from application.cycle_result import StrategyCycleResult
24

35

@@ -32,6 +34,75 @@ def fake_run_strategy_core(**_kwargs):
3234
assert observed["called"] is True
3335

3436

37+
def test_handle_precheck_post_uses_dry_run_override(strategy_module, monkeypatch):
38+
observed = {"called": False, "dry_run_only_override": None, "events": []}
39+
40+
monkeypatch.setattr(strategy_module, "build_request_log_context", lambda: types.SimpleNamespace(run_id="run-001"))
41+
monkeypatch.setattr(strategy_module, "build_execution_report", lambda log_context, **_kwargs: {"status": "pending"})
42+
monkeypatch.setattr(strategy_module, "persist_execution_report", lambda report, **_kwargs: observed.setdefault("report", dict(report)) or "/tmp/runtime-report.json")
43+
monkeypatch.setattr(strategy_module, "emit_runtime_log", lambda context, event, **fields: observed["events"].append((event, fields)))
44+
monkeypatch.setattr(strategy_module, "is_market_open_today", lambda: True)
45+
monkeypatch.setattr(strategy_module, "load_strategy_plugin_signals", lambda: ((), None))
46+
monkeypatch.setattr(strategy_module, "attach_strategy_plugin_report", lambda *args, **kwargs: None)
47+
48+
def fake_run_strategy_core(**kwargs):
49+
observed["called"] = True
50+
observed["dry_run_only_override"] = kwargs.get("dry_run_only_override")
51+
return "OK - precheck"
52+
53+
monkeypatch.setattr(strategy_module, "run_strategy_core", fake_run_strategy_core)
54+
55+
with strategy_module.app.test_request_context("/precheck", method="POST"):
56+
body, status = strategy_module.handle_precheck()
57+
58+
assert status == 200
59+
assert body == "Precheck OK"
60+
assert observed["called"] is True
61+
assert observed["dry_run_only_override"] is True
62+
assert observed["events"][0][0] == "strategy_cycle_received"
63+
assert observed["events"][0][1]["execution_window"] == "precheck"
64+
65+
66+
def test_handle_precheck_ignores_paper_liquidate_only(strategy_module, monkeypatch):
67+
observed = {"called": False}
68+
69+
monkeypatch.setattr(strategy_module, "build_request_log_context", lambda: types.SimpleNamespace(run_id="run-001"))
70+
monkeypatch.setattr(strategy_module, "build_execution_report", lambda log_context, **_kwargs: {"status": "pending"})
71+
monkeypatch.setattr(strategy_module, "persist_execution_report", lambda report, **_kwargs: "/tmp/runtime-report.json")
72+
monkeypatch.setattr(strategy_module, "emit_runtime_log", lambda *args, **kwargs: None)
73+
monkeypatch.setattr(strategy_module, "is_market_open_today", lambda: True)
74+
monkeypatch.setattr(strategy_module, "load_strategy_plugin_signals", lambda: ((), None))
75+
monkeypatch.setattr(strategy_module, "attach_strategy_plugin_report", lambda *args, **kwargs: None)
76+
monkeypatch.setattr(strategy_module, "PAPER_LIQUIDATE_ONLY", True)
77+
78+
def fake_run_strategy_core(**kwargs):
79+
observed["called"] = True
80+
assert kwargs.get("dry_run_only_override") is True
81+
return "OK - precheck"
82+
83+
monkeypatch.setattr(strategy_module, "run_strategy_core", fake_run_strategy_core)
84+
85+
with strategy_module.app.test_request_context("/precheck", method="POST"):
86+
body, status = strategy_module.handle_precheck()
87+
88+
assert status == 200
89+
assert body == "Precheck OK"
90+
assert observed["called"] is True
91+
92+
93+
def test_handle_precheck_get_does_not_execute(strategy_module, monkeypatch):
94+
observed = {"called": False}
95+
96+
monkeypatch.setattr(strategy_module, "run_strategy_core", lambda **_kwargs: observed.__setitem__("called", True))
97+
98+
with strategy_module.app.test_request_context("/precheck", method="GET"):
99+
body, status = strategy_module.handle_precheck()
100+
101+
assert status == 200
102+
assert body == "Precheck OK - use POST to run precheck"
103+
assert observed["called"] is False
104+
105+
35106
def test_build_extra_notification_lines_includes_account_id(strategy_module):
36107
lines = strategy_module.build_extra_notification_lines(())
37108
assert any("U18308207" in line for line in lines)

0 commit comments

Comments
 (0)