rebuild(stage-5): establish market data gateway and charts

This commit is contained in:
leefer
2026-07-30 02:35:42 +08:00
parent 40ad5d6836
commit cf0ab7026f
45 changed files with 2701 additions and 46 deletions
+3
View File
@@ -0,0 +1,3 @@
from backend.features.market.service import MarketService
__all__ = ["MarketService"]
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
from typing import Annotated, Literal
from fastapi import APIRouter, Path, Query, Request
from backend.features.accounts.auth import AdminWritePrincipal, AuthenticatedPrincipal
from backend.features.market.schemas import (
ChartResponse,
MarketSummaryResponse,
ReferenceSyncResponse,
SearchResponse,
TradeContextResponse,
)
router = APIRouter(prefix="/market", tags=["market"])
@router.get("/context", response_model=TradeContextResponse)
def context(
request: Request,
_principal: AuthenticatedPrincipal,
requested_date: Annotated[str | None, Query(alias="date")] = None,
) -> dict:
return request.app.state.container.market.context(requested_date)
@router.get("/summary", response_model=MarketSummaryResponse)
def summary(
request: Request,
_principal: AuthenticatedPrincipal,
requested_date: Annotated[str | None, Query(alias="date")] = None,
) -> dict:
return request.app.state.container.market.summary(requested_date)
@router.get("/search", response_model=SearchResponse)
def search(
request: Request,
_principal: AuthenticatedPrincipal,
query: Annotated[str, Query(alias="q", max_length=80)] = "",
) -> dict:
return request.app.state.container.market.search(query)
@router.get("/entities/{entity_type}/{identifier}/charts/{interval}", response_model=ChartResponse)
def chart(
request: Request,
_principal: AuthenticatedPrincipal,
entity_type: Annotated[Literal["stock", "sector", "theme", "index"], Path()],
identifier: Annotated[str, Path(min_length=1, max_length=40)],
interval: Annotated[Literal["day", "minute"], Path()],
) -> dict:
return request.app.state.container.market.chart(entity_type, identifier, interval)
@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()
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
class TradeContextResponse(BaseModel):
requested_date: str
actual_date: str | None
previous_date: str | None
observed_at: datetime | None
state: str | None
carried_forward: bool
message: str
class MarketSummaryResponse(BaseModel):
context: TradeContextResponse
values: dict[str, Any] | None
class SearchResultResponse(BaseModel):
entity_type: Literal["stock", "sector", "theme", "index"]
identifier: str
code: str
name: str
sector: str | None
class SearchGroupResponse(BaseModel):
entity_type: Literal["stock", "sector", "theme", "index"]
label: str
items: list[SearchResultResponse]
class SearchResponse(BaseModel):
query: str
groups: list[SearchGroupResponse]
class ChartPointResponse(BaseModel):
time: str
open: float
high: float
low: float
close: float
volume: float
amount: float
average: float | None
class ChartResponse(BaseModel):
entity_type: str
identifier: str
code: str
name: str
interval: Literal["day", "minute"]
trade_date: str
observed_at: datetime
previous_close: float | None
range_start: str | None
range_end: str | None
points: list[ChartPointResponse]
class ReferenceSyncResponse(BaseModel):
calendar_days: int = Field(ge=1)
entities: int = Field(ge=1)
observed_at: datetime
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
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.http.errors import AppError
class MarketService:
def __init__(self, gateway: DataGateway) -> None:
self._gateway = gateway
def context(self, requested_date: str | None = None) -> dict[str, Any]:
context = self._call(self._gateway.trade_context, requested_date)
return _context(context)
def summary(self, requested_date: str | None = None) -> dict[str, Any]:
result = self._call(self._gateway.summary, requested_date)
return {"context": _context(result["context"]), "values": result["values"]}
def search(self, query: str) -> dict[str, Any]:
normalized = " ".join(query.split())
items = self._gateway.search(normalized) if normalized else ()
labels = {"stock": "股票", "sector": "板块", "theme": "题材", "index": "指数"}
groups = []
for entity_type in ("stock", "sector", "theme", "index"):
groups.append(
{
"entity_type": entity_type,
"label": labels[entity_type],
"items": [
{
"entity_type": item.entity_type,
"identifier": item.identifier,
"code": item.code,
"name": item.name,
"sector": item.sector,
}
for item in items
if item.entity_type == entity_type
],
}
)
return {"query": normalized, "groups": groups}
def chart(self, entity_type: str, identifier: str, interval: str) -> dict[str, Any]:
series = self._call(self._gateway.chart, entity_type, identifier, interval)
return {
"entity_type": series.entity.entity_type,
"identifier": series.entity.identifier,
"code": series.entity.code,
"name": series.entity.name,
"interval": series.interval,
"trade_date": series.trade_date,
"observed_at": series.metadata.observed_at,
"previous_close": series.previous_close,
"range_start": "09:30" if interval == "minute" else None,
"range_end": "15:00" if interval == "minute" else None,
"points": [
{
"time": point.time,
"open": point.open,
"high": point.high,
"low": point.low,
"close": point.close,
"volume": point.volume,
"amount": point.amount,
"average": point.average,
}
for point in series.points
],
}
def refresh_reference(self) -> dict[str, int | str]:
return self._call(self._gateway.refresh_reference)
@staticmethod
def _call(function, *args):
try:
return function(*args)
except (MarketDataUnavailable, ProviderError, DataQualityError) as exc:
raise AppError("market_data_unavailable", str(exc), 503) from exc
def _context(context) -> dict[str, Any]:
return {
"requested_date": context.requested_date,
"actual_date": context.actual_date,
"previous_date": context.previous_date,
"observed_at": context.observed_at,
"state": context.state.value if context.state else None,
"carried_forward": context.carried_forward,
"message": context.message,
}