feat(selection): 支持选股结果服务端分页
This commit is contained in:
@@ -11,6 +11,7 @@ from ..domain.models import SelectionEvaluation
|
||||
from ..domain.runs import (
|
||||
SelectionExecutionSource,
|
||||
SelectionRerunRequired,
|
||||
SelectionResultQuery,
|
||||
SelectionRun,
|
||||
SelectionRunInProgress,
|
||||
SelectionRunItem,
|
||||
@@ -135,19 +136,26 @@ class RunZhixingB1:
|
||||
except Exception: # noqa: BLE001 - preserve the original worker failure
|
||||
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."""
|
||||
|
||||
return self.store.get_run(run_id)
|
||||
return self.store.get_run(run_id, query=query)
|
||||
|
||||
def get_latest(
|
||||
self,
|
||||
strategy: StrategyName,
|
||||
target_trade_date: date | None = None,
|
||||
*,
|
||||
query: SelectionResultQuery | None = None,
|
||||
) -> SelectionRun | None:
|
||||
"""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:
|
||||
|
||||
@@ -11,6 +11,17 @@ from .models import SelectionEvaluationStatus, SelectionSignal, StockHistory
|
||||
|
||||
SelectionRunStatus = Literal["running", "success", "partial_success", "failed"]
|
||||
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)
|
||||
@@ -67,6 +78,7 @@ class SelectionRun:
|
||||
finished_at: datetime | None = None
|
||||
items: tuple[SelectionRunItem, ...] = field(default_factory=tuple)
|
||||
signals: tuple[SelectionSignal, ...] = field(default_factory=tuple)
|
||||
signals_total: int | None = None
|
||||
|
||||
|
||||
class SelectionRunError(RuntimeError):
|
||||
@@ -112,12 +124,19 @@ class SelectionRunStore(Protocol):
|
||||
error_message: str | 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(
|
||||
self,
|
||||
strategy: Literal["zhixing_b1"],
|
||||
target_trade_date: date | None = None,
|
||||
*,
|
||||
query: SelectionResultQuery | None = None,
|
||||
) -> SelectionRun | None: ...
|
||||
|
||||
|
||||
|
||||
+68
-9
@@ -18,6 +18,7 @@ from ..domain.models import SelectionSignal, ZhixingB1Category
|
||||
from ..domain.runs import (
|
||||
SelectionExecutionSource,
|
||||
SelectionRerunRequired,
|
||||
SelectionResultQuery,
|
||||
SelectionRun,
|
||||
SelectionRunError,
|
||||
SelectionRunInProgress,
|
||||
@@ -29,6 +30,19 @@ from ..domain.runs import (
|
||||
from ..domain.zhixing_b1 import 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):
|
||||
@@ -224,12 +238,17 @@ class PostgresSelectionRunRepository(SelectionRunStore):
|
||||
except psycopg.Error as exc:
|
||||
raise SelectionRunStoreError(f"failed to finish selection run {run_id}") from exc
|
||||
|
||||
def get_run(self, run_id: str) -> SelectionRun | None:
|
||||
"""Read one run with its item failures and signal details."""
|
||||
def get_run(
|
||||
self,
|
||||
run_id: str,
|
||||
*,
|
||||
query: SelectionResultQuery | None = None,
|
||||
) -> SelectionRun | None:
|
||||
"""Read one run with filtered, paged signals and item failures."""
|
||||
|
||||
try:
|
||||
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:
|
||||
raise SelectionRunStoreError(f"failed to load selection run {run_id}") from exc
|
||||
|
||||
@@ -237,6 +256,8 @@ class PostgresSelectionRunRepository(SelectionRunStore):
|
||||
self,
|
||||
strategy: Literal["zhixing_b1"],
|
||||
target_trade_date: date | None = None,
|
||||
*,
|
||||
query: SelectionResultQuery | None = None,
|
||||
) -> SelectionRun | None:
|
||||
"""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),
|
||||
).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:
|
||||
raise SelectionRunStoreError("failed to load latest selection run") from exc
|
||||
|
||||
@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(
|
||||
"""
|
||||
SELECT
|
||||
@@ -292,15 +321,23 @@ class PostgresSelectionRunRepository(SelectionRunStore):
|
||||
""",
|
||||
(run_id,),
|
||||
).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(
|
||||
"""
|
||||
f"""
|
||||
SELECT
|
||||
ts_code, name, target_trade_date, strategy, category, close, details
|
||||
FROM selection_signal
|
||||
WHERE run_id = %s
|
||||
ORDER BY ts_code, category
|
||||
WHERE {signal_filter}
|
||||
ORDER BY ts_code, {_SIGNAL_ORDER_SQL}
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(run_id,),
|
||||
tuple((*signal_parameters, query.page_size, offset)),
|
||||
).fetchall()
|
||||
signals = tuple(
|
||||
sorted(
|
||||
@@ -353,6 +390,7 @@ class PostgresSelectionRunRepository(SelectionRunStore):
|
||||
finished_at=cast(datetime | None, row[15]),
|
||||
items=items,
|
||||
signals=signals,
|
||||
signals_total=signal_total,
|
||||
)
|
||||
|
||||
@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]:
|
||||
"""Normalize JSONB details into the domain's scalar-only mapping."""
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from zhixing_server.modules.selection.domain.models import SelectionSignal, Zhix
|
||||
from zhixing_server.modules.selection.domain.runs import (
|
||||
SelectionExecutionSource,
|
||||
SelectionRerunRequired,
|
||||
SelectionResultQuery,
|
||||
SelectionRun,
|
||||
SelectionRunInProgress,
|
||||
SelectionStock,
|
||||
@@ -28,6 +29,7 @@ class FakeSelectionService:
|
||||
self.run = run
|
||||
self.executed = False
|
||||
self.mode = "ok"
|
||||
self.last_query: SelectionResultQuery | None = None
|
||||
|
||||
def prepare(
|
||||
self,
|
||||
@@ -58,14 +60,23 @@ class FakeSelectionService:
|
||||
def execute(self, prepared: PreparedSelectionRun) -> None:
|
||||
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
|
||||
|
||||
def get_latest(
|
||||
self,
|
||||
strategy: str,
|
||||
target_trade_date: date | None = None,
|
||||
*,
|
||||
query: SelectionResultQuery | None = None,
|
||||
) -> SelectionRun | None:
|
||||
self.last_query = query
|
||||
if self.run is None:
|
||||
return None
|
||||
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()
|
||||
assert body["run_id"] == "run-http"
|
||||
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]["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:
|
||||
response = _client(FakeSelectionService(_run("run-http", "success"))).get(
|
||||
"/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 (
|
||||
SelectionExecutionSource,
|
||||
SelectionRerunRequired,
|
||||
SelectionResultQuery,
|
||||
SelectionRunInProgress,
|
||||
SelectionRunItem,
|
||||
SelectionStock,
|
||||
@@ -163,6 +164,9 @@ def test_record_item_persists_independent_signals_as_jsonb(monkeypatch: pytest.M
|
||||
|
||||
|
||||
class LoadConnection:
|
||||
def __init__(self) -> None:
|
||||
self.statements: list[tuple[str, tuple[object, ...]]] = []
|
||||
|
||||
def __enter__(self) -> "LoadConnection":
|
||||
return self
|
||||
|
||||
@@ -170,6 +174,7 @@ class LoadConnection:
|
||||
return None
|
||||
|
||||
def execute(self, query: str, parameters: tuple[object, ...]) -> "LoadResult":
|
||||
self.statements.append((query, parameters))
|
||||
if "FROM selection_run\n" in query:
|
||||
return LoadResult(
|
||||
row=(
|
||||
@@ -193,6 +198,8 @@ class LoadConnection:
|
||||
)
|
||||
if "FROM selection_run_item" in query:
|
||||
return LoadResult(rows=[("000001.SZ", "平安银行", "selected", 2, None)])
|
||||
if "COUNT(*) FROM selection_signal" in query:
|
||||
return LoadResult(row=(2,))
|
||||
return LoadResult(
|
||||
rows=[
|
||||
(
|
||||
@@ -242,10 +249,32 @@ def test_get_run_orders_signals_by_formula_priority(monkeypatch: pytest.MonkeyPa
|
||||
return connection
|
||||
|
||||
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 [signal.category for signal in run.signals] == [
|
||||
ZHIXING_B1_SIGNAL_ORDER[0],
|
||||
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 (
|
||||
SelectionExecutionSource,
|
||||
SelectionResultQuery,
|
||||
SelectionRun,
|
||||
SelectionRunItem,
|
||||
SelectionRunStatus,
|
||||
@@ -100,10 +101,16 @@ class FakeStore:
|
||||
kwargs["error_message"] = error_message
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
SelectionResults,
|
||||
SelectionResultsQuery,
|
||||
SelectionRunAccepted,
|
||||
SelectionRunRequest,
|
||||
SelectionStrategy,
|
||||
@@ -10,9 +11,11 @@ import type {
|
||||
export function getSelectionResults(
|
||||
strategy: SelectionStrategy,
|
||||
targetTradeDate?: string,
|
||||
query: SelectionResultsQuery = { page: 1, pageSize: 10 },
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const params = new URLSearchParams({ strategy })
|
||||
const params = buildSelectionQueryParams(query)
|
||||
params.set("strategy", strategy)
|
||||
if (targetTradeDate) params.set("target_trade_date", targetTradeDate)
|
||||
return requestJson<SelectionResults>(
|
||||
`/api/v1/selection/results?${params.toString()}`,
|
||||
@@ -20,10 +23,28 @@ export function getSelectionResults(
|
||||
)
|
||||
}
|
||||
|
||||
export function getSelectionRun(runId: string, signal?: AbortSignal) {
|
||||
return requestJson<SelectionResults>(`/api/v1/selection/runs/${runId}`, {
|
||||
export function getSelectionRun(
|
||||
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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
@@ -11,33 +11,63 @@ import {
|
||||
getSelectionRun,
|
||||
triggerSelectionRun,
|
||||
} from "./selection.api"
|
||||
import type { SelectionRunRequest, SelectionStrategy } from "./selection.types"
|
||||
import type {
|
||||
SelectionResultsQuery,
|
||||
SelectionRunRequest,
|
||||
SelectionStrategy,
|
||||
} from "./selection.types"
|
||||
|
||||
export const selectionResultsQueryKey = (
|
||||
strategy: SelectionStrategy,
|
||||
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) =>
|
||||
["selection", "runs", runId] as const
|
||||
export const selectionRunQueryKey = (
|
||||
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(
|
||||
strategy: SelectionStrategy,
|
||||
targetTradeDate?: string,
|
||||
query: SelectionResultsQuery = { page: 1, pageSize: 10 },
|
||||
) {
|
||||
return useQuery({
|
||||
queryFn: ({ signal }) =>
|
||||
getSelectionResults(strategy, targetTradeDate, signal),
|
||||
queryKey: selectionResultsQueryKey(strategy, targetTradeDate),
|
||||
getSelectionResults(strategy, targetTradeDate, query, signal),
|
||||
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 query = useQuery({
|
||||
enabled: Boolean(runId),
|
||||
queryFn: ({ signal }) => getSelectionRun(runId ?? "", signal),
|
||||
queryKey: selectionRunQueryKey(runId ?? "none"),
|
||||
queryFn: ({ signal }) => getSelectionRun(runId ?? "", resultQuery, signal),
|
||||
queryKey: selectionRunQueryKey(runId ?? "none", resultQuery),
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.status === "running" ? 1500 : false,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
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 =
|
||||
"no_data" | "running" | "success" | "partial_success" | "failed"
|
||||
|
||||
@@ -50,6 +67,9 @@ export interface SelectionResults {
|
||||
error_message: string | null
|
||||
created_at: string | null
|
||||
finished_at: string | null
|
||||
page: number
|
||||
page_size: number
|
||||
signals_total: number
|
||||
failures: SelectionFailure[]
|
||||
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> = {
|
||||
zhixing_b1_extreme_volume: "超卖超缩量 B",
|
||||
@@ -10,7 +13,7 @@ export const categoryLabels: Record<string, string> = {
|
||||
zhixing_b1_pullback_yellow: "回踩黄线 B",
|
||||
}
|
||||
|
||||
export type SignalCategoryFilter = "all" | "pullback" | "oversold" | "original"
|
||||
export type SignalCategoryFilter = SelectionCategoryFilter
|
||||
|
||||
export const signalCategoryOptions: ReadonlyArray<{
|
||||
label: string
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "./selection-presentation"
|
||||
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 {
|
||||
result: SelectionResults
|
||||
@@ -26,14 +26,14 @@ export function SelectionResultsWorkbench({
|
||||
}: SelectionResultsWorkbenchProps) {
|
||||
const search = useSearch({ from: "/_workspace/selection" })
|
||||
const navigate = useNavigate({ from: "/selection" })
|
||||
const [query, setQuery] = useState("")
|
||||
const [category, setCategory] = useState<SignalCategoryFilter>("all")
|
||||
const query = search.search ?? ""
|
||||
const category = search.category ?? "all"
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null)
|
||||
const [expandedKeys, setExpandedKeys] = useState<ReadonlySet<string>>(
|
||||
new Set(),
|
||||
)
|
||||
|
||||
const filteredSignals = useMemo(() => {
|
||||
const visibleSignals = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase()
|
||||
return result.signals.filter((signal) => {
|
||||
const matchesQuery =
|
||||
@@ -46,12 +46,10 @@ export function SelectionResultsWorkbench({
|
||||
|
||||
const page = search.page
|
||||
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 currentSignals = filteredSignals.slice(
|
||||
(currentPage - 1) * pageSize,
|
||||
currentPage * pageSize,
|
||||
)
|
||||
const currentSignals = visibleSignals
|
||||
const selectedSignal =
|
||||
currentSignals.find((signal) => getSignalKey(signal) === selectedKey) ??
|
||||
currentSignals[0]
|
||||
@@ -64,18 +62,21 @@ export function SelectionResultsWorkbench({
|
||||
}
|
||||
}, [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 }) })
|
||||
}
|
||||
|
||||
function handleQueryChange(value: string) {
|
||||
setQuery(value)
|
||||
updateSearch({ page: 1 })
|
||||
updateSearch({ page: 1, search: value })
|
||||
}
|
||||
|
||||
function handleCategoryChange(value: SignalCategoryFilter) {
|
||||
setCategory(value)
|
||||
updateSearch({ page: 1 })
|
||||
updateSearch({ category: value, page: 1 })
|
||||
}
|
||||
|
||||
function handleToggleExpanded(signal: SelectionSignal) {
|
||||
@@ -115,7 +116,7 @@ export function SelectionResultsWorkbench({
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs tabular-nums text-muted-foreground sm:justify-self-end">
|
||||
筛选结果 {filteredSignals.length} 条
|
||||
筛选结果 {signalsTotal} 条
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -141,7 +142,7 @@ export function SelectionResultsWorkbench({
|
||||
page={currentPage}
|
||||
pageSize={pageSize}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
total={filteredSignals.length}
|
||||
total={signalsTotal}
|
||||
/>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock("@/features/selection/api/selection.query", () => ({
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
useNavigate: () => routerNavigate,
|
||||
useSearch: () => ({ page: 1, pageSize: 5 }),
|
||||
useSearch: () => ({ page: 1, pageSize: 5, search: "", category: "all" }),
|
||||
}))
|
||||
|
||||
const selectedResult: SelectionResults = {
|
||||
@@ -34,9 +34,12 @@ const selectedResult: SelectionResults = {
|
||||
failures: [],
|
||||
finished_at: "2026-08-08T09:02:00+08:00",
|
||||
market_sync_batch_id: "market-run-1",
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
run_id: "run-1",
|
||||
selected_stock_count: 1,
|
||||
signal_count: 2,
|
||||
signals_total: 2,
|
||||
signals: [
|
||||
{
|
||||
category: "zhixing_b1_original_b1",
|
||||
@@ -142,7 +145,12 @@ describe("SelectionResultsPage", () => {
|
||||
ts_code: `00000${index + 1}.SZ`,
|
||||
}))
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: { ...selectedResult, signal_count: signals.length, signals },
|
||||
data: {
|
||||
...selectedResult,
|
||||
signal_count: signals.length,
|
||||
signals,
|
||||
signals_total: signals.length,
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
@@ -262,7 +270,10 @@ describe("SelectionResultsPage", () => {
|
||||
|
||||
expect(screen.getByText("策略执行中")).toBeInTheDocument()
|
||||
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", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from "lucide-react"
|
||||
import { useSearch } from "@tanstack/react-router"
|
||||
import { useState } from "react"
|
||||
|
||||
import { PageLayout } from "@/app/layout/page-layout"
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
} from "@/features/selection/api/selection.query"
|
||||
import type {
|
||||
SelectionResults,
|
||||
SelectionResultsQuery,
|
||||
SelectionRunStatus,
|
||||
} from "@/features/selection/api/selection.types"
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
@@ -54,12 +56,23 @@ export function SelectionResultsPage() {
|
||||
const [targetTradeDate, setTargetTradeDate] = useState<string | null>(null)
|
||||
const [activeRunId, setActiveRunId] = useState<string | null>(null)
|
||||
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 =
|
||||
results.data?.status === "running" ? results.data.run_id : null
|
||||
const polledRunId = activeRunId ?? persistedRunningRunId
|
||||
const activeRun = useSelectionRun(polledRunId)
|
||||
const activeRun = useSelectionRun(polledRunId, resultQuery)
|
||||
const trigger = useTriggerSelectionRun()
|
||||
|
||||
const selectedTargetTradeDate =
|
||||
@@ -420,7 +433,9 @@ function ResultState({ result }: { result: SelectionResults }) {
|
||||
{result.signal_count === 0 ? (
|
||||
<NoSignalState />
|
||||
) : (
|
||||
<div className="min-h-[420px] shrink-0">
|
||||
<SelectionResultsWorkbench result={result} />
|
||||
</div>
|
||||
)}
|
||||
{result.failures.length > 0 ? <FailureList result={result} /> : null}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,10 @@ import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router"
|
||||
|
||||
import { AppLayout } from "@/app/layout/app-layout"
|
||||
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"
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
@@ -28,8 +32,17 @@ const selectionRoute = createRoute({
|
||||
const rawPageSize = Number(search.pageSize)
|
||||
const page =
|
||||
Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : 1
|
||||
const pageSize = [5, 10, 20].includes(rawPageSize) ? rawPageSize : 10
|
||||
return { page, pageSize }
|
||||
const pageSize = [5, 10, 15].includes(rawPageSize) ? rawPageSize : 10
|
||||
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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user