rebuild(stage-6): deliver emotion and market pools
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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]}"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user