feat(selection): 支持选股结果服务端分页

This commit is contained in:
yuxuanhui
2026-08-10 11:09:23 +08:00
parent ed7bdda515
commit 3af97bf65d
16 changed files with 431 additions and 63 deletions
@@ -3,7 +3,7 @@
from datetime import date, datetime
from typing import Annotated, Literal
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from zhixing_server.bootstrap.config import Settings, get_settings
@@ -12,6 +12,7 @@ from zhixing_server.modules.selection.application.run import (
)
from zhixing_server.modules.selection.domain.runs import (
SelectionRerunRequired,
SelectionResultQuery,
SelectionRun,
SelectionRunInProgress,
SelectionRunStoreError,
@@ -88,7 +89,7 @@ def _empty_signals() -> list[SelectionSignalResponse]:
class SelectionResultsResponse(BaseModel):
"""Batch summary and materialized signals consumed by the Web feature."""
"""Batch summary and one filtered page of signals consumed by the Web feature."""
strategy: StrategyValue
target_trade_date: date | None
@@ -106,6 +107,9 @@ class SelectionResultsResponse(BaseModel):
error_message: str | None = None
created_at: datetime | None = None
finished_at: datetime | None = None
page: int = Field(default=1, ge=1)
page_size: int = Field(default=10, ge=1, le=100)
signals_total: int = Field(default=0, ge=0)
failures: list[SelectionFailureResponse] = Field(default_factory=_empty_failures)
signals: list[SelectionSignalResponse] = Field(default_factory=_empty_signals)
@@ -160,16 +164,21 @@ def trigger_selection_run(
def get_selection_run(
run_id: str,
service: Annotated[RunZhixingB1, Depends(get_selection_service)],
page: Annotated[int, Query(ge=1)] = 1,
page_size: Annotated[int, Query(ge=1, le=100)] = 10,
search: Annotated[str | None, Query(max_length=100)] = None,
category: Literal["pullback", "oversold", "original"] | None = None,
) -> SelectionResultsResponse:
"""Return one run for asynchronous polling."""
try:
run = service.get_run(run_id)
query = _result_query(page, page_size, search, category)
run = service.get_run(run_id, query=query)
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)
return _run_response(run, query=query)
@selection_router.get("/results", response_model=SelectionResultsResponse)
@@ -177,11 +186,16 @@ def get_selection_results(
service: Annotated[RunZhixingB1, Depends(get_selection_service)],
strategy: StrategyValue = "zhixing_b1",
target_trade_date: date | None = None,
page: Annotated[int, Query(ge=1)] = 1,
page_size: Annotated[int, Query(ge=1, le=100)] = 10,
search: Annotated[str | None, Query(max_length=100)] = None,
category: Literal["pullback", "oversold", "original"] | None = None,
) -> SelectionResultsResponse:
"""Return the current persisted result for a strategy and optional date."""
try:
run = service.get_latest(strategy, target_trade_date)
query = _result_query(page, page_size, search, category)
run = service.get_latest(strategy, target_trade_date, query=query)
except SelectionRunStoreError as exc:
raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc
if run is None:
@@ -192,11 +206,14 @@ def get_selection_results(
market_sync_batch_id=None,
status="no_data",
coverage=0,
page=query.page,
page_size=query.page_size,
signals_total=0,
)
return _run_response(run)
return _run_response(run, query=query)
def _run_response(run: SelectionRun) -> SelectionResultsResponse:
def _run_response(run: SelectionRun, *, query: SelectionResultQuery) -> SelectionResultsResponse:
"""Translate a domain run without exposing storage-specific fields."""
return SelectionResultsResponse(
@@ -216,6 +233,9 @@ def _run_response(run: SelectionRun) -> SelectionResultsResponse:
error_message=run.error_message,
created_at=run.created_at,
finished_at=run.finished_at,
page=query.page,
page_size=query.page_size,
signals_total=run.signals_total if run.signals_total is not None else run.signal_count,
failures=[
SelectionFailureResponse(
ts_code=item.ts_code,
@@ -241,6 +261,23 @@ def _run_response(run: SelectionRun) -> SelectionResultsResponse:
)
def _result_query(
page: int,
page_size: int,
search: str | None,
category: Literal["pullback", "oversold", "original"] | None,
) -> SelectionResultQuery:
"""Normalize HTTP query values before handing them to the selection port."""
normalized_search = search.strip() if search else None
return SelectionResultQuery(
page=page,
page_size=page_size,
search=normalized_search or None,
category=category,
)
def _http_error(code: int, error_type: str, message: str) -> HTTPException:
"""Create the project's explicit, safe error envelope."""