rebuild(stage-5): establish market data gateway and charts
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from backend.features.market.service import MarketService
|
||||
|
||||
__all__ = ["MarketService"]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user