rebuild(runtime): govern market operations and job truth
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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