feat(selection): expose run sector aggregates and sector filter on results API

- sector_radar: add batch sector-count aggregation and sector member lookup
  over the strict last-good membership snapshot (postgres + in-memory fakes)
- selection: add SelectionSectorReader port, list_sector_counts use case,
  and sector_stock_codes filtering via run identity resolution; queries stay
  inside the selection context per ADR 0001
- http: add GET /api/v1/selection/sectors and forward sector param on
  /results and /runs/{run_id}
- fix stale positional args in pattern-scoring run tests; cover new behavior
  with read-service, application, and HTTP contract tests
This commit is contained in:
yuxuanhui
2026-09-05 19:48:30 +08:00
parent 41a9b4eb9a
commit 7e0f13d678
13 changed files with 1135 additions and 36 deletions
@@ -33,6 +33,7 @@ from ..domain.runs import (
SelectionResultQuery,
SelectionRun,
SelectionRunError,
SelectionRunIdentity,
SelectionRunInProgress,
SelectionRunItem,
SelectionRunStatus,
@@ -46,9 +47,7 @@ _SELECTION_SIGNAL_ORDER: tuple[SelectionSignalCategory, ...] = (
*ZHIXING_B1_SIGNAL_ORDER,
*GOLD_BRICK_SIGNAL_ORDER,
)
_SIGNAL_PRIORITY = {
category: index for index, category in enumerate(_SELECTION_SIGNAL_ORDER)
}
_SIGNAL_PRIORITY = {category: index for index, category in enumerate(_SELECTION_SIGNAL_ORDER)}
_CATEGORY_PREFIXES = {
"pullback": "zhixing_b1_pullback_",
"oversold": "zhixing_b1_oversold_",
@@ -335,21 +334,41 @@ class PostgresSelectionRunRepository(SelectionRunStore):
run_id: str,
*,
query: SelectionResultQuery | None = None,
sector_stock_codes: Sequence[str] | None = None,
) -> SelectionRun | None:
"""Read one run with filtered, stock-paged signals and item failures."""
try:
with self._connection() as connection:
return self._load_run(connection, run_id, query or SelectionResultQuery())
return self._load_run(
connection,
run_id,
query or SelectionResultQuery(),
sector_stock_codes=sector_stock_codes,
)
except psycopg.Error as exc:
raise SelectionRunStoreError(f"failed to load selection run {run_id}") from exc
def get_run_identity(self, run_id: str) -> SelectionRunIdentity | None:
"""Read only a run's locator so date-dependent filters resolve first."""
try:
with self._connection() as connection:
row = connection.execute(
"SELECT id, target_trade_date FROM selection_run WHERE id = %s",
(run_id,),
).fetchone()
except psycopg.Error as exc:
raise SelectionRunStoreError(f"failed to load selection run {run_id}") from exc
return SelectionRunIdentity(run_id=str(row[0]), target_trade_date=row[1]) if row else None
def get_latest_run(
self,
strategy: SelectionStrategyName,
target_trade_date: date | None = None,
*,
query: SelectionResultQuery | None = None,
sector_stock_codes: Sequence[str] | None = None,
) -> SelectionRun | None:
"""Read the current run for a date or the latest date for a strategy."""
@@ -377,18 +396,59 @@ class PostgresSelectionRunRepository(SelectionRunStore):
(strategy, target_trade_date),
).fetchone()
return (
self._load_run(connection, str(row[0]), query or SelectionResultQuery())
self._load_run(
connection,
str(row[0]),
query or SelectionResultQuery(),
sector_stock_codes=sector_stock_codes,
)
if row
else None
)
except psycopg.Error as exc:
raise SelectionRunStoreError("failed to load latest selection run") from exc
def get_latest_run_identity(
self,
strategy: SelectionStrategyName,
target_trade_date: date | None = None,
) -> SelectionRunIdentity | None:
"""Read only the current run's locator so sector filters resolve first."""
try:
with self._connection() as connection:
if target_trade_date is None:
row = connection.execute(
"""
SELECT id, target_trade_date
FROM selection_run
WHERE strategy = %s
ORDER BY target_trade_date DESC, created_at DESC, id DESC
LIMIT 1
""",
(strategy,),
).fetchone()
else:
row = connection.execute(
"""
SELECT id, target_trade_date
FROM selection_run
WHERE strategy = %s AND target_trade_date = %s
LIMIT 1
""",
(strategy, target_trade_date),
).fetchone()
except psycopg.Error as exc:
raise SelectionRunStoreError("failed to load latest selection run") from exc
return SelectionRunIdentity(run_id=str(row[0]), target_trade_date=row[1]) if row else None
@staticmethod
def _load_run(
connection: Any,
run_id: str,
query: SelectionResultQuery,
*,
sector_stock_codes: Sequence[str] | None = None,
) -> SelectionRun | None:
row = connection.execute(
"""
@@ -417,7 +477,9 @@ class PostgresSelectionRunRepository(SelectionRunStore):
""",
(run_id,),
).fetchall()
stock_filter, stock_parameters = _stock_filter(query, run_id)
stock_filter, stock_parameters = _stock_filter(
query, run_id, sector_stock_codes=sector_stock_codes
)
stock_total_row = connection.execute(
f"SELECT COUNT(*) FROM selection_run_item AS item WHERE {stock_filter}",
tuple(stock_parameters),
@@ -568,12 +630,19 @@ def _signal_category(value: str) -> SelectionSignalCategory:
return GoldBrickCategory(value)
def _stock_filter(query: SelectionResultQuery, run_id: str) -> tuple[str, list[object]]:
def _stock_filter(
query: SelectionResultQuery,
run_id: str,
*,
sector_stock_codes: Sequence[str] | None = None,
) -> tuple[str, list[object]]:
"""Build the signal predicate used to select distinct matching stocks.
A category narrows which stocks qualify for the page. Once a stock
qualifies, the repository loads every signal for that stock so callers
can present all independently persisted categories together.
can present all independently persisted categories together. Resolved
sector membership codes arrive from the sector-radar port, so the SQL
stays inside the selection context.
"""
clauses = ["item.run_id = %s", "item.status = 'selected'", "item.signal_count > 0"]
@@ -592,6 +661,12 @@ def _stock_filter(query: SelectionResultQuery, run_id: str) -> tuple[str, list[o
")"
)
parameters.append(f"{_CATEGORY_PREFIXES[query.category]}%")
if sector_stock_codes is not None:
if not sector_stock_codes:
clauses.append("FALSE")
else:
clauses.append("item.ts_code = ANY(%s)")
parameters.append(list(sector_stock_codes))
return " AND ".join(clauses), parameters