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
@@ -7,7 +7,7 @@ import time
from collections import Counter
from collections.abc import Callable, Mapping, Sequence
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from dataclasses import dataclass, replace
from datetime import date
from typing import Protocol, cast
@@ -29,6 +29,9 @@ from ..domain.runs import (
SelectionRunItem,
SelectionRunStatus,
SelectionRunStore,
SelectionSectorCount,
SelectionSectorMembership,
SelectionSectorReader,
SelectionStock,
SelectionUniverseReader,
)
@@ -60,6 +63,16 @@ class PreparedSelectionRun:
source: SelectionExecutionSource
@dataclass(frozen=True, slots=True)
class SelectionSectorAggregates:
"""A run summary plus its selected stocks' sector membership counts."""
run: SelectionRun | None
snapshot_trade_date: date | None
sector_type: str
sectors: tuple[SelectionSectorCount, ...]
class RunZhixingB1:
"""Prepare, execute, and query persisted selection strategy batches.
@@ -75,6 +88,7 @@ class RunZhixingB1:
evaluators: Mapping[StrategyName, SelectionEvaluator] | None = None,
pattern_case_loader: PatternCaseLibraryLoader | None = None,
pattern_scorer: PatternScorer | None = None,
sector_reader: SelectionSectorReader | None = None,
*,
pattern_scoring_enabled: bool = False,
max_workers: int = 4,
@@ -96,6 +110,7 @@ class RunZhixingB1:
self.evaluators.update(evaluators)
self.pattern_case_loader = pattern_case_loader
self.pattern_scorer = pattern_scorer
self.sector_reader = sector_reader
self.pattern_scoring_enabled = pattern_scoring_enabled
self.max_workers = max_workers
self.batch_size = batch_size
@@ -211,8 +226,7 @@ class RunZhixingB1:
)
history_rows += batch_history_rows
batch_missing_turnover = sum(
not _turnover_present(history, target_trade_date)
for history in histories
not _turnover_present(history, target_trade_date) for history in histories
)
logger.info(
"selection_read_batch_summary strategy=%s target_trade_date=%s "
@@ -581,9 +595,22 @@ class RunZhixingB1:
*,
query: SelectionResultQuery | None = None,
) -> SelectionRun | None:
"""Read one persisted run for polling."""
"""Read one persisted run for polling with optional sector filtering."""
return self.store.get_run(run_id, query=query)
effective = query or SelectionResultQuery()
if not effective.sector:
return self.store.get_run(run_id, query=query)
identity = self.store.get_run_identity(run_id)
if identity is None:
return None
member_codes = self._sector_member_codes(identity.target_trade_date, effective.sector)
if member_codes is None:
return self.store.get_run(run_id, query=query)
return self.store.get_run(
run_id,
query=replace(effective, sector=None),
sector_stock_codes=member_codes,
)
def get_latest(
self,
@@ -594,7 +621,73 @@ class RunZhixingB1:
) -> 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, query=query)
effective = query or SelectionResultQuery()
if not effective.sector:
return self.store.get_latest_run(strategy, target_trade_date, query=query)
identity = self.store.get_latest_run_identity(strategy, target_trade_date)
if identity is None:
return None
member_codes = self._sector_member_codes(identity.target_trade_date, effective.sector)
if member_codes is None:
return self.store.get_latest_run(strategy, target_trade_date, query=query)
return self.store.get_latest_run(
strategy,
identity.target_trade_date,
query=replace(effective, sector=None),
sector_stock_codes=member_codes,
)
def _sector_member_codes(
self,
target_trade_date: date,
sector_code: str,
) -> tuple[str, ...] | None:
"""Resolve one sector's members, or None when the port is absent."""
if self.sector_reader is None:
return None
return self.sector_reader.sector_member_codes(target_trade_date, sector_code)
def list_sector_counts(
self,
strategy: StrategyName,
target_trade_date: date | None = None,
*,
sector_type: str = "concept",
) -> SelectionSectorAggregates | None:
"""Aggregate the current run's selected stocks by point-in-time sector."""
run = self.store.get_latest_run(strategy, target_trade_date)
if run is None:
return None
selected_codes = [
item.ts_code
for item in run.items
if item.status == "selected" and item.signal_count > 0
]
membership = self._sector_membership(selected_codes, run.target_trade_date, sector_type)
return SelectionSectorAggregates(
run=run,
snapshot_trade_date=membership.snapshot_trade_date,
sector_type=sector_type,
sectors=membership.sector_counts,
)
def _sector_membership(
self,
stock_codes: Sequence[str],
target_trade_date: date,
sector_type: str,
) -> SelectionSectorMembership:
"""Read sector counts for a stock set, tolerating a missing port."""
if self.sector_reader is None or not stock_codes:
return SelectionSectorMembership(snapshot_trade_date=None, sector_counts=())
return self.sector_reader.sector_counts(
stock_codes,
target_trade_date,
sector_type=sector_type,
)
def _to_item(
@@ -676,4 +769,5 @@ __all__ = [
"RunZhixingB1",
"SelectionRerunRequired",
"SelectionRunInProgress",
"SelectionSectorAggregates",
]