rebuild(stage-6): deliver emotion and market pools

This commit is contained in:
leefer
2026-07-30 03:03:53 +08:00
parent 889963862a
commit 59f6011ae8
30 changed files with 1751 additions and 9 deletions
+24
View File
@@ -8,8 +8,10 @@ from backend.features.accounts.auth import AdminWritePrincipal, AuthenticatedPri
from backend.features.market.schemas import (
ChartResponse,
MarketSummaryResponse,
MarketWorkspaceResponse,
ReferenceSyncResponse,
SearchResponse,
SnapshotSyncResponse,
TradeContextResponse,
)
@@ -57,3 +59,25 @@ def chart(
@router.post("/reference-sync", response_model=ReferenceSyncResponse)
def refresh_reference(request: Request, _principal: AdminWritePrincipal) -> dict[str, int | str]:
return request.app.state.container.market.refresh_reference()
@router.post("/snapshot-sync", response_model=SnapshotSyncResponse)
def sync_snapshot(
request: Request,
_principal: AdminWritePrincipal,
requested_date: Annotated[str | None, Query(alias="date")] = None,
) -> dict:
return request.app.state.container.market.sync_snapshot(requested_date)
@router.get("/workspaces/{key}", response_model=MarketWorkspaceResponse)
def workspace(
request: Request,
_principal: AuthenticatedPrincipal,
key: Annotated[
Literal["emotion", "pool", "broken", "limit-down", "yesterday", "performance"],
Path(),
],
requested_date: Annotated[str | None, Query(alias="date")] = None,
) -> dict:
return request.app.state.container.market.workspace(key, requested_date)
+22
View File
@@ -69,3 +69,25 @@ class ReferenceSyncResponse(BaseModel):
calendar_days: int = Field(ge=1)
entities: int = Field(ge=1)
observed_at: datetime
class SnapshotSyncResponse(BaseModel):
trade_date: str
observed_at: datetime
coverage: float
stocks: int
limit_up: int
limit_down: int
broken: int
temperature: int
class MarketWorkspaceResponse(BaseModel):
trade_date: str | None
observed_at: datetime | None = None
carried_forward: bool = False
message: str = ""
overview: dict[str, Any] = Field(default_factory=dict)
sentiment: dict[str, Any] | None = None
history: list[dict[str, Any]] | None = None
items: list[dict[str, Any]] | None = None
+270
View File
@@ -0,0 +1,270 @@
from __future__ import annotations
from statistics import mean, median
from typing import Any
WEIGHTS = {
"breadth": 20,
"limit_ecology": 25,
"profit_effect": 30,
"ladder_structure": 15,
"liquidity": 10,
}
def calculate_sentiment(snapshot: dict[str, Any], history: list[dict[str, Any]]) -> dict[str, Any]:
stats = _stats(snapshot)
historical = [_stats(item) for item in history[-250:]]
breadth = _clamp(stats["breadth_ratio"])
limit_strength = _adaptive(
stats["limit_up"], _linear(stats["limit_up"], 10, 100), _series(historical, "limit_up")
)
down_pressure = _adaptive(
stats["limit_down"], _linear(stats["limit_down"], 0, 50), _series(historical, "limit_down")
)
down_relief = 100 - down_pressure
seal_quality = _linear(stats["seal_rate"], 35, 90)
ecology = limit_strength * 0.35 + seal_quality * 0.35 + down_relief * 0.30
systemic_health = breadth * 0.60 + down_relief * 0.40
gate = 1 if systemic_health >= 35 else 0.35 + systemic_health / 35 * 0.65
profit = _profit(stats)
max_height = _adaptive(
stats["max_height"],
_linear(stats["max_height"], 1, 7),
_series(historical, "max_height"),
)
continuation = (stats["second_board"] + stats["three_plus"]) / max(stats["limit_up"], 1) * 100
three_density = stats["three_plus"] / max(stats["limit_up"], 1) * 100
three_score = _adaptive(
stats["three_plus"], _clamp(three_density * 5), _series(historical, "three_plus")
)
ladder = (
max_height * 0.30
+ _clamp(continuation * 3) * 0.25
+ three_score * 0.25
+ stats["ladder_completeness"] * 0.20
)
prior_amounts = [item["amount"] for item in historical[-20:] if item["amount"] > 0]
baseline = mean(prior_amounts) if prior_amounts else stats["amount"] or 1
amount_ratio = stats["amount"] / max(baseline, 1)
amount_score = _clamp(50 + (amount_ratio - 1) * 100)
limit_share = stats["limit_amount"] / max(stats["amount"], 1) * 100
liquidity = amount_score * 0.70 + _clamp(limit_share * 20) * 0.30
components = {
"breadth": breadth,
"limit_ecology": ecology,
"profit_effect": profit,
"ladder_structure": ladder,
"liquidity": liquidity,
}
score = round(sum(components[key] * weight / 100 for key, weight in WEIGHTS.items()) * gate)
extreme = stats["breadth_ratio"] <= 15 and stats["limit_down"] >= 100
if extreme:
score = min(score, 15)
elif stats["breadth_ratio"] <= 25 and stats["limit_down"] >= 50:
score = min(score, 24)
prior_sentiments = [item.get("sentiment") or {} for item in history[-3:]]
prior_scores = [
float(item["score"]) for item in prior_sentiments if item.get("score") is not None
]
momentum = score - mean(prior_scores) if prior_scores else 0
direction = "升温" if momentum > 3 else "降温" if momentum < -3 else "持平"
previous = prior_sentiments[-1] if prior_sentiments else None
day_change = (
score - float(previous["score"]) if previous and previous.get("score") is not None else 0
)
signal = _phase_signal(score, momentum, profit)
phase, reason = _phase(
previous, score, day_change, systemic_health, profit, ecology, signal, extreme
)
fermentation_ready = signal == "发酵" and score >= 45 and profit >= 45 and systemic_health >= 35
previous_count = int(previous.get("fermentation_signal_count") or 0) if previous else 0
fermentation_count = previous_count + 1 if fermentation_ready else 0
history_days = len(history)
confidence = min(95, round(55 + min(history_days, 20) * 1.25 + (15 if phase == signal else 7)))
labels = {
"breadth": "市场宽度",
"limit_ecology": "涨停生态",
"profit_effect": "赚钱效应",
"ladder_structure": "连板结构",
"liquidity": "成交活跃度",
}
return {
"score": score,
"label": _label(score),
"direction": direction,
"momentum": round(momentum, 1),
"day_change": round(day_change, 1),
"phase": phase,
"phase_signal": signal,
"transition_reason": reason,
"fermentation_signal_count": fermentation_count,
"confidence": confidence,
"history_days": history_days,
"systemic_health": round(systemic_health, 1),
"components": [
{
"key": key,
"label": labels[key],
"score": round(value, 1),
"weight": WEIGHTS[key],
}
for key, value in components.items()
],
"stats": stats,
}
def _stats(snapshot: dict[str, Any]) -> dict[str, float]:
overview = snapshot.get("overview") or {}
limits = snapshot.get("limits") or []
yesterday = snapshot.get("yesterday_limits") or []
streaks = [max(1, int(_number(row.get("streak"), 1))) for row in limits]
levels = set(streaks)
max_height = max(streaks, default=0)
active = _number(overview.get("up_count")) + _number(overview.get("down_count"))
changes = [_number(row.get("current_change")) for row in yesterday]
previous_count = len(yesterday)
return {
"breadth_ratio": _number(overview.get("up_count")) / max(active, 1) * 100,
"limit_up": _number(overview.get("limit_up")),
"limit_down": _number(overview.get("limit_down")),
"broken": _number(overview.get("broken")),
"seal_rate": _number(overview.get("seal_rate")),
"amount": _number(overview.get("amount")),
"limit_amount": sum(_number(row.get("amount")) for row in limits),
"second_board": sum(streak == 2 for streak in streaks),
"three_plus": sum(streak >= 3 for streak in streaks),
"max_height": max_height,
"ladder_completeness": (
sum(level in levels for level in range(1, max_height + 1)) / max_height * 100
if max_height
else 0
),
"previous_count": previous_count,
"positive_rate": sum(change > 0 for change in changes) / max(previous_count, 1) * 100,
"advance_rate": sum(row.get("outcome") == "晋级" for row in yesterday)
/ max(previous_count, 1)
* 100,
"average_change": mean(changes) if changes else 0,
"median_change": median(changes) if changes else 0,
"severe_loss_rate": sum(change <= -5 for change in changes) / max(previous_count, 1) * 100,
"previous_down_rate": sum(row.get("outcome") == "跌停" for row in yesterday)
/ max(previous_count, 1)
* 100,
}
def _profit(stats: dict[str, float]) -> float:
if not stats["previous_count"]:
return 50
median_score = _clamp(50 + stats["median_change"] * 7)
average_score = _clamp(50 + stats["average_change"] * 6)
advance_score = _clamp(stats["advance_rate"] * 2.5)
loss_safety = _clamp(100 - stats["severe_loss_rate"] * 3)
down_safety = _clamp(100 - stats["previous_down_rate"] * 7)
tail = loss_safety * 0.70 + down_safety * 0.30
return (
stats["positive_rate"] * 0.30
+ median_score * 0.25
+ average_score * 0.10
+ advance_score * 0.20
+ tail * 0.15
)
def _phase_signal(score: float, momentum: float, profit: float) -> str:
if score < 25:
return "修复" if momentum > 3 else "冰点"
if score < 45:
return "修复" if momentum > 3 else "退潮"
if score >= 80:
return "高潮" if momentum >= -2 and profit >= 60 else "分化"
if score >= 65:
return "分化" if momentum < -3 or profit < 50 else "发酵"
if momentum < -5:
return "退潮"
return "发酵" if momentum >= 0 and profit >= 45 else "分化"
def _phase(previous, score, change, health, profit, ecology, signal, extreme):
if not previous:
return signal, "首个连续交易日,采用原始阶段信号"
prior = str(previous.get("phase") or signal)
if extreme:
return "冰点", "市场宽度与跌停数量触发极端冰点"
recovery = change >= 6 and score >= 25 and health >= 24
climax = score >= 80 and profit >= 60 and health >= 60 and ecology >= 70
if prior in {"冰点", "退潮"}:
if score < 25:
return "冰点", "市场仍处于冰点区间"
return ("修复", "出现有效回升") if recovery else (prior, "尚未形成有效修复")
if prior == "修复":
if score < 25:
return "冰点", "修复失败并跌入冰点"
if change <= -6 and score < 45:
return "退潮", "修复失败且显著降温"
prior_signal = int(previous.get("fermentation_signal_count") or 0)
if signal == "发酵" and prior_signal >= 1:
return "发酵", "发酵条件连续两个交易日成立"
return "修复", "修复延续,等待发酵确认"
if prior == "发酵":
if score < 45 and (change < 0 or health < 35):
return "退潮", "温度与系统健康度转弱"
if climax:
return "高潮", "温度、赚钱效应与涨停生态达到高潮条件"
return ("分化", "发酵阶段出现降温") if signal in {"分化", "退潮"} else ("发酵", "发酵延续")
if prior == "高潮":
if climax:
return "高潮", "高潮条件继续成立"
return ("退潮", "风险快速释放") if score < 45 or health < 30 else ("分化", "高潮条件消退")
if score < 25:
return "冰点", "分化继续恶化至冰点"
if score < 45 or health < 30:
return "退潮", "分化后继续转弱"
return "分化", "分化延续,等待方向确认"
def _label(score: float) -> str:
if score >= 80:
return "情绪高涨"
if score >= 60:
return "情绪偏强"
if score >= 40:
return "情绪中性"
if score >= 20:
return "情绪偏弱"
return "情绪冰点"
def _series(rows: list[dict[str, float]], key: str) -> list[float]:
return [row[key] for row in rows]
def _adaptive(value: float, fixed: float, history: list[float]) -> float:
if len(history) < 20:
return fixed
below = sum(item < value for item in history[-250:])
equal = sum(item == value for item in history[-250:])
percentile = (below + equal * 0.5) / len(history[-250:]) * 100
return fixed * 0.25 + percentile * 0.75
def _linear(value: float, low: float, high: float) -> float:
return _clamp((value - low) / (high - low) * 100) if high > low else 50
def _clamp(value: float) -> float:
return min(100, max(0, value))
def _number(value: Any, default: float = 0.0) -> float:
try:
number = float(value)
return number if number == number else default
except (TypeError, ValueError):
return default
+10 -2
View File
@@ -5,12 +5,14 @@ from typing import Any
from backend.data.gateway import DataGateway, MarketDataUnavailable
from backend.data.providers.base import ProviderError
from backend.data.quality import DataQualityError
from backend.features.market.sync import MarketSnapshotService, SnapshotSyncError
from backend.http.errors import AppError
class MarketService:
def __init__(self, gateway: DataGateway) -> None:
def __init__(self, gateway: DataGateway, snapshots: MarketSnapshotService) -> None:
self._gateway = gateway
self._snapshots = snapshots
def context(self, requested_date: str | None = None) -> dict[str, Any]:
context = self._call(self._gateway.trade_context, requested_date)
@@ -76,11 +78,17 @@ class MarketService:
def refresh_reference(self) -> dict[str, int | str]:
return self._call(self._gateway.refresh_reference)
def sync_snapshot(self, requested_date: str | None = None) -> dict[str, Any]:
return self._call(self._snapshots.sync, requested_date)
def workspace(self, key: str, requested_date: str | None = None) -> dict[str, Any]:
return self._call(self._snapshots.workspace, key, requested_date)
@staticmethod
def _call(function, *args):
try:
return function(*args)
except (MarketDataUnavailable, ProviderError, DataQualityError) as exc:
except (MarketDataUnavailable, ProviderError, DataQualityError, SnapshotSyncError) as exc:
raise AppError("market_data_unavailable", str(exc), 503) from exc
+176
View File
@@ -0,0 +1,176 @@
from __future__ import annotations
from statistics import mean
from typing import Any
from backend.data.contracts import ProviderResult
def build_snapshot(
trade_date: str,
previous_trade_date: str,
inputs: dict[str, ProviderResult | dict[str, Any]],
) -> dict[str, Any]:
daily_rows = _rows(inputs, "daily")
daily = {str(row.get("ts_code") or ""): row for row in daily_rows}
limits = _pool(_rows(inputs, "limit_up"), "涨停")
broken = _pool(_rows(inputs, "broken"), "炸板")
down_limits = _pool(_rows(inputs, "limit_down"), "跌停")
price_limits = {str(row.get("ts_code") or ""): row for row in _rows(inputs, "price_limits")}
for row in broken:
up_limit = _number(price_limits.get(row["identifier"], {}).get("up_limit"))
row["distance_to_limit"] = (
round((up_limit - row["price"]) / up_limit * 100, 2) if up_limit else None
)
yesterday = _yesterday(
_pool(_rows(inputs, "previous_limit_up"), "涨停"),
daily,
limits,
broken,
down_limits,
)
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)
seal_rate = len(limits) / max(len(limits) + len(broken), 1) * 100
overview = {
"up_count": up_count,
"down_count": down_count,
"flat_count": flat_count,
"limit_up": len(limits),
"limit_down": len(down_limits),
"broken": len(broken),
"seal_rate": round(seal_rate, 1),
"amount": round(amount, 2),
}
return {
"trade_date": trade_date,
"previous_trade_date": previous_trade_date,
"overview": overview,
"limits": limits,
"broken": broken,
"down_limits": down_limits,
"yesterday_limits": yesterday,
"limit_performance": _performance(yesterday),
}
def _rows(
inputs: dict[str, ProviderResult | dict[str, Any]], key: str
) -> tuple[dict[str, Any], ...]:
value = inputs.get(key)
return value.rows if isinstance(value, ProviderResult) else ()
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
result.append(
{
"identifier": identifier,
"code": identifier.split(".")[0],
"name": str(row.get("name") or "").strip(),
"price": _number(row.get("close")),
"change": _number(row.get("pct_chg")),
"sector": str(row.get("industry") or "").strip(),
"reason": "",
"first_time": _time(row.get("first_time")),
"last_time": _time(row.get("last_time")),
"open_times": int(_number(row.get("open_times"))),
"streak": max(1, int(_number(row.get("limit_times"), 1))),
"turnover_rate": _number(row.get("turnover_ratio")),
"amount": amount,
"seal_amount": _number(row.get("fd_amount")),
"float_market_value": _number(row.get("float_mv")),
"status": status,
}
)
return result
def _yesterday(
previous: list[dict[str, Any]],
daily: dict[str, dict[str, Any]],
current: list[dict[str, Any]],
broken: list[dict[str, Any]],
down: list[dict[str, Any]],
) -> list[dict[str, Any]]:
current_map = {row["identifier"]: row for row in current}
broken_codes = {row["identifier"] for row in broken}
down_codes = {row["identifier"] for row in down}
result = []
for prior in previous:
identifier = prior["identifier"]
quote = daily.get(identifier, {})
change = _number(quote.get("pct_chg"))
if identifier in current_map:
outcome = "晋级"
elif identifier in broken_codes:
outcome = "炸板"
elif identifier in down_codes:
outcome = "跌停"
elif change > 0:
outcome = "红盘"
else:
outcome = "断板"
result.append(
{
"identifier": identifier,
"code": prior["code"],
"name": prior["name"],
"prior_streak": prior["streak"],
"current_streak": current_map.get(identifier, {}).get("streak", 0),
"current_change": change,
"current_price": _number(quote.get("close")),
"sector": prior["sector"],
"reason": prior["reason"],
"outcome": outcome,
}
)
return result
def _performance(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
result = []
for level in sorted({int(row["prior_streak"]) for row in rows}, reverse=True):
group = [row for row in rows if int(row["prior_streak"]) == level]
advanced = sum(row["outcome"] == "晋级" for row in group)
positive = sum(_number(row["current_change"]) > 0 for row in group)
outcomes = {
outcome: sum(row["outcome"] == outcome for row in group)
for outcome in ("晋级", "红盘", "断板", "炸板", "跌停")
}
result.append(
{
"level": level,
"count": len(group),
"advanced": advanced,
"red": outcomes["红盘"],
"broken": outcomes["断板"],
"opened": outcomes["炸板"],
"limit_down": outcomes["跌停"],
"advance_rate": round(advanced / len(group) * 100, 1),
"positive_rate": round(positive / len(group) * 100, 1),
"average_change": round(mean(_number(row["current_change"]) for row in group), 2),
}
)
return result
def _number(value: Any, default: float = 0.0) -> float:
try:
number = float(value)
return number if number == number else default
except (TypeError, ValueError):
return default
def _time(value: Any) -> str:
text = str(value or "").strip().replace(":", "")
if len(text) < 4 or not text[:4].isdigit():
return ""
return f"{text[:2]}:{text[2:4]}"
+157
View File
@@ -0,0 +1,157 @@
from __future__ import annotations
import json
from datetime import date, datetime, time, timedelta
from typing import Any
from zoneinfo import ZoneInfo
from backend.data.contracts import ProviderResult, SnapshotState
from backend.data.providers.base import ProviderError
from backend.data.providers.tushare import TushareProvider
from backend.data.repository import MarketRepository
from backend.database.connection import Database
from backend.features.market.sentiment import calculate_sentiment
from backend.features.market.snapshot import build_snapshot
SHANGHAI = ZoneInfo("Asia/Shanghai")
class SnapshotSyncError(RuntimeError):
pass
class MarketSnapshotService:
def __init__(
self, database: Database, repository: MarketRepository, provider: TushareProvider
) -> None:
self._database = database
self._repository = repository
self._provider = provider
def sync(
self, requested_date: str | None = None, now: datetime | None = None
) -> dict[str, Any]:
clock = now or datetime.now(SHANGHAI)
requested = _date(requested_date or clock.date().isoformat())
through = requested
if requested == clock.date().isoformat() and clock.time() < time(9, 15):
through = (clock.date() - timedelta(days=1)).isoformat()
elif requested == clock.date().isoformat() and clock.time() < time(15, 10):
raise SnapshotSyncError("盘中快照任务尚未开放,请保留最近真实收盘数据")
with self._database.read() as connection:
dates = self._repository.open_dates(connection, through, 2)
active_count = self._repository.active_stock_count(connection)
if len(dates) < 2:
raise SnapshotSyncError("请先同步完整交易日历")
if active_count <= 0:
raise SnapshotSyncError("请先同步股票目录")
trade_date, previous_date = dates[0], dates[1]
try:
inputs = self._provider.snapshot_inputs(trade_date, previous_date)
except ProviderError as exc:
raise SnapshotSyncError("收盘行情读取失败,已保留原有快照") from exc
daily = inputs.get("daily")
if not isinstance(daily, ProviderResult):
raise SnapshotSyncError("收盘日线缺失,已保留原有快照")
coverage = len(daily.rows) / active_count
if coverage < 0.98:
raise SnapshotSyncError(f"收盘日线覆盖率仅{coverage * 100:.1f}%,未写入不完整快照")
for key in ("limit_up", "limit_down", "broken", "previous_limit_up", "price_limits"):
value = inputs.get(key)
if not isinstance(value, ProviderResult) or value.metadata.coverage < 1:
raise SnapshotSyncError("涨跌停事件数据不完整,已保留原有快照")
snapshot = build_snapshot(trade_date, previous_date, inputs)
with self._database.read() as connection:
rows = self._repository.summaries(connection, previous_date, 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 = datetime.combine(
date.fromisoformat(trade_date), time(15), tzinfo=SHANGHAI
).isoformat(timespec="seconds")
state = (
SnapshotState.FINAL if trade_date == clock.date().isoformat() else SnapshotState.ARCHIVE
)
with self._database.transaction() as connection:
self._repository.save_summary(
connection,
trade_date=trade_date,
observed_at=observed_at,
state=state.value,
source="tushare",
coverage=min(coverage, 1),
payload=snapshot,
)
return {
"trade_date": trade_date,
"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"],
}
def workspace(self, key: str, requested_date: str | None = None) -> dict[str, Any]:
requested = _date(requested_date or datetime.now(SHANGHAI).date().isoformat())
with self._database.read() as connection:
row = self._repository.latest_summary(connection, requested)
history_rows = self._repository.summaries(connection, requested, 60)
if row is None:
return {"trade_date": None, "message": "等待管理员首次同步真实收盘行情"}
payload = json.loads(str(row["payload_json"]))
response: dict[str, Any] = {
"trade_date": str(row["trade_date"]),
"observed_at": str(row["observed_at"]),
"carried_forward": str(row["trade_date"]) != requested,
"message": "沿用最近真实收盘快照" if str(row["trade_date"]) != requested else "",
"overview": payload.get("overview") or {},
}
if key == "emotion":
response["sentiment"] = payload.get("sentiment") or {}
response["history"] = [
_history_item(json.loads(str(item["payload_json"]))) for item in history_rows
]
elif key == "pool":
response["items"] = payload.get("limits") or []
elif key == "broken":
response["items"] = payload.get("broken") or []
elif key == "limit-down":
response["items"] = payload.get("down_limits") or []
elif key == "yesterday":
response["items"] = payload.get("yesterday_limits") or []
elif key == "performance":
response["items"] = payload.get("limit_performance") or []
else:
raise SnapshotSyncError("不支持的市场工作区")
return response
def _history_item(payload: dict[str, Any]) -> dict[str, Any]:
sentiment = payload.get("sentiment") or {}
stats = sentiment.get("stats") or {}
overview = payload.get("overview") or {}
return {
"trade_date": payload.get("trade_date"),
"temperature": sentiment.get("score"),
"phase": sentiment.get("phase"),
"direction": sentiment.get("direction"),
"positive_rate": stats.get("positive_rate"),
"seal_rate": overview.get("seal_rate"),
"limit_up": overview.get("limit_up"),
"broken": overview.get("broken"),
"limit_down": overview.get("limit_down"),
"max_height": stats.get("max_height"),
"amount": overview.get("amount"),
}
def _date(value: str) -> str:
try:
return date.fromisoformat(value).isoformat()
except ValueError as exc:
raise SnapshotSyncError("日期格式无效") from exc