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"
|
||||
|
||||
Reference in New Issue
Block a user