Skip to content

Commit 0b2ba10

Browse files
Pigbibiclaude
andauthored
feat: 提交 platform_runner 目录 + datetime.utcnow 修复 (#116)
- platform_runner/: 跨服务 monitor 调度器 + 平台适配器协议 - ibkr/market_data.py: datetime.utcnow() → now(timezone.utc) - ibkr/portfolio.py: datetime.utcnow() → now(timezone.utc) - schwab/market_data.py: datetime.utcnow() → now(timezone.utc) - schwab/portfolio.py: datetime.utcnow() → now(timezone.utc) Co-authored-by: Claude <noreply@anthropic.com>
1 parent e86554b commit 0b2ba10

8 files changed

Lines changed: 317 additions & 8 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
"""Platform runner — 平台服务通用框架。"""
2+
from .monitor import dispatch_due_monitors, load_monitor_targets
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""Common Flask app utilities for platform Runner services."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
7+
8+
def resolve_project_id() -> str:
9+
"""从部署环境解析 GCP project ID。"""
10+
try:
11+
from quant_platform_kit.cloud import get_deployment_context
12+
return get_deployment_context().project_id
13+
except Exception:
14+
return os.getenv("GOOGLE_CLOUD_PROJECT") or ""
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""Platform broker adapter protocol and common type definitions."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any, Protocol, runtime_checkable
6+
7+
8+
@runtime_checkable
9+
class PlatformBrokerAdapter(Protocol):
10+
"""每个平台必须实现的 broker 适配器接口。"""
11+
12+
platform: str
13+
deploy_target: str = "cloud_run"
14+
15+
def get_project_id(self) -> str: ...
16+
17+
def load_settings(self) -> Any: ...
18+
19+
def build_composer(self, settings: Any) -> Any: ...
20+
21+
def run_strategy_cycle(self, composer: Any, dry_run: bool = False) -> dict: ...
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
"""Shared platform monitor dispatch — extracted from CharlesSchwabPlatform/FirstradePlatform/LongBridgePlatform (byte-identical)."""
2+
3+
"""Dispatch shared monitor checks to platform Cloud Run services."""
4+
5+
from __future__ import annotations
6+
7+
import datetime as dt
8+
import json
9+
import os
10+
from concurrent.futures import ThreadPoolExecutor, as_completed
11+
from dataclasses import dataclass
12+
from typing import Any, Callable, Iterable, Mapping, Sequence
13+
from zoneinfo import ZoneInfo
14+
15+
import requests
16+
from ..cloud import get_deployment_context
17+
18+
19+
MONITOR_TARGET_ENV_NAMES = (
20+
"MONITOR_DISPATCH_TARGETS_JSON",
21+
"LONGBRIDGE_MONITOR_DISPATCH_TARGETS_JSON",
22+
"SCHWAB_MONITOR_DISPATCH_TARGETS_JSON",
23+
"FIRSTRADE_MONITOR_DISPATCH_TARGETS_JSON",
24+
)
25+
DEFAULT_LOOKBACK_MINUTES = 4
26+
DEFAULT_TIMEOUT_SECONDS = 120
27+
DEFAULT_MAX_WORKERS = 4
28+
29+
30+
@dataclass(frozen=True)
31+
class MonitorWindow:
32+
name: str
33+
path: str
34+
scheduler_key: str
35+
36+
37+
MONITOR_WINDOWS = (
38+
MonitorWindow("probe", "/probe", "probe_time"),
39+
MonitorWindow("precheck", "/dry-run", "precheck_time"),
40+
)
41+
42+
43+
def load_monitor_targets(env: Mapping[str, str] | None = None) -> list[dict[str, Any]]:
44+
env = env or os.environ
45+
raw = ""
46+
for name in MONITOR_TARGET_ENV_NAMES:
47+
raw = str(env.get(name) or "").strip()
48+
if raw:
49+
break
50+
if not raw:
51+
return []
52+
payload = json.loads(raw)
53+
if isinstance(payload, dict):
54+
targets = payload.get("targets")
55+
else:
56+
targets = payload
57+
if not isinstance(targets, list):
58+
raise ValueError("monitor dispatch targets must be a JSON array or an object with targets")
59+
return [dict(target) for target in targets if isinstance(target, Mapping)]
60+
61+
62+
def dispatch_due_monitors(
63+
targets: Sequence[Mapping[str, Any]],
64+
*,
65+
now: dt.datetime | None = None,
66+
lookback_minutes: int = DEFAULT_LOOKBACK_MINUTES,
67+
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
68+
max_workers: int = DEFAULT_MAX_WORKERS,
69+
token_fetcher: Callable[[str], str] | None = None,
70+
post_fn: Callable[..., Any] | None = None,
71+
) -> dict[str, Any]:
72+
now_utc = _as_utc(now or dt.datetime.now(dt.timezone.utc))
73+
due_dispatches = list(_iter_due_dispatches(targets, now_utc=now_utc, lookback_minutes=lookback_minutes))
74+
token_fetcher = token_fetcher or _fetch_id_token
75+
post_fn = post_fn or requests.post
76+
if not due_dispatches:
77+
return {
78+
"ok": True,
79+
"total_targets": len(targets),
80+
"dispatches_due": 0,
81+
"results": [],
82+
}
83+
84+
workers = max(1, min(int(max_workers or 1), len(due_dispatches)))
85+
results: list[dict[str, Any]] = []
86+
with ThreadPoolExecutor(max_workers=workers) as executor:
87+
futures = [
88+
executor.submit(
89+
_send_dispatch,
90+
dispatch,
91+
timeout_seconds=timeout_seconds,
92+
token_fetcher=token_fetcher,
93+
post_fn=post_fn,
94+
)
95+
for dispatch in due_dispatches
96+
]
97+
for future in as_completed(futures):
98+
results.append(future.result())
99+
results.sort(key=lambda item: (str(item.get("service_name") or ""), str(item.get("window") or "")))
100+
return {
101+
"ok": all(bool(result.get("ok")) for result in results),
102+
"total_targets": len(targets),
103+
"dispatches_due": len(due_dispatches),
104+
"results": results,
105+
}
106+
107+
108+
def _iter_due_dispatches(
109+
targets: Sequence[Mapping[str, Any]],
110+
*,
111+
now_utc: dt.datetime,
112+
lookback_minutes: int,
113+
) -> Iterable[dict[str, Any]]:
114+
for target in targets:
115+
if not _target_enabled(target):
116+
continue
117+
service_url = str(target.get("service_url") or "").rstrip("/")
118+
if not service_url:
119+
continue
120+
scheduler = target.get("scheduler") if isinstance(target.get("scheduler"), Mapping) else {}
121+
timezone = _target_timezone(scheduler)
122+
local_now = now_utc.astimezone(timezone)
123+
for window in MONITOR_WINDOWS:
124+
schedule = str(scheduler.get(window.scheduler_key) or "").strip()
125+
if not schedule:
126+
continue
127+
if _cron_due_within_window(schedule, local_now=local_now, lookback_minutes=lookback_minutes):
128+
yield {
129+
"service_name": str(target.get("service_name") or ""),
130+
"strategy_profile": str(target.get("strategy_profile") or ""),
131+
"account_scope": str(target.get("account_scope") or target.get("account_group") or ""),
132+
"window": window.name,
133+
"url": f"{service_url}{window.path}",
134+
"audience": service_url,
135+
}
136+
137+
138+
def _send_dispatch(
139+
dispatch: Mapping[str, Any],
140+
*,
141+
timeout_seconds: int,
142+
token_fetcher: Callable[[str], str],
143+
post_fn: Callable[..., Any],
144+
) -> dict[str, Any]:
145+
url = str(dispatch.get("url") or "")
146+
audience = str(dispatch.get("audience") or "")
147+
base_result = {
148+
"service_name": dispatch.get("service_name"),
149+
"strategy_profile": dispatch.get("strategy_profile"),
150+
"account_scope": dispatch.get("account_scope"),
151+
"window": dispatch.get("window"),
152+
"url": url,
153+
}
154+
try:
155+
token = token_fetcher(audience)
156+
response = post_fn(
157+
url,
158+
headers={
159+
"Authorization": f"Bearer {token}",
160+
"User-Agent": "platform-monitor-dispatcher",
161+
},
162+
timeout=timeout_seconds,
163+
)
164+
status_code = int(getattr(response, "status_code", 0) or 0)
165+
return {
166+
**base_result,
167+
"status_code": status_code,
168+
"ok": 200 <= status_code < 300,
169+
}
170+
except Exception as exc:
171+
return {
172+
**base_result,
173+
"status_code": 0,
174+
"ok": False,
175+
"error_type": type(exc).__name__,
176+
"error": str(exc),
177+
}
178+
179+
180+
def _target_enabled(target: Mapping[str, Any]) -> bool:
181+
value = target.get("runtime_target_enabled")
182+
if value is None:
183+
value = target.get("enabled")
184+
if value is None:
185+
return True
186+
if isinstance(value, bool):
187+
return value
188+
return str(value).strip().lower() not in {"0", "false", "no", "off", "disabled"}
189+
190+
191+
def _target_timezone(scheduler: Mapping[str, Any]) -> dt.tzinfo:
192+
try:
193+
return ZoneInfo(str(scheduler.get("timezone") or "UTC"))
194+
except Exception:
195+
return dt.timezone.utc
196+
197+
198+
def _cron_due_within_window(schedule: str, *, local_now: dt.datetime, lookback_minutes: int) -> bool:
199+
fields = schedule.split()
200+
if len(fields) != 5:
201+
return False
202+
lookback = max(0, int(lookback_minutes or 0))
203+
floor_now = local_now.replace(second=0, microsecond=0)
204+
for minute_offset in range(lookback + 1):
205+
candidate = floor_now - dt.timedelta(minutes=minute_offset)
206+
if _cron_matches(fields, candidate):
207+
return True
208+
return False
209+
210+
211+
def _cron_matches(fields: Sequence[str], value: dt.datetime) -> bool:
212+
minute, hour, day_of_month, month, day_of_week = fields
213+
cron_weekday = (value.weekday() + 1) % 7
214+
return (
215+
_field_matches(minute, value.minute, 0, 59)
216+
and _field_matches(hour, value.hour, 0, 23)
217+
and _field_matches(day_of_month, value.day, 1, 31)
218+
and _field_matches(month, value.month, 1, 12)
219+
and _field_matches(day_of_week, cron_weekday, 0, 7, sunday_alias=True)
220+
)
221+
222+
223+
def _field_matches(field: str, value: int, min_value: int, max_value: int, *, sunday_alias: bool = False) -> bool:
224+
for part in field.split(","):
225+
part = part.strip()
226+
if not part:
227+
continue
228+
if _part_matches(part, value, min_value, max_value, sunday_alias=sunday_alias):
229+
return True
230+
return False
231+
232+
233+
def _part_matches(part: str, value: int, min_value: int, max_value: int, *, sunday_alias: bool = False) -> bool:
234+
if "/" in part:
235+
base, step_text = part.split("/", 1)
236+
try:
237+
step = int(step_text)
238+
except ValueError:
239+
return False
240+
if step <= 0:
241+
return False
242+
else:
243+
base = part
244+
step = 1
245+
if base == "*":
246+
start, end = min_value, max_value
247+
elif "-" in base:
248+
start_text, end_text = base.split("-", 1)
249+
try:
250+
start, end = int(start_text), int(end_text)
251+
except ValueError:
252+
return False
253+
else:
254+
try:
255+
start = end = int(base)
256+
except ValueError:
257+
return False
258+
if sunday_alias and value == 0 and start == end == 7:
259+
return True
260+
if value < start or value > end:
261+
return False
262+
return (value - start) % step == 0
263+
264+
265+
def _as_utc(value: dt.datetime) -> dt.datetime:
266+
if value.tzinfo is None:
267+
return value.replace(tzinfo=dt.timezone.utc)
268+
return value.astimezone(dt.timezone.utc)
269+
270+
271+
def _fetch_id_token(audience: str) -> str:
272+
return get_deployment_context().fetch_id_token(audience)

src/quant_platform_kit/ibkr/market_data.py

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

3-
from datetime import date, datetime, time
3+
from datetime import date, datetime, time, timezone
44
from math import ceil
55
from math import isnan
66
import re
@@ -239,7 +239,7 @@ def _collect_quote_snapshots(
239239
}
240240
_wait_for_market_data(ib, wait_seconds)
241241

242-
as_of = datetime.utcnow()
242+
as_of = datetime.now(timezone.utc)
243243
snapshots: dict[str, QuoteSnapshot] = {}
244244
for symbol, contract in contracts:
245245
ib.cancelMktData(contract)
@@ -395,7 +395,7 @@ def fetch_option_chain_snapshot(
395395
if chain is None:
396396
return {"underlier": symbol, "spot": spot, "contracts": ()}
397397

398-
as_of = datetime.utcnow().date()
398+
as_of = datetime.now(timezone.utc).date()
399399
target_dte = int(target_dte if target_dte is not None else (min_dte + max_dte) / 2)
400400
expirations = []
401401
for raw_expiration in getattr(chain, "expirations", ()) or ():

src/quant_platform_kit/ibkr/portfolio.py

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

3-
from datetime import datetime
3+
from datetime import datetime, timezone
44
from typing import Any, Iterable
55

66
from quant_platform_kit.common.models import PortfolioSnapshot, Position
@@ -91,7 +91,7 @@ def fetch_portfolio_snapshot(
9191
buying_power = value if buying_power is None else buying_power + value
9292

9393
return PortfolioSnapshot(
94-
as_of=datetime.utcnow(),
94+
as_of=datetime.now(timezone.utc),
9595
total_equity=total_equity,
9696
buying_power=buying_power,
9797
positions=tuple(positions),

src/quant_platform_kit/schwab/market_data.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ def fetch_default_daily_price_history_candles(api_client: Any, symbol: str) -> l
128128

129129
def fetch_quotes(api_client: Any, symbols: list[str] | tuple[str, ...]) -> dict[str, QuoteSnapshot]:
130130
payload = decode_response_json(_request_with_retries(lambda: api_client.get_quotes(symbols)), "Quotes")
131-
as_of = datetime.utcnow()
131+
as_of = datetime.now(timezone.utc)
132132
snapshots: dict[str, QuoteSnapshot] = {}
133133
for symbol in symbols:
134134
symbol_payload = payload.get(symbol)

src/quant_platform_kit/schwab/portfolio.py

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

3-
from datetime import datetime
3+
from datetime import datetime, timezone
44
from typing import Any, Iterable
55

66
from quant_platform_kit.common.models import PortfolioSnapshot, Position
@@ -44,7 +44,7 @@ def fetch_account_snapshot(
4444
total_equity = cash_for_equity + sum(position.market_value for position in positions)
4545

4646
return PortfolioSnapshot(
47-
as_of=datetime.utcnow(),
47+
as_of=datetime.now(timezone.utc),
4848
total_equity=total_equity,
4949
buying_power=buying_power,
5050
cash_balance=cash_for_equity,

0 commit comments

Comments
 (0)