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:
@@ -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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user