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
@@ -11,6 +11,7 @@ from ..domain.models import SelectionEvaluation
from ..domain.runs import ( from ..domain.runs import (
SelectionExecutionSource, SelectionExecutionSource,
SelectionRerunRequired, SelectionRerunRequired,
SelectionResultQuery,
SelectionRun, SelectionRun,
SelectionRunInProgress, SelectionRunInProgress,
SelectionRunItem, SelectionRunItem,
@@ -135,19 +136,26 @@ class RunZhixingB1:
except Exception: # noqa: BLE001 - preserve the original worker failure except Exception: # noqa: BLE001 - preserve the original worker failure
logger.exception("selection_run_failure_persist_failed run_id=%s", prepared.run.id) logger.exception("selection_run_failure_persist_failed run_id=%s", prepared.run.id)
def get_run(self, run_id: str) -> SelectionRun | None: def get_run(
self,
run_id: str,
*,
query: SelectionResultQuery | None = None,
) -> SelectionRun | None:
"""Read one persisted run for polling.""" """Read one persisted run for polling."""
return self.store.get_run(run_id) return self.store.get_run(run_id, query=query)
def get_latest( def get_latest(
self, self,
strategy: StrategyName, strategy: StrategyName,
target_trade_date: date | None = None, target_trade_date: date | None = None,
*,
query: SelectionResultQuery | None = None,
) -> SelectionRun | None: ) -> SelectionRun | None:
"""Read the current result by date or the latest result for a strategy.""" """Read the current result by date or the latest result for a strategy."""
return self.store.get_latest_run(strategy, target_trade_date) return self.store.get_latest_run(strategy, target_trade_date, query=query)
def _to_item(ts_code: str, name: str, evaluation: SelectionEvaluation) -> SelectionRunItem: def _to_item(ts_code: str, name: str, evaluation: SelectionEvaluation) -> SelectionRunItem:
@@ -11,6 +11,17 @@ from .models import SelectionEvaluationStatus, SelectionSignal, StockHistory
SelectionRunStatus = Literal["running", "success", "partial_success", "failed"] SelectionRunStatus = Literal["running", "success", "partial_success", "failed"]
SelectionRunItemStatus = SelectionEvaluationStatus SelectionRunItemStatus = SelectionEvaluationStatus
SelectionSignalCategoryFilter = Literal["pullback", "oversold", "original"]
@dataclass(frozen=True, slots=True)
class SelectionResultQuery:
"""Validated query options for a paged selection-result read."""
page: int = 1
page_size: int = 10
search: str | None = None
category: SelectionSignalCategoryFilter | None = None
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -67,6 +78,7 @@ class SelectionRun:
finished_at: datetime | None = None finished_at: datetime | None = None
items: tuple[SelectionRunItem, ...] = field(default_factory=tuple) items: tuple[SelectionRunItem, ...] = field(default_factory=tuple)
signals: tuple[SelectionSignal, ...] = field(default_factory=tuple) signals: tuple[SelectionSignal, ...] = field(default_factory=tuple)
signals_total: int | None = None
class SelectionRunError(RuntimeError): class SelectionRunError(RuntimeError):
@@ -112,12 +124,19 @@ class SelectionRunStore(Protocol):
error_message: str | None = None, error_message: str | None = None,
) -> None: ... ) -> None: ...
def get_run(self, run_id: str) -> SelectionRun | None: ... def get_run(
self,
run_id: str,
*,
query: SelectionResultQuery | None = None,
) -> SelectionRun | None: ...
def get_latest_run( def get_latest_run(
self, self,
strategy: Literal["zhixing_b1"], strategy: Literal["zhixing_b1"],
target_trade_date: date | None = None, target_trade_date: date | None = None,
*,
query: SelectionResultQuery | None = None,
) -> SelectionRun | None: ... ) -> SelectionRun | None: ...
@@ -18,6 +18,7 @@ from ..domain.models import SelectionSignal, ZhixingB1Category
from ..domain.runs import ( from ..domain.runs import (
SelectionExecutionSource, SelectionExecutionSource,
SelectionRerunRequired, SelectionRerunRequired,
SelectionResultQuery,
SelectionRun, SelectionRun,
SelectionRunError, SelectionRunError,
SelectionRunInProgress, SelectionRunInProgress,
@@ -29,6 +30,19 @@ from ..domain.runs import (
from ..domain.zhixing_b1 import ZHIXING_B1_SIGNAL_ORDER from ..domain.zhixing_b1 import ZHIXING_B1_SIGNAL_ORDER
_SIGNAL_PRIORITY = {category: index for index, category in enumerate(ZHIXING_B1_SIGNAL_ORDER)} _SIGNAL_PRIORITY = {category: index for index, category in enumerate(ZHIXING_B1_SIGNAL_ORDER)}
_CATEGORY_PREFIXES = {
"pullback": "zhixing_b1_pullback_",
"oversold": "zhixing_b1_oversold_",
"original": "zhixing_b1_original_b1",
}
_SIGNAL_ORDER_SQL = (
"CASE category "
+ " ".join(
f"WHEN '{category.value}' THEN {index}"
for index, category in enumerate(ZHIXING_B1_SIGNAL_ORDER)
)
+ f" ELSE {len(ZHIXING_B1_SIGNAL_ORDER)} END"
)
class PostgresSelectionRunRepository(SelectionRunStore): class PostgresSelectionRunRepository(SelectionRunStore):
@@ -224,12 +238,17 @@ class PostgresSelectionRunRepository(SelectionRunStore):
except psycopg.Error as exc: except psycopg.Error as exc:
raise SelectionRunStoreError(f"failed to finish selection run {run_id}") from exc raise SelectionRunStoreError(f"failed to finish selection run {run_id}") from exc
def get_run(self, run_id: str) -> SelectionRun | None: def get_run(
"""Read one run with its item failures and signal details.""" self,
run_id: str,
*,
query: SelectionResultQuery | None = None,
) -> SelectionRun | None:
"""Read one run with filtered, paged signals and item failures."""
try: try:
with self._connection() as connection: with self._connection() as connection:
return self._load_run(connection, run_id) return self._load_run(connection, run_id, query or SelectionResultQuery())
except psycopg.Error as exc: except psycopg.Error as exc:
raise SelectionRunStoreError(f"failed to load selection run {run_id}") from exc raise SelectionRunStoreError(f"failed to load selection run {run_id}") from exc
@@ -237,6 +256,8 @@ class PostgresSelectionRunRepository(SelectionRunStore):
self, self,
strategy: Literal["zhixing_b1"], strategy: Literal["zhixing_b1"],
target_trade_date: date | None = None, target_trade_date: date | None = None,
*,
query: SelectionResultQuery | None = None,
) -> SelectionRun | None: ) -> SelectionRun | None:
"""Read the current run for a date or the latest date for a strategy.""" """Read the current run for a date or the latest date for a strategy."""
@@ -263,12 +284,20 @@ class PostgresSelectionRunRepository(SelectionRunStore):
""", """,
(strategy, target_trade_date), (strategy, target_trade_date),
).fetchone() ).fetchone()
return self._load_run(connection, str(row[0])) if row else None return (
self._load_run(connection, str(row[0]), query or SelectionResultQuery())
if row
else None
)
except psycopg.Error as exc: except psycopg.Error as exc:
raise SelectionRunStoreError("failed to load latest selection run") from exc raise SelectionRunStoreError("failed to load latest selection run") from exc
@staticmethod @staticmethod
def _load_run(connection: Any, run_id: str) -> SelectionRun | None: def _load_run(
connection: Any,
run_id: str,
query: SelectionResultQuery,
) -> SelectionRun | None:
row = connection.execute( row = connection.execute(
""" """
SELECT SELECT
@@ -292,15 +321,23 @@ class PostgresSelectionRunRepository(SelectionRunStore):
""", """,
(run_id,), (run_id,),
).fetchall() ).fetchall()
signal_filter, signal_parameters = _signal_filter(query, run_id)
signal_total_row = connection.execute(
f"SELECT COUNT(*) FROM selection_signal WHERE {signal_filter}",
tuple(signal_parameters),
).fetchone()
signal_total = int(signal_total_row[0] or 0) if signal_total_row else 0
offset = (query.page - 1) * query.page_size
signal_rows = connection.execute( signal_rows = connection.execute(
""" f"""
SELECT SELECT
ts_code, name, target_trade_date, strategy, category, close, details ts_code, name, target_trade_date, strategy, category, close, details
FROM selection_signal FROM selection_signal
WHERE run_id = %s WHERE {signal_filter}
ORDER BY ts_code, category ORDER BY ts_code, {_SIGNAL_ORDER_SQL}
LIMIT %s OFFSET %s
""", """,
(run_id,), tuple((*signal_parameters, query.page_size, offset)),
).fetchall() ).fetchall()
signals = tuple( signals = tuple(
sorted( sorted(
@@ -353,6 +390,7 @@ class PostgresSelectionRunRepository(SelectionRunStore):
finished_at=cast(datetime | None, row[15]), finished_at=cast(datetime | None, row[15]),
items=items, items=items,
signals=signals, signals=signals,
signals_total=signal_total,
) )
@contextmanager @contextmanager
@@ -380,6 +418,27 @@ def _signal_from_row(row: tuple[object, ...]) -> SelectionSignal:
) )
def _signal_filter(query: SelectionResultQuery, run_id: str) -> tuple[str, list[object]]:
"""Build the parameterized WHERE clause shared by count and page reads."""
clauses = ["run_id = %s"]
parameters: list[object] = [run_id]
if query.search:
pattern = f"%{_escape_like(query.search)}%"
clauses.append("(name ILIKE %s ESCAPE '\\' OR ts_code ILIKE %s ESCAPE '\\')")
parameters.extend((pattern, pattern))
if query.category:
clauses.append("category LIKE %s")
parameters.append(f"{_CATEGORY_PREFIXES[query.category]}%")
return " AND ".join(clauses), parameters
def _escape_like(value: str) -> str:
"""Escape user wildcards before placing text inside a SQL LIKE pattern."""
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _details(value: object) -> dict[str, float | str | None]: def _details(value: object) -> dict[str, float | str | None]:
"""Normalize JSONB details into the domain's scalar-only mapping.""" """Normalize JSONB details into the domain's scalar-only mapping."""
@@ -3,7 +3,7 @@
from datetime import date, datetime from datetime import date, datetime
from typing import Annotated, Literal 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 pydantic import BaseModel, Field
from zhixing_server.bootstrap.config import Settings, get_settings 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 ( from zhixing_server.modules.selection.domain.runs import (
SelectionRerunRequired, SelectionRerunRequired,
SelectionResultQuery,
SelectionRun, SelectionRun,
SelectionRunInProgress, SelectionRunInProgress,
SelectionRunStoreError, SelectionRunStoreError,
@@ -88,7 +89,7 @@ def _empty_signals() -> list[SelectionSignalResponse]:
class SelectionResultsResponse(BaseModel): 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 strategy: StrategyValue
target_trade_date: date | None target_trade_date: date | None
@@ -106,6 +107,9 @@ class SelectionResultsResponse(BaseModel):
error_message: str | None = None error_message: str | None = None
created_at: datetime | None = None created_at: datetime | None = None
finished_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) failures: list[SelectionFailureResponse] = Field(default_factory=_empty_failures)
signals: list[SelectionSignalResponse] = Field(default_factory=_empty_signals) signals: list[SelectionSignalResponse] = Field(default_factory=_empty_signals)
@@ -160,16 +164,21 @@ def trigger_selection_run(
def get_selection_run( def get_selection_run(
run_id: str, run_id: str,
service: Annotated[RunZhixingB1, Depends(get_selection_service)], 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: ) -> SelectionResultsResponse:
"""Return one run for asynchronous polling.""" """Return one run for asynchronous polling."""
try: 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: except SelectionRunStoreError as exc:
raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc
if run is None: if run is None:
raise _http_error(404, "run_not_found", f"selection run not found: {run_id}") 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) @selection_router.get("/results", response_model=SelectionResultsResponse)
@@ -177,11 +186,16 @@ def get_selection_results(
service: Annotated[RunZhixingB1, Depends(get_selection_service)], service: Annotated[RunZhixingB1, Depends(get_selection_service)],
strategy: StrategyValue = "zhixing_b1", strategy: StrategyValue = "zhixing_b1",
target_trade_date: date | None = None, 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: ) -> SelectionResultsResponse:
"""Return the current persisted result for a strategy and optional date.""" """Return the current persisted result for a strategy and optional date."""
try: 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: except SelectionRunStoreError as exc:
raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc
if run is None: if run is None:
@@ -192,11 +206,14 @@ def get_selection_results(
market_sync_batch_id=None, market_sync_batch_id=None,
status="no_data", status="no_data",
coverage=0, 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.""" """Translate a domain run without exposing storage-specific fields."""
return SelectionResultsResponse( return SelectionResultsResponse(
@@ -216,6 +233,9 @@ def _run_response(run: SelectionRun) -> SelectionResultsResponse:
error_message=run.error_message, error_message=run.error_message,
created_at=run.created_at, created_at=run.created_at,
finished_at=run.finished_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=[ failures=[
SelectionFailureResponse( SelectionFailureResponse(
ts_code=item.ts_code, 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: def _http_error(code: int, error_type: str, message: str) -> HTTPException:
"""Create the project's explicit, safe error envelope.""" """Create the project's explicit, safe error envelope."""
+49 -1
View File
@@ -11,6 +11,7 @@ from zhixing_server.modules.selection.domain.models import SelectionSignal, Zhix
from zhixing_server.modules.selection.domain.runs import ( from zhixing_server.modules.selection.domain.runs import (
SelectionExecutionSource, SelectionExecutionSource,
SelectionRerunRequired, SelectionRerunRequired,
SelectionResultQuery,
SelectionRun, SelectionRun,
SelectionRunInProgress, SelectionRunInProgress,
SelectionStock, SelectionStock,
@@ -28,6 +29,7 @@ class FakeSelectionService:
self.run = run self.run = run
self.executed = False self.executed = False
self.mode = "ok" self.mode = "ok"
self.last_query: SelectionResultQuery | None = None
def prepare( def prepare(
self, self,
@@ -58,14 +60,23 @@ class FakeSelectionService:
def execute(self, prepared: PreparedSelectionRun) -> None: def execute(self, prepared: PreparedSelectionRun) -> None:
self.executed = True self.executed = True
def get_run(self, run_id: str) -> SelectionRun | None: def get_run(
self,
run_id: str,
*,
query: SelectionResultQuery | None = None,
) -> SelectionRun | None:
self.last_query = query
return self.run if self.run and self.run.id == run_id else None return self.run if self.run and self.run.id == run_id else None
def get_latest( def get_latest(
self, self,
strategy: str, strategy: str,
target_trade_date: date | None = None, target_trade_date: date | None = None,
*,
query: SelectionResultQuery | None = None,
) -> SelectionRun | None: ) -> SelectionRun | None:
self.last_query = query
if self.run is None: if self.run is None:
return None return None
if target_trade_date is not None and self.run.target_trade_date != target_trade_date: if target_trade_date is not None and self.run.target_trade_date != target_trade_date:
@@ -193,10 +204,47 @@ def test_query_returns_persisted_signal_details() -> None:
body = response.json() body = response.json()
assert body["run_id"] == "run-http" assert body["run_id"] == "run-http"
assert body["signal_count"] == 1 assert body["signal_count"] == 1
assert body["page"] == 1
assert body["page_size"] == 10
assert body["signals_total"] == 1
assert body["signals"][0]["category"] == "zhixing_b1_original_b1" assert body["signals"][0]["category"] == "zhixing_b1_original_b1"
assert body["signals"][0]["details"] == {"j": 12.0} assert body["signals"][0]["details"] == {"j": 12.0}
def test_query_forwards_pagination_and_filters() -> None:
service = FakeSelectionService(_run("run-http", "success"))
response = _client(service).get(
"/api/v1/selection/results",
params={
"strategy": "zhixing_b1",
"page": 2,
"page_size": 5,
"search": " 平安银行 ",
"category": "original",
},
)
assert response.status_code == 200
assert service.last_query == SelectionResultQuery(
page=2,
page_size=5,
search="平安银行",
category="original",
)
assert response.json()["page"] == 2
assert response.json()["page_size"] == 5
def test_query_rejects_invalid_page_size() -> None:
response = _client(FakeSelectionService()).get(
"/api/v1/selection/results",
params={"strategy": "zhixing_b1", "page_size": 101},
)
assert response.status_code == 422
def test_run_polling_returns_the_persisted_terminal_result() -> None: def test_run_polling_returns_the_persisted_terminal_result() -> None:
response = _client(FakeSelectionService(_run("run-http", "success"))).get( response = _client(FakeSelectionService(_run("run-http", "success"))).get(
"/api/v1/selection/runs/run-http" "/api/v1/selection/runs/run-http"
@@ -11,6 +11,7 @@ from zhixing_server.modules.selection.domain.models import SelectionSignal, Zhix
from zhixing_server.modules.selection.domain.runs import ( from zhixing_server.modules.selection.domain.runs import (
SelectionExecutionSource, SelectionExecutionSource,
SelectionRerunRequired, SelectionRerunRequired,
SelectionResultQuery,
SelectionRunInProgress, SelectionRunInProgress,
SelectionRunItem, SelectionRunItem,
SelectionStock, SelectionStock,
@@ -163,6 +164,9 @@ def test_record_item_persists_independent_signals_as_jsonb(monkeypatch: pytest.M
class LoadConnection: class LoadConnection:
def __init__(self) -> None:
self.statements: list[tuple[str, tuple[object, ...]]] = []
def __enter__(self) -> "LoadConnection": def __enter__(self) -> "LoadConnection":
return self return self
@@ -170,6 +174,7 @@ class LoadConnection:
return None return None
def execute(self, query: str, parameters: tuple[object, ...]) -> "LoadResult": def execute(self, query: str, parameters: tuple[object, ...]) -> "LoadResult":
self.statements.append((query, parameters))
if "FROM selection_run\n" in query: if "FROM selection_run\n" in query:
return LoadResult( return LoadResult(
row=( row=(
@@ -193,6 +198,8 @@ class LoadConnection:
) )
if "FROM selection_run_item" in query: if "FROM selection_run_item" in query:
return LoadResult(rows=[("000001.SZ", "平安银行", "selected", 2, None)]) return LoadResult(rows=[("000001.SZ", "平安银行", "selected", 2, None)])
if "COUNT(*) FROM selection_signal" in query:
return LoadResult(row=(2,))
return LoadResult( return LoadResult(
rows=[ rows=[
( (
@@ -242,10 +249,32 @@ def test_get_run_orders_signals_by_formula_priority(monkeypatch: pytest.MonkeyPa
return connection return connection
monkeypatch.setattr(psycopg, "connect", connect) monkeypatch.setattr(psycopg, "connect", connect)
run = PostgresSelectionRunRepository("postgresql://test").get_run("run-1") run = PostgresSelectionRunRepository("postgresql://test").get_run(
"run-1",
query=SelectionResultQuery(
page=2,
page_size=1,
search="100%",
category="pullback",
),
)
assert run is not None assert run is not None
assert [signal.category for signal in run.signals] == [ assert [signal.category for signal in run.signals] == [
ZHIXING_B1_SIGNAL_ORDER[0], ZHIXING_B1_SIGNAL_ORDER[0],
ZHIXING_B1_SIGNAL_ORDER[-1], ZHIXING_B1_SIGNAL_ORDER[-1],
] ]
count_query, count_parameters = next(
(query, parameters)
for query, parameters in connection.statements
if "COUNT(*) FROM selection_signal" in query
)
assert "name ILIKE %s ESCAPE" in count_query
assert count_parameters == ("run-1", "%100\\%%", "%100\\%%", "zhixing_b1_pullback_%")
page_query, page_parameters = next(
(query, parameters)
for query, parameters in connection.statements
if "LIMIT %s OFFSET %s" in query
)
assert "ORDER BY ts_code, CASE category" in page_query
assert page_parameters[-2:] == (1, 1)
@@ -15,6 +15,7 @@ from zhixing_server.modules.selection.domain.models import (
) )
from zhixing_server.modules.selection.domain.runs import ( from zhixing_server.modules.selection.domain.runs import (
SelectionExecutionSource, SelectionExecutionSource,
SelectionResultQuery,
SelectionRun, SelectionRun,
SelectionRunItem, SelectionRunItem,
SelectionRunStatus, SelectionRunStatus,
@@ -100,10 +101,16 @@ class FakeStore:
kwargs["error_message"] = error_message kwargs["error_message"] = error_message
self.finished = (run_id, status, kwargs) self.finished = (run_id, status, kwargs)
def get_run(self, run_id: str): def get_run(self, run_id: str, *, query: SelectionResultQuery | None = None):
return None return None
def get_latest_run(self, strategy: str, target_trade_date: date | None = None): def get_latest_run(
self,
strategy: str,
target_trade_date: date | None = None,
*,
query: SelectionResultQuery | None = None,
):
return None return None
@@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const requestJson = vi.hoisted(() => vi.fn())
vi.mock("@/shared/api/request-json", () => ({ requestJson }))
import { getSelectionResults, getSelectionRun } from "./selection.api"
describe("selection API adapters", () => {
beforeEach(() => {
requestJson.mockReset()
})
it("sends paged result filters using the HTTP field names", async () => {
await getSelectionResults("zhixing_b1", "2026-08-08", {
category: "pullback",
page: 2,
pageSize: 15,
search: "平安银行",
})
const [input, init] = requestJson.mock.calls[0] as [
string,
{ signal?: AbortSignal },
]
const params = new URL(input, "http://localhost").searchParams
expect(params.get("strategy")).toBe("zhixing_b1")
expect(params.get("target_trade_date")).toBe("2026-08-08")
expect(params.get("page")).toBe("2")
expect(params.get("page_size")).toBe("15")
expect(params.get("search")).toBe("平安银行")
expect(params.get("category")).toBe("pullback")
expect(init).toEqual({ signal: undefined })
})
it("uses the same query contract while polling a run", async () => {
await getSelectionRun("run-1", { page: 3, pageSize: 5 })
const [input] = requestJson.mock.calls[0] as [string]
const params = new URL(input, "http://localhost").searchParams
expect(input).toContain("/api/v1/selection/runs/run-1?")
expect(params.get("page")).toBe("3")
expect(params.get("page_size")).toBe("5")
expect(params.has("search")).toBe(false)
expect(params.has("category")).toBe(false)
})
})
@@ -2,6 +2,7 @@ import { requestJson } from "@/shared/api/request-json"
import type { import type {
SelectionResults, SelectionResults,
SelectionResultsQuery,
SelectionRunAccepted, SelectionRunAccepted,
SelectionRunRequest, SelectionRunRequest,
SelectionStrategy, SelectionStrategy,
@@ -10,9 +11,11 @@ import type {
export function getSelectionResults( export function getSelectionResults(
strategy: SelectionStrategy, strategy: SelectionStrategy,
targetTradeDate?: string, targetTradeDate?: string,
query: SelectionResultsQuery = { page: 1, pageSize: 10 },
signal?: AbortSignal, signal?: AbortSignal,
) { ) {
const params = new URLSearchParams({ strategy }) const params = buildSelectionQueryParams(query)
params.set("strategy", strategy)
if (targetTradeDate) params.set("target_trade_date", targetTradeDate) if (targetTradeDate) params.set("target_trade_date", targetTradeDate)
return requestJson<SelectionResults>( return requestJson<SelectionResults>(
`/api/v1/selection/results?${params.toString()}`, `/api/v1/selection/results?${params.toString()}`,
@@ -20,10 +23,28 @@ export function getSelectionResults(
) )
} }
export function getSelectionRun(runId: string, signal?: AbortSignal) { export function getSelectionRun(
return requestJson<SelectionResults>(`/api/v1/selection/runs/${runId}`, { runId: string,
query: SelectionResultsQuery = { page: 1, pageSize: 10 },
signal?: AbortSignal,
) {
const params = buildSelectionQueryParams(query)
return requestJson<SelectionResults>(
`/api/v1/selection/runs/${runId}?${params.toString()}`,
{
signal, signal,
},
)
}
function buildSelectionQueryParams(query: SelectionResultsQuery) {
const params = new URLSearchParams({
page: String(query.page),
page_size: String(query.pageSize),
}) })
if (query.search) params.set("search", query.search)
if (query.category) params.set("category", query.category)
return params
} }
export function triggerSelectionRun( export function triggerSelectionRun(
@@ -11,33 +11,63 @@ import {
getSelectionRun, getSelectionRun,
triggerSelectionRun, triggerSelectionRun,
} from "./selection.api" } from "./selection.api"
import type { SelectionRunRequest, SelectionStrategy } from "./selection.types" import type {
SelectionResultsQuery,
SelectionRunRequest,
SelectionStrategy,
} from "./selection.types"
export const selectionResultsQueryKey = ( export const selectionResultsQueryKey = (
strategy: SelectionStrategy, strategy: SelectionStrategy,
targetTradeDate?: string, targetTradeDate?: string,
) => ["selection", "results", strategy, targetTradeDate] as const query: SelectionResultsQuery = { page: 1, pageSize: 10 },
) =>
[
"selection",
"results",
strategy,
targetTradeDate,
query.page,
query.pageSize,
query.search ?? "",
query.category ?? "all",
] as const
export const selectionRunQueryKey = (runId: string) => export const selectionRunQueryKey = (
["selection", "runs", runId] as const runId: string,
query: SelectionResultsQuery = { page: 1, pageSize: 10 },
) =>
[
"selection",
"runs",
runId,
query.page,
query.pageSize,
query.search ?? "",
query.category ?? "all",
] as const
export function useSelectionResults( export function useSelectionResults(
strategy: SelectionStrategy, strategy: SelectionStrategy,
targetTradeDate?: string, targetTradeDate?: string,
query: SelectionResultsQuery = { page: 1, pageSize: 10 },
) { ) {
return useQuery({ return useQuery({
queryFn: ({ signal }) => queryFn: ({ signal }) =>
getSelectionResults(strategy, targetTradeDate, signal), getSelectionResults(strategy, targetTradeDate, query, signal),
queryKey: selectionResultsQueryKey(strategy, targetTradeDate), queryKey: selectionResultsQueryKey(strategy, targetTradeDate, query),
}) })
} }
export function useSelectionRun(runId: string | null) { export function useSelectionRun(
runId: string | null,
resultQuery: SelectionResultsQuery = { page: 1, pageSize: 10 },
) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const query = useQuery({ const query = useQuery({
enabled: Boolean(runId), enabled: Boolean(runId),
queryFn: ({ signal }) => getSelectionRun(runId ?? "", signal), queryFn: ({ signal }) => getSelectionRun(runId ?? "", resultQuery, signal),
queryKey: selectionRunQueryKey(runId ?? "none"), queryKey: selectionRunQueryKey(runId ?? "none", resultQuery),
refetchInterval: (query) => refetchInterval: (query) =>
query.state.data?.status === "running" ? 1500 : false, query.state.data?.status === "running" ? 1500 : false,
}) })
@@ -1,5 +1,22 @@
export type SelectionStrategy = "zhixing_b1" export type SelectionStrategy = "zhixing_b1"
export type SelectionCategoryFilter =
"all" | "pullback" | "oversold" | "original"
export const selectionCategoryFilters = [
"all",
"pullback",
"oversold",
"original",
] as const
export interface SelectionResultsQuery {
page: number
pageSize: number
search?: string
category?: Exclude<SelectionCategoryFilter, "all">
}
export type SelectionRunStatus = export type SelectionRunStatus =
"no_data" | "running" | "success" | "partial_success" | "failed" "no_data" | "running" | "success" | "partial_success" | "failed"
@@ -50,6 +67,9 @@ export interface SelectionResults {
error_message: string | null error_message: string | null
created_at: string | null created_at: string | null
finished_at: string | null finished_at: string | null
page: number
page_size: number
signals_total: number
failures: SelectionFailure[] failures: SelectionFailure[]
signals: SelectionSignal[] signals: SelectionSignal[]
} }
@@ -1,4 +1,7 @@
import type { SelectionSignal } from "../api/selection.types" import type {
SelectionCategoryFilter,
SelectionSignal,
} from "../api/selection.types"
export const categoryLabels: Record<string, string> = { export const categoryLabels: Record<string, string> = {
zhixing_b1_extreme_volume: "超卖超缩量 B", zhixing_b1_extreme_volume: "超卖超缩量 B",
@@ -10,7 +13,7 @@ export const categoryLabels: Record<string, string> = {
zhixing_b1_pullback_yellow: "回踩黄线 B", zhixing_b1_pullback_yellow: "回踩黄线 B",
} }
export type SignalCategoryFilter = "all" | "pullback" | "oversold" | "original" export type SignalCategoryFilter = SelectionCategoryFilter
export const signalCategoryOptions: ReadonlyArray<{ export const signalCategoryOptions: ReadonlyArray<{
label: string label: string
@@ -15,7 +15,7 @@ import {
} from "./selection-presentation" } from "./selection-presentation"
import { SignalTable } from "./signal-table" import { SignalTable } from "./signal-table"
const PAGE_SIZE_OPTIONS = [5, 10, 20] as const const PAGE_SIZE_OPTIONS = [5, 10, 15] as const
interface SelectionResultsWorkbenchProps { interface SelectionResultsWorkbenchProps {
result: SelectionResults result: SelectionResults
@@ -26,14 +26,14 @@ export function SelectionResultsWorkbench({
}: SelectionResultsWorkbenchProps) { }: SelectionResultsWorkbenchProps) {
const search = useSearch({ from: "/_workspace/selection" }) const search = useSearch({ from: "/_workspace/selection" })
const navigate = useNavigate({ from: "/selection" }) const navigate = useNavigate({ from: "/selection" })
const [query, setQuery] = useState("") const query = search.search ?? ""
const [category, setCategory] = useState<SignalCategoryFilter>("all") const category = search.category ?? "all"
const [selectedKey, setSelectedKey] = useState<string | null>(null) const [selectedKey, setSelectedKey] = useState<string | null>(null)
const [expandedKeys, setExpandedKeys] = useState<ReadonlySet<string>>( const [expandedKeys, setExpandedKeys] = useState<ReadonlySet<string>>(
new Set(), new Set(),
) )
const filteredSignals = useMemo(() => { const visibleSignals = useMemo(() => {
const normalized = query.trim().toLowerCase() const normalized = query.trim().toLowerCase()
return result.signals.filter((signal) => { return result.signals.filter((signal) => {
const matchesQuery = const matchesQuery =
@@ -46,12 +46,10 @@ export function SelectionResultsWorkbench({
const page = search.page const page = search.page
const pageSize = search.pageSize const pageSize = search.pageSize
const pageCount = Math.max(1, Math.ceil(filteredSignals.length / pageSize)) const signalsTotal = result.signals_total ?? result.signal_count
const pageCount = Math.max(1, Math.ceil(signalsTotal / pageSize))
const currentPage = Math.min(page, pageCount) const currentPage = Math.min(page, pageCount)
const currentSignals = filteredSignals.slice( const currentSignals = visibleSignals
(currentPage - 1) * pageSize,
currentPage * pageSize,
)
const selectedSignal = const selectedSignal =
currentSignals.find((signal) => getSignalKey(signal) === selectedKey) ?? currentSignals.find((signal) => getSignalKey(signal) === selectedKey) ??
currentSignals[0] currentSignals[0]
@@ -64,18 +62,21 @@ export function SelectionResultsWorkbench({
} }
}, [navigate, page, pageCount]) }, [navigate, page, pageCount])
function updateSearch(next: { page?: number; pageSize?: number }) { function updateSearch(next: {
page?: number
pageSize?: number
search?: string
category?: SignalCategoryFilter
}) {
void navigate({ search: (previous) => ({ ...previous, ...next }) }) void navigate({ search: (previous) => ({ ...previous, ...next }) })
} }
function handleQueryChange(value: string) { function handleQueryChange(value: string) {
setQuery(value) updateSearch({ page: 1, search: value })
updateSearch({ page: 1 })
} }
function handleCategoryChange(value: SignalCategoryFilter) { function handleCategoryChange(value: SignalCategoryFilter) {
setCategory(value) updateSearch({ category: value, page: 1 })
updateSearch({ page: 1 })
} }
function handleToggleExpanded(signal: SelectionSignal) { function handleToggleExpanded(signal: SelectionSignal) {
@@ -115,7 +116,7 @@ export function SelectionResultsWorkbench({
))} ))}
</select> </select>
<span className="text-xs tabular-nums text-muted-foreground sm:justify-self-end"> <span className="text-xs tabular-nums text-muted-foreground sm:justify-self-end">
筛选结果 {filteredSignals.length} 条 筛选结果 {signalsTotal} 条
</span> </span>
</div> </div>
@@ -141,7 +142,7 @@ export function SelectionResultsWorkbench({
page={currentPage} page={currentPage}
pageSize={pageSize} pageSize={pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS} pageSizeOptions={PAGE_SIZE_OPTIONS}
total={filteredSignals.length} total={signalsTotal}
/> />
</section> </section>
@@ -20,7 +20,7 @@ vi.mock("@/features/selection/api/selection.query", () => ({
vi.mock("@tanstack/react-router", () => ({ vi.mock("@tanstack/react-router", () => ({
useNavigate: () => routerNavigate, useNavigate: () => routerNavigate,
useSearch: () => ({ page: 1, pageSize: 5 }), useSearch: () => ({ page: 1, pageSize: 5, search: "", category: "all" }),
})) }))
const selectedResult: SelectionResults = { const selectedResult: SelectionResults = {
@@ -34,9 +34,12 @@ const selectedResult: SelectionResults = {
failures: [], failures: [],
finished_at: "2026-08-08T09:02:00+08:00", finished_at: "2026-08-08T09:02:00+08:00",
market_sync_batch_id: "market-run-1", market_sync_batch_id: "market-run-1",
page: 1,
page_size: 5,
run_id: "run-1", run_id: "run-1",
selected_stock_count: 1, selected_stock_count: 1,
signal_count: 2, signal_count: 2,
signals_total: 2,
signals: [ signals: [
{ {
category: "zhixing_b1_original_b1", category: "zhixing_b1_original_b1",
@@ -142,7 +145,12 @@ describe("SelectionResultsPage", () => {
ts_code: `00000${index + 1}.SZ`, ts_code: `00000${index + 1}.SZ`,
})) }))
useSelectionResults.mockReturnValue({ useSelectionResults.mockReturnValue({
data: { ...selectedResult, signal_count: signals.length, signals }, data: {
...selectedResult,
signal_count: signals.length,
signals,
signals_total: signals.length,
},
isError: false, isError: false,
isPending: false, isPending: false,
}) })
@@ -262,7 +270,10 @@ describe("SelectionResultsPage", () => {
expect(screen.getByText("策略执行中")).toBeInTheDocument() expect(screen.getByText("策略执行中")).toBeInTheDocument()
expect(screen.getByRole("button", { name: "执行中" })).toBeDisabled() expect(screen.getByRole("button", { name: "执行中" })).toBeDisabled()
expect(useSelectionRun).toHaveBeenCalledWith("run-1") expect(useSelectionRun).toHaveBeenCalledWith("run-1", {
page: 1,
pageSize: 5,
})
}) })
it("renders query failures explicitly", () => { it("renders query failures explicitly", () => {
@@ -5,6 +5,7 @@ import {
RefreshCw, RefreshCw,
Search, Search,
} from "lucide-react" } from "lucide-react"
import { useSearch } from "@tanstack/react-router"
import { useState } from "react" import { useState } from "react"
import { PageLayout } from "@/app/layout/page-layout" import { PageLayout } from "@/app/layout/page-layout"
@@ -15,6 +16,7 @@ import {
} from "@/features/selection/api/selection.query" } from "@/features/selection/api/selection.query"
import type { import type {
SelectionResults, SelectionResults,
SelectionResultsQuery,
SelectionRunStatus, SelectionRunStatus,
} from "@/features/selection/api/selection.types" } from "@/features/selection/api/selection.types"
import { Badge } from "@/shared/ui/badge" import { Badge } from "@/shared/ui/badge"
@@ -54,12 +56,23 @@ export function SelectionResultsPage() {
const [targetTradeDate, setTargetTradeDate] = useState<string | null>(null) const [targetTradeDate, setTargetTradeDate] = useState<string | null>(null)
const [activeRunId, setActiveRunId] = useState<string | null>(null) const [activeRunId, setActiveRunId] = useState<string | null>(null)
const [rerunDialogOpen, setRerunDialogOpen] = useState(false) const [rerunDialogOpen, setRerunDialogOpen] = useState(false)
const search = useSearch({ from: "/_workspace/selection" })
const resultQuery: SelectionResultsQuery = {
page: search.page,
pageSize: search.pageSize,
...(search.search ? { search: search.search } : {}),
...(search.category !== "all" ? { category: search.category } : {}),
}
const results = useSelectionResults(STRATEGY, targetTradeDate || undefined) const results = useSelectionResults(
STRATEGY,
targetTradeDate || undefined,
resultQuery,
)
const persistedRunningRunId = const persistedRunningRunId =
results.data?.status === "running" ? results.data.run_id : null results.data?.status === "running" ? results.data.run_id : null
const polledRunId = activeRunId ?? persistedRunningRunId const polledRunId = activeRunId ?? persistedRunningRunId
const activeRun = useSelectionRun(polledRunId) const activeRun = useSelectionRun(polledRunId, resultQuery)
const trigger = useTriggerSelectionRun() const trigger = useTriggerSelectionRun()
const selectedTargetTradeDate = const selectedTargetTradeDate =
@@ -420,7 +433,9 @@ function ResultState({ result }: { result: SelectionResults }) {
{result.signal_count === 0 ? ( {result.signal_count === 0 ? (
<NoSignalState /> <NoSignalState />
) : ( ) : (
<div className="min-h-[420px] shrink-0">
<SelectionResultsWorkbench result={result} /> <SelectionResultsWorkbench result={result} />
</div>
)} )}
{result.failures.length > 0 ? <FailureList result={result} /> : null} {result.failures.length > 0 ? <FailureList result={result} /> : null}
</div> </div>
+15 -2
View File
@@ -2,6 +2,10 @@ import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router"
import { AppLayout } from "@/app/layout/app-layout" import { AppLayout } from "@/app/layout/app-layout"
import { HomePage } from "@/features/home/pages/home-page" import { HomePage } from "@/features/home/pages/home-page"
import {
selectionCategoryFilters,
type SelectionCategoryFilter,
} from "@/features/selection/api/selection.types"
import { SelectionResultsPage } from "@/features/selection/pages/selection-results-page" import { SelectionResultsPage } from "@/features/selection/pages/selection-results-page"
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({
@@ -28,8 +32,17 @@ const selectionRoute = createRoute({
const rawPageSize = Number(search.pageSize) const rawPageSize = Number(search.pageSize)
const page = const page =
Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : 1 Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : 1
const pageSize = [5, 10, 20].includes(rawPageSize) ? rawPageSize : 10 const pageSize = [5, 10, 15].includes(rawPageSize) ? rawPageSize : 10
return { page, pageSize } const searchValue =
typeof search.search === "string" ? search.search.slice(0, 100) : ""
const rawCategory =
typeof search.category === "string" ? search.category : "all"
const category: SelectionCategoryFilter = selectionCategoryFilters.includes(
rawCategory as SelectionCategoryFilter,
)
? (rawCategory as SelectionCategoryFilter)
: "all"
return { page, pageSize, search: searchValue, category }
}, },
component: SelectionResultsPage, component: SelectionResultsPage,
}) })