Develop #7

Merged
sakibcc merged 3 commits from develop into main 2026-08-10 11:12:11 +08:00
16 changed files with 431 additions and 63 deletions
Showing only changes of commit 3af97bf65d - Show all commits
@@ -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: ...
@@ -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."""
+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 (
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}`, {
signal,
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 />
) : (
<SelectionResultsWorkbench result={result} />
<div className="min-h-[420px] shrink-0">
<SelectionResultsWorkbench result={result} />
</div>
)}
{result.failures.length > 0 ? <FailureList result={result} /> : null}
</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 { 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,
})