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
@@ -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."""