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