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:
+83
-8
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user