rebuild(runtime): govern market operations and job truth
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from backend.data.repository import MarketRepository
|
||||
from backend.database.connection import Database
|
||||
from backend.features.market.events import MarketEventService
|
||||
from backend.features.market.service import MarketService
|
||||
from backend.features.screener.service import ScreenerService
|
||||
from backend.jobs.service import JobAlreadyRunning, JobService
|
||||
|
||||
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
class OperationsError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class OperationsService:
|
||||
def __init__(
|
||||
self,
|
||||
database: Database,
|
||||
market_repository: MarketRepository,
|
||||
market: MarketService,
|
||||
events: MarketEventService,
|
||||
screener: ScreenerService,
|
||||
jobs: JobService,
|
||||
) -> None:
|
||||
self._database = database
|
||||
self._market_repository = market_repository
|
||||
self._market = market
|
||||
self._events = events
|
||||
self._screener = screener
|
||||
self._jobs = jobs
|
||||
|
||||
def refresh(
|
||||
self,
|
||||
requested_date: str | None = None,
|
||||
*,
|
||||
trigger: str = "administrator",
|
||||
realtime: bool = False,
|
||||
now: datetime | None = None,
|
||||
run_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
clock = now or datetime.now(SHANGHAI)
|
||||
target = _date(requested_date or clock.date().isoformat())
|
||||
key = run_key or f"{target}:{trigger}:{uuid4().hex}"
|
||||
try:
|
||||
return self._jobs.execute(
|
||||
kind="market.refresh",
|
||||
run_key=key,
|
||||
requested_date=target,
|
||||
trigger=trigger,
|
||||
operation=lambda: (
|
||||
self._market.sync_realtime(target, clock)
|
||||
if realtime
|
||||
else self._market.sync_snapshot(target)
|
||||
),
|
||||
stale_after_seconds=120,
|
||||
)
|
||||
except JobAlreadyRunning as exc:
|
||||
raise OperationsError("行情刷新任务正在运行") from exc
|
||||
|
||||
def backfill(self, start_date: str, end_date: str) -> dict[str, Any]:
|
||||
start, end = _date(start_date), _date(end_date)
|
||||
if start > end:
|
||||
raise OperationsError("开始日期不能晚于结束日期")
|
||||
with self._database.read() as connection:
|
||||
dates = self._market_repository.open_dates_between(connection, start, end)
|
||||
if not dates:
|
||||
raise OperationsError("所选范围没有交易日")
|
||||
if len(dates) > 15:
|
||||
raise OperationsError("单次最多回补15个交易日")
|
||||
|
||||
def operation() -> dict[str, Any]:
|
||||
rows = []
|
||||
for target in dates:
|
||||
rows.append(
|
||||
self.refresh(
|
||||
target,
|
||||
trigger="historical-backfill",
|
||||
run_key=f"{target}:archive-v1",
|
||||
)
|
||||
)
|
||||
return {
|
||||
"start_date": start,
|
||||
"end_date": end,
|
||||
"dates": list(dates),
|
||||
"completed": len(rows),
|
||||
"coverage": min(
|
||||
(float(row.get("coverage") or 0) for row in rows), default=0
|
||||
),
|
||||
"source_set": ["tushare", "local"],
|
||||
"output_version": "historical-backfill-v1",
|
||||
}
|
||||
|
||||
try:
|
||||
return self._jobs.execute(
|
||||
kind="market.backfill",
|
||||
run_key=f"{start}:{end}",
|
||||
requested_date=end,
|
||||
trigger="administrator",
|
||||
operation=operation,
|
||||
stale_after_seconds=15 * 60,
|
||||
)
|
||||
except JobAlreadyRunning as exc:
|
||||
raise OperationsError("历史回补任务正在运行") from exc
|
||||
|
||||
def supplement_events(
|
||||
self,
|
||||
trade_date: str,
|
||||
*,
|
||||
trigger: str = "administrator",
|
||||
run_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
target = _date(trade_date)
|
||||
try:
|
||||
return self._jobs.execute(
|
||||
kind="market.event-supplement",
|
||||
run_key=run_key or f"{target}:event-v1:{uuid4().hex}",
|
||||
requested_date=target,
|
||||
trigger=trigger,
|
||||
operation=lambda: self._events.supplement(target),
|
||||
stale_after_seconds=120,
|
||||
)
|
||||
except JobAlreadyRunning as exc:
|
||||
raise OperationsError("事件补充任务正在运行") from exc
|
||||
|
||||
def revise_event(self, user_id: int, **payload: Any) -> dict[str, Any]:
|
||||
return self._events.revise(user_id=user_id, **payload)
|
||||
|
||||
def event_history(self, trade_date: str, identifier: str) -> list[dict[str, Any]]:
|
||||
return self._events.history(trade_date, identifier)
|
||||
|
||||
def jobs(self) -> list[dict[str, Any]]:
|
||||
return self._jobs.latest()
|
||||
|
||||
def public_status(self) -> dict[str, Any]:
|
||||
latest = self._jobs.latest_for_kind("market.refresh")
|
||||
with self._database.read() as connection:
|
||||
snapshot = self._market_repository.latest_summary(
|
||||
connection, datetime.now(SHANGHAI).date().isoformat()
|
||||
)
|
||||
if latest and latest["status"] == "running":
|
||||
return {"state": "running", "message": "正在更新行情数据"}
|
||||
if snapshot is None:
|
||||
message = "等待管理员首次同步真实行情"
|
||||
if latest and latest["status"] == "failed":
|
||||
message = "最近行情更新失败,等待重新同步"
|
||||
return {"state": "degraded", "message": message}
|
||||
trade_date = str(snapshot["trade_date"])
|
||||
observed_at = str(snapshot["observed_at"])
|
||||
if latest and latest["status"] == "failed":
|
||||
message = "最近更新失败,继续使用最后成功行情"
|
||||
state = "degraded"
|
||||
elif str(snapshot["state"]) == "realtime":
|
||||
message = f"实时行情更新于 {observed_at[11:19]}"
|
||||
state = "ready"
|
||||
else:
|
||||
message = f"{trade_date} 收盘数据已就绪"
|
||||
state = "ready"
|
||||
return {
|
||||
"state": state,
|
||||
"message": message,
|
||||
"trade_date": trade_date,
|
||||
"observed_at": observed_at,
|
||||
}
|
||||
|
||||
def tick(self, now: datetime | None = None) -> None:
|
||||
clock = now or datetime.now(SHANGHAI)
|
||||
target = clock.date().isoformat()
|
||||
local_time = clock.time().replace(tzinfo=None)
|
||||
in_market = time(9, 15) <= local_time < time(11, 35) or time(
|
||||
12, 55
|
||||
) <= local_time < time(15, 5)
|
||||
is_open = self._is_open(target)
|
||||
if (
|
||||
in_market
|
||||
and is_open
|
||||
and self._jobs.ready_for_schedule(
|
||||
"market.refresh",
|
||||
now=clock,
|
||||
completed_after_seconds=8,
|
||||
failed_after_seconds=60,
|
||||
)
|
||||
):
|
||||
bucket = int(clock.timestamp() // 8)
|
||||
self._attempt(
|
||||
lambda: self.refresh(
|
||||
target,
|
||||
trigger="realtime-poll",
|
||||
realtime=True,
|
||||
now=clock,
|
||||
run_key=f"{target}:realtime:{bucket}",
|
||||
)
|
||||
)
|
||||
if (
|
||||
time(9, 15) <= local_time < time(9, 30)
|
||||
and is_open
|
||||
and self._jobs.ready_for_schedule(
|
||||
"auction.collect",
|
||||
now=clock,
|
||||
completed_after_seconds=8,
|
||||
failed_after_seconds=60,
|
||||
)
|
||||
):
|
||||
bucket = int(clock.timestamp() // 8)
|
||||
self._attempt(
|
||||
lambda: self._jobs.execute(
|
||||
kind="auction.collect",
|
||||
run_key=f"{target}:{bucket}",
|
||||
requested_date=target,
|
||||
trigger="auction-poll",
|
||||
operation=lambda: self._auction_result(target, clock),
|
||||
stale_after_seconds=60,
|
||||
)
|
||||
)
|
||||
if local_time >= time(15, 10) and is_open:
|
||||
final = self._jobs.latest_success("market.refresh", target)
|
||||
if not final or final["payload"].get("output_version") != "market-summary-v1":
|
||||
if self._jobs.ready_for_schedule(
|
||||
"market.refresh",
|
||||
now=clock,
|
||||
completed_after_seconds=0,
|
||||
failed_after_seconds=300,
|
||||
):
|
||||
retry_bucket = int(clock.timestamp() // 300)
|
||||
self._attempt(
|
||||
lambda: self.refresh(
|
||||
target,
|
||||
trigger="after-close",
|
||||
run_key=f"{target}:final:{retry_bucket}",
|
||||
)
|
||||
)
|
||||
return
|
||||
self._after_close(target, clock)
|
||||
|
||||
def _after_close(self, target: str, clock: datetime) -> None:
|
||||
if (
|
||||
not self._jobs.latest_success("market.event-supplement", target)
|
||||
and self._jobs.ready_for_schedule(
|
||||
"market.event-supplement",
|
||||
now=clock,
|
||||
completed_after_seconds=300,
|
||||
failed_after_seconds=300,
|
||||
)
|
||||
):
|
||||
self._attempt(
|
||||
lambda: self.supplement_events(
|
||||
target, trigger="after-close", run_key=f"{target}:event-v1"
|
||||
)
|
||||
)
|
||||
if (
|
||||
not self._jobs.latest_success("screener.after-close", target)
|
||||
and self._jobs.ready_for_schedule(
|
||||
"screener.after-close",
|
||||
now=clock,
|
||||
completed_after_seconds=300,
|
||||
failed_after_seconds=300,
|
||||
)
|
||||
):
|
||||
self._attempt(
|
||||
lambda: self._jobs.execute(
|
||||
kind="screener.after-close",
|
||||
run_key=f"{target}:catalog-v1",
|
||||
requested_date=target,
|
||||
trigger="after-close",
|
||||
operation=self._screener_result,
|
||||
stale_after_seconds=15 * 60,
|
||||
)
|
||||
)
|
||||
|
||||
def _auction_result(self, target: str, clock: datetime) -> dict[str, Any]:
|
||||
result = self._market.sync_insight("auction", target, 0)
|
||||
return {
|
||||
"trade_date": result.get("trade_date") or target,
|
||||
"coverage": result.get("coverage"),
|
||||
"phase": result.get("phase"),
|
||||
"observed_at": result.get("observed_at") or clock.isoformat(timespec="seconds"),
|
||||
"source_set": ["ifind", "tushare", "local"],
|
||||
"output_version": "auction-snapshot-v1",
|
||||
}
|
||||
|
||||
def _screener_result(self) -> dict[str, Any]:
|
||||
result = self._screener.run_after_close() or {}
|
||||
return {
|
||||
**result,
|
||||
"coverage": result.get("coverage"),
|
||||
"source_set": ["tushare", "local"],
|
||||
"output_version": "screener-runs-v1",
|
||||
}
|
||||
|
||||
def _is_open(self, target: str) -> bool:
|
||||
with self._database.read() as connection:
|
||||
return target in self._market_repository.open_dates(connection, target, 1)
|
||||
|
||||
@staticmethod
|
||||
def _attempt(operation) -> None:
|
||||
try:
|
||||
operation()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def _date(value: str) -> str:
|
||||
try:
|
||||
return date.fromisoformat(value).isoformat()
|
||||
except ValueError as exc:
|
||||
raise OperationsError("日期格式无效") from exc
|
||||
Reference in New Issue
Block a user