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
18 changes: 9 additions & 9 deletions .github/workflows/invoke-cloud-run.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ on:
path:
description: "HTTP path to call"
required: false
default: "/precheck"
default: "/dry-run"
type: string
allow_live_execution:
description: "Allow calling / live execution entrypoint"
description: "Allow calling /run live execution entrypoint"
required: false
default: false
type: boolean
Expand Down Expand Up @@ -82,12 +82,12 @@ jobs:

raw_path="${{ inputs.path }}"
if [ -z "${raw_path}" ]; then
raw_path="/"
raw_path="/dry-run"
fi
if [[ "${raw_path}" != /* ]]; then
raw_path="/${raw_path}"
fi
if { [ "${raw_path}" = "/" ] || [ "${raw_path}" = "/run" ]; } && [ "${{ inputs.allow_live_execution }}" != "true" ]; then
if [ "${raw_path}" = "/run" ] && [ "${{ inputs.allow_live_execution }}" != "true" ]; then
echo "Calling ${raw_path} can trigger the live execution entrypoint. Re-run with allow_live_execution=true if this is intentional." >&2
exit 1
fi
Expand Down Expand Up @@ -144,21 +144,21 @@ jobs:
if [ "${service_ingress}" = "internal" ]; then
scheduler_location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}"
case "${raw_path}" in
/|/run)
/run)
scheduler_job="${CLOUD_RUN_SERVICE}-scheduler"
scheduler_expected_path="/"
scheduler_expected_path="/run"
;;
/probe)
scheduler_job="${CLOUD_RUN_SERVICE}-probe-scheduler"
scheduler_expected_path="/probe"
;;
/precheck|/dry-run)
/dry-run)
scheduler_job="${CLOUD_RUN_SERVICE}-precheck-scheduler"
scheduler_expected_path="/precheck"
scheduler_expected_path="/dry-run"
;;
*)
echo "Cloud Run service ${CLOUD_RUN_SERVICE} has internal ingress, so GitHub-hosted runners cannot curl ${raw_path} directly." >&2
echo "Use one of the scheduler-backed paths: /, /run, /probe, /precheck, /dry-run." >&2
echo "Use one of the scheduler-backed paths: /run, /probe, /dry-run." >&2
exit 1
;;
esac
Expand Down
16 changes: 15 additions & 1 deletion .github/workflows/sync-cloud-run-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -991,17 +991,29 @@ jobs:
probe_time="${scheduler_market_config[2]}"
precheck_time="${scheduler_market_config[3]}"

service_url="$(gcloud run services describe "${CLOUD_RUN_SERVICE}" \
--project="${GCP_PROJECT_ID}" \
--region="${CLOUD_RUN_REGION}" \
--format='value(status.url)' 2>/dev/null || true)"
if [ -z "${service_url}" ]; then
echo "Unable to resolve Cloud Run service URL for ${CLOUD_RUN_SERVICE}; cannot sync scheduler URI." >&2
exit 1
fi

for suffix in scheduler probe-scheduler precheck-scheduler; do
job_name="${CLOUD_RUN_SERVICE}-${suffix}"
case "${suffix}" in
scheduler)
schedule_time="${main_time}"
scheduler_path="/run"

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 Update heartbeat scheduler matching for /run

This changes the main Cloud Scheduler job to target /run, but scripts/execution_report_heartbeat.py still treats only path / as a strategy-run scheduler (_scheduler_job_targets_strategy_run returns false for any other path). After this sync, scheduler-aware heartbeat checks will see no matching main scheduler jobs and conservatively require reports for every configured service, causing false heartbeat failures for services whose main scheduler was not actually due.

Useful? React with 👍 / 👎.

;;
probe-scheduler)
schedule_time="${probe_time}"
scheduler_path="/probe"
;;
precheck-scheduler)
schedule_time="${precheck_time}"
scheduler_path="/dry-run"
;;
esac

Expand All @@ -1027,10 +1039,12 @@ jobs:
PY
)"

echo "Updating Cloud Scheduler job ${job_name} schedule to ${desired_schedule} and timezone to ${market_timezone}."
scheduler_uri="${service_url}${scheduler_path}"
echo "Updating Cloud Scheduler job ${job_name} schedule to ${desired_schedule}, timezone to ${market_timezone}, and URI to ${scheduler_uri}."
gcloud scheduler jobs update http "${job_name}" \
--project="${GCP_PROJECT_ID}" \
--location="${scheduler_location}" \
--uri="${scheduler_uri}" \

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 Set Scheduler OIDC audience when adding /run URI

Please set or verify the Cloud Scheduler OIDC audience when changing the main job target to a pathful URI. Google Cloud Scheduler documents that, when the Audience is not specified, the entire target URL is used as the OIDC aud, while Cloud Run service-to-service auth requires aud to remain the service URL even when requesting a specific path; for jobs created with the default audience, this update makes the main scheduler send an audience like ${service_url}/run, causing authenticated Cloud Run invocations to be rejected after the sync.

Useful? React with 👍 / 👎.

--schedule="${desired_schedule}" \
--time-zone="${market_timezone}" \
--quiet
Expand Down
22 changes: 4 additions & 18 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -518,8 +518,8 @@ def run_strategy(*, force_run: bool = False, validation_only: bool = False, vali
strategy_plugin_signals=strategy_plugin_signals,
strategy_plugin_error=strategy_plugin_error,
notification_title_key=(
"precheck_title"
if validation_only and validation_label == "precheck"
"dry_run_title"
if validation_only and validation_label == "dry_run"
else ""
),
),
Expand Down Expand Up @@ -672,7 +672,6 @@ def run_probe(*, response_body: str = "Probe OK"):
print(f"failed to persist execution report: {persist_exc}", flush=True)


@app.route("/", methods=["POST", "GET"])
@app.route("/run", methods=["POST", "GET"])
def handle_trigger():
"""Entrypoint for Cloud Run / scheduler: run strategy and return 200."""
Expand All @@ -695,27 +694,14 @@ def handle_backfill():
)


@app.route("/precheck", methods=["POST", "GET"])
def handle_precheck():
"""Pre-market / post-open verification entrypoint for dry-run only execution."""
return _route_with_runtime_error_fallback(
run_strategy,
force_run=True,
validation_only=True,
validation_label="precheck",
success_body="Precheck OK",
route_label="POST /precheck",
)


@app.route("/dry-run", methods=["POST", "GET"])
def handle_dry_run():
"""Strategy dry-run entrypoint; alias of precheck with clearer operator wording."""
"""Strategy dry-run entrypoint."""
return _route_with_runtime_error_fallback(
run_strategy,
force_run=True,
validation_only=True,
validation_label="precheck",
validation_label="dry_run",
success_body="Dry Run OK",
route_label="POST /dry-run",
)
Expand Down
6 changes: 4 additions & 2 deletions notifications/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
"income_locked": "🏦 收入层锁定占比: {ratio}",
"signal": "🎯 触发信号: {msg}",
"heartbeat_title": "💓 【心跳检测】",
"precheck_title": "🧪 【策略预检】",
"precheck_title": "🧪 【策略演练】",
"dry_run_title": "🧪 【策略演练】",
"health_probe_title": "🔎 【连接探针】",
"health_probe_error_prefix": "健康探针异常:\n",
"equity": "💰 净值: ${value}",
Expand Down Expand Up @@ -197,7 +198,8 @@
"income_locked": "🏦 Income Locked: {ratio}",
"signal": "🎯 Signal: {msg}",
"heartbeat_title": "💓 【Heartbeat】",
"precheck_title": "🧪 【Strategy Precheck】",
"precheck_title": "🧪 【Strategy Dry Run】",
"dry_run_title": "🧪 【Strategy Dry Run】",
"health_probe_title": "🔎 【Health Probe】",
"health_probe_error_prefix": "Health probe error:\n",
"equity": "💰 Equity: ${value}",
Expand Down
6 changes: 3 additions & 3 deletions tests/test_invoke_cloud_run_workflow.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ workflow_file="$repo_dir/.github/workflows/invoke-cloud-run.yml"
grep -Fq "name: Invoke Cloud Run" "$workflow_file"
grep -Fq "workflow_dispatch:" "$workflow_file"
grep -Fq "environment: \${{ inputs.environment }}" "$workflow_file"
grep -Fq 'default: "/precheck"' "$workflow_file"
grep -Fq 'default: "/dry-run"' "$workflow_file"
grep -Fq "allow_live_execution:" "$workflow_file"
grep -Fq "Calling / can trigger the live execution entrypoint." "$workflow_file"
grep -Fq "Calling \${raw_path} can trigger the live execution entrypoint." "$workflow_file"
grep -Fq "id-token: write" "$workflow_file"
grep -Fq "google-github-actions/auth@v3" "$workflow_file"
grep -Fq "google-github-actions/setup-gcloud@v3" "$workflow_file"
Expand All @@ -19,7 +19,7 @@ grep -Fq "CLOUD_SCHEDULER_LOCATION: \${{ vars.CLOUD_SCHEDULER_LOCATION }}" "$wor
grep -Fq "longbridge-hk|longbridge-paper|longbridge-sg" "$workflow_file"
grep -Fq "gcloud run services describe \"\${CLOUD_RUN_SERVICE}\"" "$workflow_file"
grep -Fq "Cloud Run service \${CLOUD_RUN_SERVICE} has internal ingress" "$workflow_file"
grep -Fq "Use one of the scheduler-backed paths: /, /probe, /precheck." "$workflow_file"
grep -Fq "Use one of the scheduler-backed paths: /run, /probe, /dry-run." "$workflow_file"
grep -Fq "scheduler_job=\"\${CLOUD_RUN_SERVICE}-precheck-scheduler\"" "$workflow_file"
grep -Fq "Invoke internal service through Cloud Scheduler" "$workflow_file"
grep -Fq "gcloud scheduler jobs run \"\${scheduler_job}\"" "$workflow_file"
Expand Down
10 changes: 5 additions & 5 deletions tests/test_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ def test_heartbeat_signal_snapshot_localizes_price_source(self):
self.assertIn("📊 市场状态: 🚀 风险开启(SOXX+SOXL)", rendered.compact_text)
self.assertNotIn("longbridge_candlesticks", rendered.compact_text)

def test_precheck_heartbeat_uses_precheck_title(self):
def test_dry_run_heartbeat_uses_dry_run_title(self):
rendered = render_heartbeat_notification(
execution={
"signal_display": "🚀 入场信号 | 原因:QQQ 高于 MA200",
Expand All @@ -188,7 +188,7 @@ def test_precheck_heartbeat_uses_precheck_title(self):
separator="━━━━━━━━━━━━━━━━━━",
strategy_display_name="TQQQ 增长收益",
dry_run_only=True,
title_key="precheck_title",
title_key="dry_run_title",
)
en_rendered = render_heartbeat_notification(
execution={
Expand All @@ -200,12 +200,12 @@ def test_precheck_heartbeat_uses_precheck_title(self):
separator="━━━━━━━━━━━━━━━━━━",
strategy_display_name="TQQQ Growth Income",
dry_run_only=True,
title_key="precheck_title",
title_key="dry_run_title",
)

self.assertIn("🧪 【策略预检】", rendered.compact_text)
self.assertIn("🧪 【策略演练】", rendered.compact_text)
self.assertNotIn("💓 【心跳检测】", rendered.compact_text)
self.assertIn("🧪 【Strategy Precheck】", en_rendered.compact_text)
self.assertIn("🧪 【Strategy Dry Run】", en_rendered.compact_text)
self.assertNotIn("💓 【Heartbeat】", en_rendered.compact_text)

def test_heartbeat_renders_tqqq_volatility_delever_risk_control(self):
Expand Down
45 changes: 10 additions & 35 deletions tests/test_request_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,16 +203,11 @@ class RequestHandlingTests(unittest.TestCase):
def test_cloud_run_route_contracts_are_registered(self):
module = load_module()

self.assertIs(module.app._routes[("/", ("POST", "GET"))], module.handle_trigger)
self.assertIs(module.app._routes[("/run", ("POST", "GET"))], module.handle_trigger)
self.assertIs(
module.app._routes[("/backfill", ("POST", "GET"))],
module.handle_backfill,
)
self.assertIs(
module.app._routes[("/precheck", ("POST", "GET"))],
module.handle_precheck,
)
self.assertIs(
module.app._routes[("/dry-run", ("POST", "GET"))],
module.handle_dry_run,
Expand Down Expand Up @@ -249,7 +244,7 @@ def fake_run_strategy():
},
clear=False,
):
with module.app.test_request_context("/", method="POST"):
with module.app.test_request_context("/run", method="POST"):
body, status = module.handle_trigger()

self.assertEqual(status, 200)
Expand All @@ -260,7 +255,7 @@ def test_handle_trigger_returns_500_when_strategy_reports_failure(self):
module = load_module()
module.run_strategy = lambda: False

with module.app.test_request_context("/", method="POST"):
with module.app.test_request_context("/run", method="POST"):
body, status = module.handle_trigger()

self.assertEqual(status, 500)
Expand All @@ -282,7 +277,7 @@ def fake_post(_url, *, json, timeout):
module.requests.post = fake_post
module.run_strategy = lambda: (_ for _ in ()).throw(RuntimeError("boom"))

with module.app.test_request_context("/", method="POST"):
with module.app.test_request_context("/run", method="POST"):
body, status = module.handle_trigger()

self.assertEqual(status, 500)
Expand All @@ -308,7 +303,7 @@ def fake_post(_url, *, json, timeout):
module.requests.post = fake_post
module.run_strategy = lambda: (_ for _ in ()).throw(RuntimeError("boom"))

with module.app.test_request_context("/", method="POST"):
with module.app.test_request_context("/run", method="POST"):
body, status = module.handle_trigger()

self.assertEqual(status, 500)
Expand All @@ -327,7 +322,7 @@ def fake_run_strategy():

module.run_strategy = fake_run_strategy

with module.app.test_request_context("/", method="GET"):
with module.app.test_request_context("/run", method="GET"):
body, status = module.handle_trigger()

self.assertEqual(status, 200)
Expand All @@ -351,27 +346,7 @@ def fake_run_strategy(*, force_run=False, validation_only=False, validation_labe
self.assertEqual(body, "OK")
self.assertTrue(observed["force_run"])
self.assertTrue(observed["validation_only"])
def test_handle_precheck_forces_strategy_run(self):
module = load_module()
observed = {"force_run": None, "validation_only": None}

def fake_run_strategy(*, force_run=False, validation_only=False, validation_label="backfill"):
observed["force_run"] = force_run
observed["validation_only"] = validation_only
observed["validation_label"] = validation_label

module.run_strategy = fake_run_strategy

with module.app.test_request_context("/precheck", method="POST"):
body, status = module.handle_precheck()

self.assertEqual(status, 200)
self.assertEqual(body, "Precheck OK")
self.assertTrue(observed["force_run"])
self.assertTrue(observed["validation_only"])
self.assertEqual(observed["validation_label"], "precheck")

def test_handle_dry_run_alias_forces_strategy_dry_run(self):
def test_handle_dry_run_forces_strategy_dry_run(self):
module = load_module()
observed = {"force_run": None, "validation_only": None}

Expand All @@ -389,7 +364,7 @@ def fake_run_strategy(*, force_run=False, validation_only=False, validation_labe
self.assertEqual(body, "Dry Run OK")
self.assertTrue(observed["force_run"])
self.assertTrue(observed["validation_only"])
self.assertEqual(observed["validation_label"], "precheck")
self.assertEqual(observed["validation_label"], "dry_run")

def test_handle_probe_checks_account_snapshot_without_success_notification(self):
module = load_module()
Expand Down Expand Up @@ -644,7 +619,7 @@ def build_rebalance_config(
self.assertTrue(observed["silent_cycle_notifications"])
self.assertEqual(observed["notification_title_key"], "")

def test_run_strategy_precheck_sets_precheck_notification_title(self):
def test_run_strategy_dry_run_sets_dry_run_notification_title(self):
module = load_module()
observed = {"notification_title_key": None}

Expand Down Expand Up @@ -685,9 +660,9 @@ def build_rebalance_config(
module.is_market_open_now = lambda **_kwargs: False
module.run_rebalance_cycle = lambda **_kwargs: None

module.run_strategy(force_run=True, validation_only=True, validation_label="precheck")
module.run_strategy(force_run=True, validation_only=True, validation_label="dry_run")

self.assertEqual(observed["notification_title_key"], "precheck_title")
self.assertEqual(observed["notification_title_key"], "dry_run_title")

def test_run_strategy_persists_machine_readable_report(self):
module = load_module()
Expand Down