rebuild(runtime): govern market operations and job truth

This commit is contained in:
leefer
2026-07-30 10:14:29 +08:00
parent 4fc8691eee
commit d8f0dd930c
39 changed files with 2224 additions and 79 deletions
+199
View File
@@ -0,0 +1,199 @@
from __future__ import annotations
import json
from datetime import date, datetime
from typing import Any
from zoneinfo import ZoneInfo
from backend.data.gateway import DataGateway
from backend.data.repository import MarketRepository
from backend.database.connection import Database
SHANGHAI = ZoneInfo("Asia/Shanghai")
EVENT_KEYS = {"limit_up": "limits", "broken": "broken", "limit_down": "down_limits"}
class MarketEventError(RuntimeError):
pass
class MarketEventService:
def __init__(
self, database: Database, repository: MarketRepository, gateway: DataGateway
) -> None:
self._database = database
self._repository = repository
self._gateway = gateway
def supplement(self, trade_date: str) -> dict[str, Any]:
target = _date(trade_date)
snapshot = self._snapshot(target)
expected = {
(str(row.get("identifier") or ""), event_type)
for event_type, key in EVENT_KEYS.items()
for row in snapshot.get(key) or []
if row.get("identifier")
}
if not expected:
return {
"trade_date": target,
"events": 0,
"updated": 0,
"coverage": 1.0,
"source_set": ["local"],
"output_version": "event-revisions-v1",
}
result = self._gateway.event_reasons(target)
now = datetime.now(SHANGHAI).isoformat(timespec="seconds")
updated = 0
matched: set[tuple[str, str]] = set()
with self._database.transaction() as connection:
current = {
(str(row["identifier"]), str(row["event_type"])): dict(row)
for row in self._repository.event_revisions(connection, target)
}
for row in result.rows:
identity = (str(row.get("identifier") or ""), str(row.get("event_type") or ""))
if identity not in expected:
continue
useful = any(
row.get(field) not in (None, "")
for field in ("reason", "first_time", "last_time", "open_times")
)
if not useful:
continue
matched.add(identity)
values = _revision_values(row)
existing = current.get(identity)
if existing and int(existing["priority"]) >= 100:
continue
if existing and all(existing.get(key) == value for key, value in values.items()):
continue
self._repository.save_event_revision(
connection,
trade_date=target,
identifier=identity[0],
event_type=identity[1],
source="ifind",
priority=20,
created_by=None,
created_at=now,
**values,
)
updated += 1
return {
"trade_date": target,
"events": len(expected),
"matched": len(matched),
"updated": updated,
"coverage": round(len(matched) / len(expected), 4),
"source_set": ["ifind", "local"],
"output_version": "event-revisions-v1",
}
def revise(
self,
*,
trade_date: str,
identifier: str,
event_type: str,
reason: str,
first_time: str,
last_time: str,
open_times: int | None,
user_id: int,
) -> dict[str, Any]:
target = _date(trade_date)
normalized = identifier.strip().upper()
if event_type not in EVENT_KEYS:
raise MarketEventError("事件类型无效")
snapshot = self._snapshot(target)
exists = any(
str(row.get("identifier") or "") == normalized
for row in snapshot.get(EVENT_KEYS[event_type]) or []
)
if not exists:
raise MarketEventError("该股票不在所选日期的对应事件池中")
normalized_reason = " ".join(reason.split())
if not 1 <= len(normalized_reason) <= 200:
raise MarketEventError("原因应为1至200个字符")
values = {
"reason": normalized_reason,
"first_time": _time(first_time),
"last_time": _time(last_time),
"open_times": open_times,
}
with self._database.transaction() as connection:
revision_id = self._repository.save_event_revision(
connection,
trade_date=target,
identifier=normalized,
event_type=event_type,
source="admin",
priority=100,
created_by=user_id,
created_at=datetime.now(SHANGHAI).isoformat(timespec="seconds"),
**values,
)
return {"id": revision_id, "trade_date": target, "identifier": normalized, **values}
def history(self, trade_date: str, identifier: str) -> list[dict[str, Any]]:
target = _date(trade_date)
with self._database.read() as connection:
rows = self._repository.event_revision_history(
connection, target, identifier.strip().upper()
)
return [dict(row) for row in rows]
def _snapshot(self, trade_date: str) -> dict[str, Any]:
with self._database.read() as connection:
row = self._repository.latest_summary(connection, trade_date)
if row is None or str(row["trade_date"]) != trade_date:
raise MarketEventError("所选日期没有正式行情快照")
return json.loads(str(row["payload_json"]))
def apply_event_revisions(
payload: dict[str, Any], revisions: tuple[Any, ...]
) -> dict[str, Any]:
index = {
(str(row["identifier"]), str(row["event_type"])): row for row in revisions
}
for event_type, key in EVENT_KEYS.items():
for item in payload.get(key) or []:
revision = index.get((str(item.get("identifier") or ""), event_type))
if revision is None:
continue
for field in ("reason", "first_time", "last_time", "open_times"):
value = revision[field]
if value not in (None, ""):
item[field] = value
item["reason_source"] = str(revision["source"])
item["revision_id"] = int(revision["id"])
return payload
def _revision_values(row: dict[str, Any]) -> dict[str, Any]:
return {
"reason": str(row.get("reason") or "").strip(),
"first_time": _time(str(row.get("first_time") or "")),
"last_time": _time(str(row.get("last_time") or "")),
"open_times": row.get("open_times") if isinstance(row.get("open_times"), int) else None,
}
def _date(value: str) -> str:
try:
return date.fromisoformat(value).isoformat()
except ValueError as exc:
raise MarketEventError("日期格式无效") from exc
def _time(value: str) -> str:
normalized = value.strip()
if not normalized:
return ""
try:
return datetime.strptime(normalized, "%H:%M").strftime("%H:%M")
except ValueError as exc:
raise MarketEventError("事件时间格式应为HH:MM") from exc
+1 -1
View File
@@ -86,7 +86,7 @@ def sync_snapshot(
_principal: AdminWritePrincipal,
requested_date: Annotated[str | None, Query(alias="date")] = None,
) -> dict:
return request.app.state.container.market.sync_snapshot(requested_date)
return request.app.state.container.operations.refresh(requested_date)
@router.get("/workspaces/{key}", response_model=MarketWorkspaceResponse)
+6
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from backend.data.gateway import DataGateway, MarketDataUnavailable
@@ -96,6 +97,11 @@ class MarketService:
def sync_snapshot(self, requested_date: str | None = None) -> dict[str, Any]:
return self._call(self._snapshots.sync, requested_date)
def sync_realtime(
self, requested_date: str | None = None, now: datetime | None = None
) -> dict[str, Any]:
return self._call(self._snapshots.sync_realtime, requested_date, now)
def workspace(self, key: str, requested_date: str | None = None) -> dict[str, Any]:
return self._call(self._snapshots.workspace, key, requested_date)
+67 -2
View File
@@ -34,7 +34,7 @@ def build_snapshot(
up_count = sum(_number(row.get("pct_chg")) > 0 for row in daily_rows)
down_count = sum(_number(row.get("pct_chg")) < 0 for row in daily_rows)
flat_count = len(daily_rows) - up_count - down_count
amount = sum(_number(row.get("amount")) * 1000 for row in daily_rows)
amount = sum(_amount_yuan(row) for row in daily_rows)
seal_rate = len(limits) / max(len(limits) + len(broken), 1) * 100
sectors = _sectors(limits)
previous_sectors = _sectors(previous_limits)
@@ -63,6 +63,66 @@ def build_snapshot(
}
def build_realtime_inputs(
inputs: dict[str, ProviderResult | dict[str, Any]],
directory: dict[str, dict[str, Any]],
) -> dict[str, ProviderResult | dict[str, Any]]:
daily_result = inputs.get("daily")
limits_result = inputs.get("price_limits")
previous_result = inputs.get("previous_limit_up")
if not isinstance(daily_result, ProviderResult) or not isinstance(
limits_result, ProviderResult
):
return inputs
prices = {str(row.get("ts_code") or ""): row for row in limits_result.rows}
previous = {
str(row.get("ts_code") or ""): row
for row in previous_result.rows
} if isinstance(previous_result, ProviderResult) else {}
pools: dict[str, list[dict[str, Any]]] = {
"limit_up": [],
"limit_down": [],
"broken": [],
}
for quote in daily_result.rows:
identifier = str(quote.get("ts_code") or "")
price = prices.get(identifier) or {}
current = _number(quote.get("close"))
high = _number(quote.get("high"))
up_limit = _number(price.get("up_limit"))
down_limit = _number(price.get("down_limit"))
event_type = ""
if up_limit > 0 and current >= up_limit - 0.001:
event_type = "limit_up"
elif up_limit > 0 and high >= up_limit - 0.001:
event_type = "broken"
elif down_limit > 0 and current <= down_limit + 0.001:
event_type = "limit_down"
if not event_type:
continue
identity = directory.get(identifier) or {}
prior_streak = int(_number(previous.get(identifier, {}).get("limit_times")))
pools[event_type].append(
{
**quote,
"name": str(quote.get("name") or identity.get("name") or ""),
"industry": str(identity.get("sector") or ""),
"first_time": "",
"last_time": "",
"open_times": 0,
"limit_times": prior_streak + 1 if event_type == "limit_up" else 1,
"fd_amount": 0,
}
)
metadata = daily_result.metadata
return {
**inputs,
"limit_up": ProviderResult(tuple(pools["limit_up"]), metadata),
"limit_down": ProviderResult(tuple(pools["limit_down"]), metadata),
"broken": ProviderResult(tuple(pools["broken"]), metadata),
}
def _rows(
inputs: dict[str, ProviderResult | dict[str, Any]], key: str
) -> tuple[dict[str, Any], ...]:
@@ -74,7 +134,7 @@ def _pool(rows: tuple[dict[str, Any], ...], status: str) -> list[dict[str, Any]]
result = []
for row in rows:
identifier = str(row.get("ts_code") or "")
amount = _number(row.get("amount")) * 1000
amount = _amount_yuan(row)
result.append(
{
"identifier": identifier,
@@ -238,6 +298,11 @@ def _number(value: Any, default: float = 0.0) -> float:
return default
def _amount_yuan(row: dict[str, Any]) -> float:
amount = _number(row.get("amount"))
return amount if row.get("amount_unit") == "yuan" else amount * 1000
def _time(value: Any) -> str:
text = str(value or "").strip().replace(":", "")
if len(text) < 4 or not text[:4].isdigit():
+84 -1
View File
@@ -11,7 +11,8 @@ from backend.data.providers.base import ProviderError
from backend.data.repository import MarketRepository
from backend.data.sentiment import calculate_sentiment
from backend.database.connection import Database
from backend.features.market.snapshot import build_snapshot
from backend.features.market.events import apply_event_revisions
from backend.features.market.snapshot import build_realtime_inputs, build_snapshot
SHANGHAI = ZoneInfo("Asia/Shanghai")
@@ -94,6 +95,79 @@ class MarketSnapshotService:
"limit_down": len(snapshot["down_limits"]),
"broken": len(snapshot["broken"]),
"temperature": sentiment["score"],
"source_set": ["tushare", "local"],
"output_version": "market-summary-v1",
}
def sync_realtime(
self, requested_date: str | None = None, now: datetime | None = None
) -> dict[str, Any]:
clock = now or datetime.now(SHANGHAI)
target = _date(requested_date or clock.date().isoformat())
if target != clock.date().isoformat():
raise SnapshotSyncError("盘中任务只允许同步当前交易日")
local_time = clock.time().replace(tzinfo=None)
in_window = time(9, 15) <= local_time < time(11, 35) or time(
12, 55
) <= local_time < time(15, 5)
if not in_window:
raise SnapshotSyncError("当前不在盘中行情刷新窗口")
with self._database.read() as connection:
dates = self._repository.open_dates(connection, target, 2)
active_count = self._repository.active_stock_count(connection)
if len(dates) < 2 or dates[0] != target:
raise SnapshotSyncError("当前日期不是有效交易日")
if active_count <= 0:
raise SnapshotSyncError("请先同步股票目录")
try:
raw = self._gateway.realtime_snapshot_inputs(target, dates[1])
except (ProviderError, MarketDataUnavailable) as exc:
raise SnapshotSyncError("盘中行情读取失败,已保留最后成功快照") from exc
daily = raw.get("daily")
price_limits = raw.get("price_limits")
if not isinstance(daily, ProviderResult):
raise SnapshotSyncError("盘中行情缺失,已保留最后成功快照")
coverage = len(daily.rows) / active_count
if coverage < 0.9:
raise SnapshotSyncError(
f"盘中行情覆盖率仅{coverage * 100:.1f}%,已保留最后成功快照"
)
if not isinstance(price_limits, ProviderResult):
raise SnapshotSyncError("盘中涨跌停价格缺失,已保留最后成功快照")
limit_coverage = len(price_limits.rows) / max(len(daily.rows), 1)
if limit_coverage < 0.95:
raise SnapshotSyncError("盘中涨跌停价格覆盖不足,已保留最后成功快照")
inputs = build_realtime_inputs(raw, self._gateway.stock_directory())
snapshot = build_snapshot(target, dates[1], inputs)
with self._database.read() as connection:
rows = self._repository.summaries(connection, dates[1], 250)
history = [json.loads(str(row["payload_json"])) for row in rows]
sentiment = calculate_sentiment(snapshot, history)
snapshot["sentiment"] = sentiment
snapshot.update(snapshot["overview"])
snapshot["temperature"] = sentiment["score"]
observed_at = clock.isoformat(timespec="seconds")
with self._database.transaction() as connection:
self._repository.save_summary(
connection,
trade_date=target,
observed_at=observed_at,
state=SnapshotState.REALTIME.value,
source="tushare",
coverage=min(coverage, 1),
payload=snapshot,
)
return {
"trade_date": target,
"observed_at": observed_at,
"coverage": round(min(coverage, 1), 4),
"stocks": len(daily.rows),
"limit_up": len(snapshot["limits"]),
"limit_down": len(snapshot["down_limits"]),
"broken": len(snapshot["broken"]),
"temperature": sentiment["score"],
"source_set": ["tushare", "local"],
"output_version": "market-summary-realtime-v1",
}
def workspace(self, key: str, requested_date: str | None = None) -> dict[str, Any]:
@@ -104,6 +178,9 @@ class MarketSnapshotService:
if row is None:
return {"trade_date": None, "message": "等待管理员首次同步真实收盘行情"}
payload = json.loads(str(row["payload_json"]))
with self._database.read() as connection:
revisions = self._repository.event_revisions(connection, str(row["trade_date"]))
apply_event_revisions(payload, revisions)
response: dict[str, Any] = {
"trade_date": str(row["trade_date"]),
"observed_at": str(row["observed_at"]),
@@ -173,6 +250,12 @@ class MarketSnapshotService:
).fetchone()
factors = json.loads(str(factor_row["payload_json"])) if factor_row else {}
snapshot = json.loads(str(summary["payload_json"])) if summary else {}
if summary:
with self._database.read() as connection:
revisions = self._repository.event_revisions(
connection, str(summary["trade_date"])
)
apply_event_revisions(snapshot, revisions)
event = (
_entity_event(snapshot, entity.identifier, entity.code)
if entity_type == "stock"
@@ -0,0 +1,3 @@
from backend.features.operations.service import OperationsService
__all__ = ["OperationsService"]
@@ -0,0 +1,96 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Path, Query, Request
from backend.data.gateway import MarketDataUnavailable
from backend.data.providers.base import ProviderError
from backend.features.accounts.auth import (
AdminPrincipal,
AdminWritePrincipal,
AuthenticatedPrincipal,
)
from backend.features.market.events import MarketEventError
from backend.features.operations.schemas import (
BackfillInput,
EventRevisionInput,
JobRunResponse,
OperationResult,
RuntimeStatusResponse,
)
from backend.features.operations.service import OperationsError
from backend.http.errors import AppError
router = APIRouter(tags=["operations"])
@router.get("/operations/status", response_model=RuntimeStatusResponse)
def public_status(request: Request, _principal: AuthenticatedPrincipal) -> dict:
return request.app.state.container.operations.public_status()
@router.get("/admin/operations/jobs", response_model=list[JobRunResponse])
def jobs(request: Request, _principal: AdminPrincipal) -> list[dict]:
return request.app.state.container.operations.jobs()
@router.post("/admin/operations/backfill", response_model=OperationResult)
def backfill(
payload: BackfillInput, request: Request, _principal: AdminWritePrincipal
) -> dict:
try:
return request.app.state.container.operations.backfill(
payload.start_date.isoformat(), payload.end_date.isoformat()
)
except OperationsError as exc:
raise AppError("invalid_operation", str(exc), 400) from exc
@router.post("/admin/operations/events/supplement", response_model=OperationResult)
def supplement_events(
request: Request,
_principal: AdminWritePrincipal,
trade_date: Annotated[str, Query(alias="date")],
) -> dict:
try:
return request.app.state.container.operations.supplement_events(trade_date)
except (OperationsError, MarketEventError) as exc:
raise AppError("invalid_operation", str(exc), 400) from exc
except (MarketDataUnavailable, ProviderError) as exc:
raise AppError("event_service_unavailable", str(exc), 503) from exc
@router.put(
"/admin/operations/events/{trade_date}/{identifier}",
response_model=OperationResult,
)
def revise_event(
payload: EventRevisionInput,
request: Request,
principal: AdminWritePrincipal,
trade_date: Annotated[str, Path()],
identifier: Annotated[str, Path(min_length=1, max_length=20)],
) -> dict:
try:
return request.app.state.container.operations.revise_event(
principal.user.id,
trade_date=trade_date,
identifier=identifier,
**payload.model_dump(),
)
except (OperationsError, MarketEventError) as exc:
raise AppError("invalid_event_revision", str(exc), 400) from exc
@router.get(
"/admin/operations/events/{trade_date}/{identifier}/history",
response_model=list[OperationResult],
)
def event_history(
request: Request,
_principal: AdminPrincipal,
trade_date: Annotated[str, Path()],
identifier: Annotated[str, Path(min_length=1, max_length=20)],
) -> list[dict]:
return request.app.state.container.operations.event_history(trade_date, identifier)
@@ -0,0 +1,49 @@
from __future__ import annotations
from datetime import date
from typing import Any, Literal
from pydantic import BaseModel, Field
class BackfillInput(BaseModel):
start_date: date
end_date: date
class EventRevisionInput(BaseModel):
event_type: Literal["limit_up", "broken", "limit_down"]
reason: str = Field(min_length=1, max_length=200)
first_time: str = Field(default="", max_length=5)
last_time: str = Field(default="", max_length=5)
open_times: int | None = Field(default=None, ge=0)
class OperationResult(BaseModel):
model_config = {"extra": "allow"}
class JobRunResponse(BaseModel):
id: int
kind: str
run_key: str
requested_date: str
trigger: str
status: Literal["running", "completed", "failed"]
attempt: int
started_at: str
finished_at: str | None
duration_ms: int | None
coverage: float | None
source_set: list[str]
output_version: str
payload: dict[str, Any]
error_code: str
error_message: str
class RuntimeStatusResponse(BaseModel):
state: Literal["idle", "running", "ready", "degraded"]
message: str
trade_date: str | None = None
observed_at: str | None = None
+312
View File
@@ -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