rebuild(audit): complete entity detail and market preview
This commit is contained in:
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Path, Query, Request
|
||||
from backend.features.accounts.auth import AdminWritePrincipal, AuthenticatedPrincipal
|
||||
from backend.features.market.schemas import (
|
||||
ChartResponse,
|
||||
EntityDetailResponse,
|
||||
MarketInsightResponse,
|
||||
MarketSummaryResponse,
|
||||
MarketWorkspaceResponse,
|
||||
@@ -61,6 +62,19 @@ def chart(
|
||||
return request.app.state.container.market.chart(entity_type, identifier, interval)
|
||||
|
||||
|
||||
@router.get("/entities/{entity_type}/{identifier}/detail", response_model=EntityDetailResponse)
|
||||
def entity_detail(
|
||||
request: Request,
|
||||
_principal: AuthenticatedPrincipal,
|
||||
entity_type: Annotated[Literal["stock", "sector", "theme", "index"], Path()],
|
||||
identifier: Annotated[str, Path(min_length=1, max_length=40)],
|
||||
requested_date: Annotated[str | None, Query(alias="date")] = None,
|
||||
) -> dict:
|
||||
return request.app.state.container.market.entity_detail(
|
||||
entity_type, identifier, requested_date
|
||||
)
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
@@ -65,6 +65,20 @@ class ChartResponse(BaseModel):
|
||||
points: list[ChartPointResponse]
|
||||
|
||||
|
||||
class EntityDetailResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
entity: SearchResultResponse
|
||||
trade_date: str
|
||||
observed_at: datetime
|
||||
price: float
|
||||
previous_close: float | None
|
||||
change: float | None
|
||||
metrics: list[dict[str, Any]] = Field(default_factory=list)
|
||||
money_flow: dict[str, Any] | None = None
|
||||
event: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ReferenceSyncResponse(BaseModel):
|
||||
calendar_days: int = Field(ge=1)
|
||||
entities: int = Field(ge=1)
|
||||
|
||||
@@ -83,6 +83,13 @@ class MarketService:
|
||||
],
|
||||
}
|
||||
|
||||
def entity_detail(
|
||||
self, entity_type: str, identifier: str, requested_date: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
return self._call(
|
||||
self._snapshots.entity_detail, entity_type, identifier, requested_date
|
||||
)
|
||||
|
||||
def refresh_reference(self) -> dict[str, int | str]:
|
||||
return self._call(self._gateway.refresh_reference)
|
||||
|
||||
|
||||
@@ -143,6 +143,86 @@ class MarketSnapshotService:
|
||||
raise SnapshotSyncError("不支持的市场工作区")
|
||||
return response
|
||||
|
||||
def entity_detail(
|
||||
self, entity_type: str, identifier: str, requested_date: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
context = self._gateway.trade_context(requested_date)
|
||||
if context.actual_date is None:
|
||||
raise SnapshotSyncError("等待管理员首次同步真实收盘行情")
|
||||
entity = self._gateway.resolve_entity(entity_type, identifier)
|
||||
series = self._gateway.chart(entity_type, entity.identifier, "day")
|
||||
eligible = [point for point in series.points if point.time <= context.actual_date]
|
||||
if not eligible:
|
||||
raise SnapshotSyncError("所选日期之前没有可核验的真实行情")
|
||||
current = eligible[-1]
|
||||
previous = eligible[-2].close if len(eligible) > 1 else series.previous_close
|
||||
change = (current.close / previous - 1) * 100 if previous else None
|
||||
|
||||
with self._database.read() as connection:
|
||||
summary = self._repository.latest_summary(connection, context.actual_date)
|
||||
factor_row = connection.execute(
|
||||
"""
|
||||
SELECT factor_values.payload_json
|
||||
FROM screener_factor_values AS factor_values
|
||||
JOIN screener_factor_snapshots AS snapshots
|
||||
ON snapshots.id = factor_values.snapshot_id
|
||||
WHERE factor_values.identifier = ? AND snapshots.trade_date <= ?
|
||||
ORDER BY snapshots.trade_date DESC, snapshots.id DESC LIMIT 1
|
||||
""",
|
||||
(entity.identifier, context.actual_date),
|
||||
).fetchone()
|
||||
factors = json.loads(str(factor_row["payload_json"])) if factor_row else {}
|
||||
snapshot = json.loads(str(summary["payload_json"])) if summary else {}
|
||||
event = (
|
||||
_entity_event(snapshot, entity.identifier, entity.code)
|
||||
if entity_type == "stock"
|
||||
else None
|
||||
)
|
||||
metrics = [
|
||||
{"key": "open", "label": "开盘", "value": current.open, "unit": "元"},
|
||||
{"key": "high", "label": "最高", "value": current.high, "unit": "元"},
|
||||
{"key": "low", "label": "最低", "value": current.low, "unit": "元"},
|
||||
{"key": "amount", "label": "成交额", "value": current.amount, "unit": "元"},
|
||||
{"key": "volume", "label": "成交量", "value": current.volume, "unit": "股"},
|
||||
]
|
||||
for key, label, unit in (
|
||||
("turnover_rate", "换手率", "%"),
|
||||
("return_5d", "近5日", "%"),
|
||||
("return_20d", "近20日", "%"),
|
||||
("total_mv_billion", "总市值", "亿"),
|
||||
("circ_mv_billion", "流通市值", "亿"),
|
||||
):
|
||||
metrics.append({"key": key, "label": label, "value": factors.get(key), "unit": unit})
|
||||
money_flow = None
|
||||
if entity_type == "stock":
|
||||
money_flow = {
|
||||
"available": any(
|
||||
factors.get(key) is not None
|
||||
for key in ("net_flow_million", "large_flow_million", "net_flow_5d_million")
|
||||
),
|
||||
"net_million": factors.get("net_flow_million"),
|
||||
"large_million": factors.get("large_flow_million"),
|
||||
"net_5d_million": factors.get("net_flow_5d_million"),
|
||||
"flow_to_circ_mv_5d": factors.get("flow_to_circ_mv_5d"),
|
||||
}
|
||||
return {
|
||||
"entity": {
|
||||
"entity_type": entity.entity_type,
|
||||
"identifier": entity.identifier,
|
||||
"code": entity.code,
|
||||
"name": factors.get("name") or entity.name,
|
||||
"sector": factors.get("sector") or entity.sector,
|
||||
},
|
||||
"trade_date": current.time,
|
||||
"observed_at": series.metadata.observed_at,
|
||||
"price": current.close,
|
||||
"previous_close": previous,
|
||||
"change": round(change, 4) if change is not None else None,
|
||||
"metrics": metrics,
|
||||
"money_flow": money_flow,
|
||||
"event": event,
|
||||
}
|
||||
|
||||
def rotation_member_target(
|
||||
self, requested_date: str | None, sector_name: str
|
||||
) -> tuple[str, str]:
|
||||
@@ -185,6 +265,24 @@ def _history_item(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _entity_event(
|
||||
snapshot: dict[str, Any], identifier: str, code: str
|
||||
) -> dict[str, Any] | None:
|
||||
for key, status in (("limits", "涨停"), ("broken", "炸板"), ("down_limits", "跌停")):
|
||||
for row in snapshot.get(key) or []:
|
||||
if str(row.get("identifier") or "") == identifier or str(row.get("code") or "") == code:
|
||||
return {
|
||||
"status": status,
|
||||
"reason": str(row.get("reason") or ""),
|
||||
"streak": row.get("streak"),
|
||||
"first_time": row.get("first_time"),
|
||||
"last_time": row.get("last_time"),
|
||||
"open_times": row.get("open_times"),
|
||||
"seal_amount": row.get("seal_amount"),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _date(value: str) -> str:
|
||||
try:
|
||||
return date.fromisoformat(value).isoformat()
|
||||
|
||||
Reference in New Issue
Block a user