313 lines
11 KiB
Python
313 lines
11 KiB
Python
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
|