feat(selection): 补充策略执行结果查询链路
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
"""HTTP presentation for persisted strategy execution results."""
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from zhixing_server.bootstrap.config import Settings, get_settings
|
||||
from zhixing_server.modules.selection.application.run import (
|
||||
RunZhixingB1,
|
||||
)
|
||||
from zhixing_server.modules.selection.domain.runs import (
|
||||
SelectionRerunRequired,
|
||||
SelectionRun,
|
||||
SelectionRunInProgress,
|
||||
SelectionRunStoreError,
|
||||
)
|
||||
from zhixing_server.modules.selection.infrastructure.postgres_reader import (
|
||||
PostgresMarketDataReader,
|
||||
SelectionMarketDataNotReady,
|
||||
SelectionReaderError,
|
||||
)
|
||||
from zhixing_server.modules.selection.infrastructure.postgres_runs import (
|
||||
PostgresSelectionRunRepository,
|
||||
)
|
||||
|
||||
selection_router = APIRouter()
|
||||
|
||||
StrategyValue = Literal["zhixing_b1"]
|
||||
SelectionStatusValue = Literal[
|
||||
"no_data",
|
||||
"running",
|
||||
"success",
|
||||
"partial_success",
|
||||
"failed",
|
||||
]
|
||||
|
||||
|
||||
class SelectionRunRequest(BaseModel):
|
||||
"""Input contract for one initial run or explicit rerun."""
|
||||
|
||||
strategy: StrategyValue
|
||||
target_trade_date: date
|
||||
rerun: bool = False
|
||||
|
||||
|
||||
class SelectionRunAcceptedResponse(BaseModel):
|
||||
"""Small response returned before the background evaluation completes."""
|
||||
|
||||
run_id: str
|
||||
strategy: StrategyValue
|
||||
target_trade_date: date
|
||||
status: Literal["running"]
|
||||
|
||||
|
||||
class SelectionSignalResponse(BaseModel):
|
||||
"""One persisted independent sub-signal in the public result contract."""
|
||||
|
||||
ts_code: str
|
||||
name: str
|
||||
target_trade_date: date
|
||||
strategy: StrategyValue
|
||||
category: str
|
||||
close: float
|
||||
details: dict[str, float | str | None]
|
||||
|
||||
|
||||
class SelectionFailureResponse(BaseModel):
|
||||
"""One stock that could not produce a complete evaluation."""
|
||||
|
||||
ts_code: str
|
||||
name: str
|
||||
status: str
|
||||
reason: str | None
|
||||
|
||||
|
||||
def _empty_failures() -> list[SelectionFailureResponse]:
|
||||
"""Create a typed default list for Pydantic's strict checker."""
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _empty_signals() -> list[SelectionSignalResponse]:
|
||||
"""Create a typed default list for Pydantic's strict checker."""
|
||||
|
||||
return []
|
||||
|
||||
|
||||
class SelectionResultsResponse(BaseModel):
|
||||
"""Batch summary and materialized signals consumed by the Web feature."""
|
||||
|
||||
strategy: StrategyValue
|
||||
target_trade_date: date | None
|
||||
run_id: str | None
|
||||
market_sync_batch_id: str | None
|
||||
status: SelectionStatusValue
|
||||
target_count: int = Field(default=0, ge=0)
|
||||
eligible_count: int = Field(default=0, ge=0)
|
||||
evaluated_count: int = Field(default=0, ge=0)
|
||||
selected_stock_count: int = Field(default=0, ge=0)
|
||||
signal_count: int = Field(default=0, ge=0)
|
||||
failed_count: int = Field(default=0, ge=0)
|
||||
coverage: float = Field(default=0, ge=0, le=1)
|
||||
error_type: str | None = None
|
||||
error_message: str | None = None
|
||||
created_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
failures: list[SelectionFailureResponse] = Field(default_factory=_empty_failures)
|
||||
signals: list[SelectionSignalResponse] = Field(default_factory=_empty_signals)
|
||||
|
||||
|
||||
def get_selection_service(
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> RunZhixingB1:
|
||||
"""Build one request-scoped selection application service."""
|
||||
|
||||
reader = PostgresMarketDataReader(settings)
|
||||
store = PostgresSelectionRunRepository(settings.database_url)
|
||||
return RunZhixingB1(reader, store)
|
||||
|
||||
|
||||
@selection_router.post(
|
||||
"/runs",
|
||||
response_model=SelectionRunAcceptedResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
def trigger_selection_run(
|
||||
request: SelectionRunRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
service: Annotated[RunZhixingB1, Depends(get_selection_service)],
|
||||
) -> SelectionRunAcceptedResponse:
|
||||
"""Claim a run and schedule its whole-universe evaluation."""
|
||||
|
||||
try:
|
||||
prepared = service.prepare(
|
||||
request.strategy,
|
||||
request.target_trade_date,
|
||||
rerun=request.rerun,
|
||||
)
|
||||
except SelectionRunInProgress as exc:
|
||||
raise _http_error(409, "run_in_progress", str(exc)) from exc
|
||||
except SelectionRerunRequired as exc:
|
||||
raise _http_error(409, "rerun_confirmation_required", str(exc)) from exc
|
||||
except SelectionMarketDataNotReady as exc:
|
||||
raise _http_error(422, "market_data_not_ready", str(exc)) from exc
|
||||
except (SelectionReaderError, SelectionRunStoreError) as exc:
|
||||
raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc
|
||||
|
||||
background_tasks.add_task(service.execute, prepared)
|
||||
return SelectionRunAcceptedResponse(
|
||||
run_id=prepared.run.id,
|
||||
strategy=prepared.run.strategy,
|
||||
target_trade_date=prepared.run.target_trade_date,
|
||||
status="running",
|
||||
)
|
||||
|
||||
|
||||
@selection_router.get("/runs/{run_id}", response_model=SelectionResultsResponse)
|
||||
def get_selection_run(
|
||||
run_id: str,
|
||||
service: Annotated[RunZhixingB1, Depends(get_selection_service)],
|
||||
) -> SelectionResultsResponse:
|
||||
"""Return one run for asynchronous polling."""
|
||||
|
||||
try:
|
||||
run = service.get_run(run_id)
|
||||
except SelectionRunStoreError as exc:
|
||||
raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc
|
||||
if run is None:
|
||||
raise _http_error(404, "run_not_found", f"selection run not found: {run_id}")
|
||||
return _run_response(run)
|
||||
|
||||
|
||||
@selection_router.get("/results", response_model=SelectionResultsResponse)
|
||||
def get_selection_results(
|
||||
service: Annotated[RunZhixingB1, Depends(get_selection_service)],
|
||||
strategy: StrategyValue = "zhixing_b1",
|
||||
target_trade_date: date | None = None,
|
||||
) -> SelectionResultsResponse:
|
||||
"""Return the current persisted result for a strategy and optional date."""
|
||||
|
||||
try:
|
||||
run = service.get_latest(strategy, target_trade_date)
|
||||
except SelectionRunStoreError as exc:
|
||||
raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc
|
||||
if run is None:
|
||||
return SelectionResultsResponse(
|
||||
strategy=strategy,
|
||||
target_trade_date=target_trade_date,
|
||||
run_id=None,
|
||||
market_sync_batch_id=None,
|
||||
status="no_data",
|
||||
coverage=0,
|
||||
)
|
||||
return _run_response(run)
|
||||
|
||||
|
||||
def _run_response(run: SelectionRun) -> SelectionResultsResponse:
|
||||
"""Translate a domain run without exposing storage-specific fields."""
|
||||
|
||||
return SelectionResultsResponse(
|
||||
strategy=run.strategy,
|
||||
target_trade_date=run.target_trade_date,
|
||||
run_id=run.id,
|
||||
market_sync_batch_id=run.market_sync_batch_id,
|
||||
status=run.status,
|
||||
target_count=run.target_count,
|
||||
eligible_count=run.eligible_count,
|
||||
evaluated_count=run.evaluated_count,
|
||||
selected_stock_count=run.selected_stock_count,
|
||||
signal_count=run.signal_count,
|
||||
failed_count=run.failed_count,
|
||||
coverage=float(run.coverage),
|
||||
error_type=run.error_type,
|
||||
error_message=run.error_message,
|
||||
created_at=run.created_at,
|
||||
finished_at=run.finished_at,
|
||||
failures=[
|
||||
SelectionFailureResponse(
|
||||
ts_code=item.ts_code,
|
||||
name=item.name,
|
||||
status=item.status,
|
||||
reason=item.reason,
|
||||
)
|
||||
for item in run.items
|
||||
if item.status in {"insufficient_history", "missing_target_bar", "data_error"}
|
||||
],
|
||||
signals=[
|
||||
SelectionSignalResponse(
|
||||
ts_code=signal.ts_code,
|
||||
name=signal.name,
|
||||
target_trade_date=signal.target_trade_date,
|
||||
strategy=signal.strategy,
|
||||
category=signal.category.value,
|
||||
close=signal.close,
|
||||
details=dict(signal.details),
|
||||
)
|
||||
for signal in run.signals
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _http_error(code: int, error_type: str, message: str) -> HTTPException:
|
||||
"""Create the project's explicit, safe error envelope."""
|
||||
|
||||
return HTTPException(
|
||||
status_code=code,
|
||||
detail={"code": error_type, "message": message},
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SelectionResultsResponse",
|
||||
"SelectionRunAcceptedResponse",
|
||||
"SelectionRunRequest",
|
||||
"get_selection_service",
|
||||
"selection_router",
|
||||
]
|
||||
Reference in New Issue
Block a user