Skip to content

Commit 7645a60

Browse files
Pigbibicodex
andcommitted
fix: harden runtime scheduling and notifications
Co-Authored-By: Codex <noreply@openai.com>
1 parent 9b1d500 commit 7645a60

19 files changed

Lines changed: 1443 additions & 44 deletions

.github/workflows/execution-report-heartbeat.yml

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
name: Execution Report Heartbeat
22

3-
# Schedule disabled; trade/error notifications own unattended alerting.
43
on:
54
workflow_dispatch:
65
inputs:
@@ -17,6 +16,8 @@ on:
1716
options:
1817
- "true"
1918
- "false"
19+
schedule:
20+
- cron: "20 22 * * *"
2021

2122
env:
2223
GCP_PROJECT_ID: longbridgequant
@@ -56,6 +57,9 @@ jobs:
5657
RUNTIME_HEARTBEAT_FAIL_WORKFLOW_ON_ALERT: ${{ inputs.fail_workflow_on_alert || vars.RUNTIME_HEARTBEAT_FAIL_WORKFLOW_ON_ALERT || 'true' }}
5758
RUNTIME_HEARTBEAT_ACCEPT_STATUSES: ${{ vars.RUNTIME_HEARTBEAT_ACCEPT_STATUSES }}
5859
RUNTIME_HEARTBEAT_REJECT_STATUSES: ${{ vars.RUNTIME_HEARTBEAT_REJECT_STATUSES }}
60+
RUNTIME_HEARTBEAT_MARKET_AWARE: ${{ vars.RUNTIME_HEARTBEAT_MARKET_AWARE || 'true' }}
61+
RUNTIME_HEARTBEAT_MARKET_CALENDAR: ${{ vars.LONGBRIDGE_MARKET_CALENDAR }}
62+
RUNTIME_HEARTBEAT_MARKET_TIMEZONE: ${{ vars.LONGBRIDGE_MARKET_TIMEZONE }}
5963
RUNTIME_HEARTBEAT_SCHEDULER_AWARE: ${{ vars.RUNTIME_HEARTBEAT_SCHEDULER_AWARE || 'true' }}
6064
RUNTIME_HEARTBEAT_SCHEDULER_LOCATION: ${{ vars.RUNTIME_HEARTBEAT_SCHEDULER_LOCATION || vars.CLOUD_RUN_REGION }}
6165
RUNTIME_HEARTBEAT_EXPECTED_DAY_OF_MONTH: ${{ vars.RUNTIME_HEARTBEAT_EXPECTED_DAY_OF_MONTH }}
@@ -74,6 +78,19 @@ jobs:
7478
uses: actions/checkout@v6
7579

7680
- name: Authenticate to Google Cloud
81+
id: gcp_auth_primary
82+
continue-on-error: true
83+
uses: google-github-actions/auth@v3
84+
with:
85+
workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }}
86+
service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }}
87+
88+
- name: Wait before Google Cloud authentication retry
89+
if: ${{ steps.gcp_auth_primary.outcome == 'failure' }}
90+
run: sleep 10
91+
92+
- name: Retry Google Cloud authentication
93+
if: ${{ steps.gcp_auth_primary.outcome == 'failure' }}
7794
uses: google-github-actions/auth@v3
7895
with:
7996
workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }}
@@ -82,5 +99,11 @@ jobs:
8299
- name: Set up gcloud
83100
uses: google-github-actions/setup-gcloud@v3
84101

102+
- name: Install market calendar
103+
continue-on-error: true
104+
run: >-
105+
python -m pip install --disable-pip-version-check
106+
--retries 3 --timeout 30 "pandas-market-calendars==5.4.0"
107+
85108
- name: Check recent execution report
86109
run: python scripts/execution_report_heartbeat.py

.github/workflows/runtime-guard.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,19 @@ jobs:
7474
uses: actions/checkout@v6
7575

7676
- name: Authenticate to Google Cloud
77+
id: gcp_auth_primary
78+
continue-on-error: true
79+
uses: google-github-actions/auth@v3
80+
with:
81+
workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }}
82+
service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }}
83+
84+
- name: Wait before Google Cloud authentication retry
85+
if: ${{ steps.gcp_auth_primary.outcome == 'failure' }}
86+
run: sleep 10
87+
88+
- name: Retry Google Cloud authentication
89+
if: ${{ steps.gcp_auth_primary.outcome == 'failure' }}
7790
uses: google-github-actions/auth@v3
7891
with:
7992
workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }}

application/runtime_composer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def __post_init__(self) -> None:
100100
def with_prefix(self, message: str) -> str:
101101
return self.prefixer_builder(self.account_prefix)(message)
102102

103-
def send_message(self, message: str) -> None:
103+
def send_message(self, message: str) -> bool:
104104
"""Send a cycle notification through the configured channel."""
105105
prefixed = self.with_prefix(message)
106106
sender = build_cycle_sender(
@@ -109,7 +109,7 @@ def send_message(self, message: str) -> None:
109109
telegram_chat_id=self.tg_chat_id,
110110
webhook_url=self.webhook_url,
111111
)
112-
sender(prefixed)
112+
return sender(prefixed)
113113

114114
send_tg_message = send_message # backward-compat alias
115115

application/runtime_notification_adapters.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,18 @@ class LongBridgeNotificationAdapters:
2727
cycle_publisher: NotificationPublisher
2828
delivery_events: list[dict[str, Any]]
2929

30-
def publish_cycle_notification(self, *, detailed_text: str, compact_text: str) -> None:
31-
self.cycle_publisher.publish(
30+
def publish_cycle_notification(self, *, detailed_text: str, compact_text: str) -> bool:
31+
before_count = len(self.delivery_events)
32+
outcome = self.cycle_publisher.publish(
3233
RenderedNotification(
3334
detailed_text=detailed_text,
3435
compact_text=compact_text,
3536
)
3637
)
38+
deliveries = self.delivery_events[before_count:]
39+
if deliveries:
40+
return all(event.get("delivery_status") == "sent" for event in deliveries)
41+
return outcome is not False
3742

3843

3944
def build_runtime_notification_adapters(
@@ -51,17 +56,34 @@ def build_runtime_notification_adapters(
5156
) -> LongBridgeNotificationAdapters:
5257
recorded_delivery_events = delivery_events if delivery_events is not None else []
5358

54-
def send_recorded_message(message: str) -> None:
55-
send_message(message)
59+
def send_recorded_message(message: str) -> bool:
5660
compact = str(message or "")
57-
recorded_delivery_events.append(
61+
event = {
62+
"sink": notification_channel,
63+
"compact_text_sha256": hashlib.sha256(compact.encode("utf-8")).hexdigest(),
64+
"compact_text_length": len(compact),
65+
}
66+
try:
67+
outcome = send_message(message)
68+
except Exception as exc:
69+
event.update(
70+
{
71+
"delivery_status": "failed",
72+
"transport_acknowledged": False,
73+
"error_type": type(exc).__name__,
74+
}
75+
)
76+
recorded_delivery_events.append(event)
77+
return False
78+
acknowledged = outcome is not False
79+
event.update(
5880
{
59-
"sink": notification_channel,
60-
"delivery_status": "sent",
61-
"compact_text_sha256": hashlib.sha256(compact.encode("utf-8")).hexdigest(),
62-
"compact_text_length": len(compact),
81+
"delivery_status": "sent" if acknowledged else "failed",
82+
"transport_acknowledged": acknowledged,
6383
}
6484
)
85+
recorded_delivery_events.append(event)
86+
return acknowledged
6587

6688
cycle_publisher = NotificationPublisher(
6789
log_message=log_message or (lambda message: print(with_prefix(message), flush=True)),

main.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,32 @@ def _build_notification_delivery_log_for_report(
329329
}
330330

331331

332+
def _build_notification_delivery_summary(delivery_events: list[dict]) -> dict:
333+
safe_fields = (
334+
"sink",
335+
"delivery_status",
336+
"transport_acknowledged",
337+
"error_type",
338+
"compact_text_sha256",
339+
"compact_text_length",
340+
)
341+
events = [
342+
{key: event[key] for key in safe_fields if key in event}
343+
for event in (dict(item) for item in delivery_events)
344+
]
345+
if not events:
346+
return {}
347+
sent_count = sum(event.get("delivery_status") == "sent" for event in events)
348+
failed_count = sum(event.get("delivery_status") == "failed" for event in events)
349+
return {
350+
"attempted_count": len(events),
351+
"sent_count": sent_count,
352+
"failed_count": failed_count,
353+
"all_acknowledged": failed_count == 0 and sent_count == len(events),
354+
"delivery_events": events,
355+
}
356+
357+
332358
signal_text = build_signal_text(t)
333359
strategy_display_name = build_strategy_display_name(t)(
334360
STRATEGY_PROFILE,
@@ -516,16 +542,28 @@ def _notify_runtime_error(exc: Exception, *, route_label: str) -> bool:
516542
print("LongBridge runtime error notification skipped: no Telegram target configured.", flush=True)
517543
return False
518544
message = _runtime_error_notification_message(exc, route_label=route_label)
545+
outcomes = []
519546
for token, chat_id in targets:
520547
try:
521-
requests.post(
548+
response = requests.post(
522549
f"https://api.telegram.org/bot{token}/sendMessage",
523550
json={"chat_id": chat_id, "text": message},
524551
timeout=10,
525552
)
553+
status_code = int(getattr(response, "status_code", 200) or 200)
554+
acknowledged = 200 <= status_code < 300
555+
load_payload = getattr(response, "json", None)
556+
payload = load_payload() if acknowledged and callable(load_payload) else None
557+
if isinstance(payload, dict) and payload.get("ok") is False:
558+
acknowledged = False
559+
outcomes.append(acknowledged)
526560
except Exception as send_exc:
527-
print(f"LongBridge runtime error Telegram send failed: {send_exc}", flush=True)
528-
return True
561+
print(
562+
f"LongBridge runtime error Telegram send failed: {type(send_exc).__name__}",
563+
flush=True,
564+
)
565+
outcomes.append(False)
566+
return bool(outcomes) and all(outcomes)
529567

530568

531569
def _handle_route_runtime_error(exc: Exception, *, route_label: str):
@@ -722,6 +760,11 @@ def run_strategy(*, force_run: bool = False, validation_only: bool = False, vali
722760
)
723761
if notification_delivery_log:
724762
execution_summary["notification_delivery_log"] = notification_delivery_log
763+
notification_delivery_summary = _build_notification_delivery_summary(
764+
notification_delivery_events
765+
)
766+
if notification_delivery_summary:
767+
execution_summary["notification_delivery_summary"] = notification_delivery_summary
725768
if signal_snapshot:
726769
reporting_adapters.log_event(
727770
log_context,

notifications/telegram.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import re
6+
from collections.abc import Mapping
67
from typing import Any
78

89
from notifications.events import NotificationPublisher, RenderedNotification
@@ -489,19 +490,30 @@ def build_sender(token, chat_id, *, with_prefix_fn, requests_module=None):
489490
if requests_module is None:
490491
import requests as requests_module
491492

492-
def send_tg_message(message):
493+
def send_tg_message(message) -> bool:
493494
if not token or not chat_id:
494-
return
495+
return False
495496
url = f"https://api.telegram.org/bot{token}/sendMessage"
496497
try:
497498
prefixed = with_prefix_fn(message)
498-
requests_module.post(
499+
response = requests_module.post(
499500
url,
500501
json={"chat_id": chat_id, "text": _break_telegram_market_symbol_auto_links(prefixed)},
501502
timeout=10,
502503
)
504+
status_code = int(getattr(response, "status_code", 200) or 200)
505+
if status_code < 200 or status_code >= 300:
506+
print(f"Telegram send failed: HTTP {status_code}", flush=True)
507+
return False
508+
load_payload = getattr(response, "json", None)
509+
payload = load_payload() if callable(load_payload) else None
510+
if isinstance(payload, Mapping) and payload.get("ok") is False:
511+
print("Telegram send failed: negative API acknowledgement", flush=True)
512+
return False
503513
except Exception as exc:
504514
print(f"Telegram send failed: {type(exc).__name__}", flush=True)
515+
return False
516+
return True
505517

506518
return send_tg_message
507519

runtime_config_support.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import json
34
import math
45
import os
56
from dataclasses import dataclass
@@ -235,6 +236,21 @@ def _market_defaults(market: str) -> dict[str, str]:
235236
}
236237

237238

239+
def _runtime_target_market_value(runtime_target: RuntimeTarget, field: str) -> str | None:
240+
value = getattr(runtime_target, field, None)
241+
if value is not None and str(value).strip():
242+
return str(value).strip()
243+
raw = os.getenv("QSL_RUNTIME_TARGET_JSON") or os.getenv("RUNTIME_TARGET_JSON")
244+
if not raw:
245+
return None
246+
try:
247+
payload = json.loads(raw)
248+
except json.JSONDecodeError:
249+
return None
250+
value = payload.get(field) if isinstance(payload, dict) else None
251+
return str(value).strip() if value is not None and str(value).strip() else None
252+
253+
238254
def load_platform_runtime_settings(
239255
*,
240256
project_id_resolver: Callable[[], str | None],
@@ -268,7 +284,13 @@ def load_platform_runtime_settings(
268284
os.getenv("ACCOUNT_REGION"),
269285
account_prefix=account_prefix,
270286
)
271-
market = infer_market(os.getenv("LONGBRIDGE_MARKET"), account_region=account_region)
287+
market = infer_market(
288+
_first_non_empty(
289+
os.getenv("LONGBRIDGE_MARKET"),
290+
_runtime_target_market_value(runtime_target, "market"),
291+
),
292+
account_region=account_region,
293+
)
272294
market_defaults = _market_defaults(market)
273295
return PlatformRuntimeSettings(
274296
project_id=project_id_resolver(),
@@ -281,11 +303,13 @@ def load_platform_runtime_settings(
281303
market=market,
282304
market_calendar=_first_non_empty(
283305
os.getenv("LONGBRIDGE_MARKET_CALENDAR"),
306+
_runtime_target_market_value(runtime_target, "market_calendar"),
284307
market_defaults["market_calendar"],
285308
)
286309
or DEFAULT_MARKET_CALENDAR,
287310
market_timezone=_first_non_empty(
288311
os.getenv("LONGBRIDGE_MARKET_TIMEZONE"),
312+
_runtime_target_market_value(runtime_target, "market_timezone"),
289313
market_defaults["market_timezone"],
290314
)
291315
or DEFAULT_MARKET_TIMEZONE,

0 commit comments

Comments
 (0)