2026-08-09 09:34:46 +08:00
|
|
|
"""Application orchestration for persisted whole-universe B1 runs."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import logging
|
2026-08-12 09:45:16 +08:00
|
|
|
import time
|
|
|
|
|
from collections.abc import Callable, Sequence
|
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
2026-08-09 09:34:46 +08:00
|
|
|
from dataclasses import dataclass
|
|
|
|
|
from datetime import date
|
2026-08-12 09:45:16 +08:00
|
|
|
from typing import Literal, Protocol, cast
|
2026-08-09 09:34:46 +08:00
|
|
|
|
2026-08-12 09:45:16 +08:00
|
|
|
from ..domain.models import SelectionEvaluation, StockHistory
|
2026-08-31 16:14:16 +08:00
|
|
|
from ..domain.pattern_scoring import (
|
|
|
|
|
PatternCase,
|
|
|
|
|
PatternCaseLibraryLoader,
|
|
|
|
|
PatternScore,
|
|
|
|
|
PatternScorer,
|
|
|
|
|
)
|
2026-08-09 09:34:46 +08:00
|
|
|
from ..domain.runs import (
|
2026-08-12 09:45:16 +08:00
|
|
|
BatchSelectionRunStore,
|
|
|
|
|
BatchSelectionUniverseReader,
|
2026-08-09 09:34:46 +08:00
|
|
|
SelectionExecutionSource,
|
|
|
|
|
SelectionRerunRequired,
|
2026-08-10 11:09:23 +08:00
|
|
|
SelectionResultQuery,
|
2026-08-09 09:34:46 +08:00
|
|
|
SelectionRun,
|
|
|
|
|
SelectionRunInProgress,
|
|
|
|
|
SelectionRunItem,
|
|
|
|
|
SelectionRunStatus,
|
|
|
|
|
SelectionRunStore,
|
2026-08-12 09:45:16 +08:00
|
|
|
SelectionStock,
|
2026-08-09 09:34:46 +08:00
|
|
|
SelectionUniverseReader,
|
|
|
|
|
)
|
|
|
|
|
from .evaluate import EvaluateZhixingB1
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
StrategyName = Literal["zhixing_b1"]
|
|
|
|
|
_FAILURE_STATUSES = {"insufficient_history", "missing_target_bar", "data_error"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SelectionEvaluator(Protocol):
|
|
|
|
|
"""Minimal single-stock evaluator required by the batch orchestrator."""
|
|
|
|
|
|
|
|
|
|
def execute(self, ts_code: str, target_trade_date: date) -> SelectionEvaluation: ...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
|
|
|
class PreparedSelectionRun:
|
|
|
|
|
"""A claimed run and its immutable market-data source snapshot."""
|
|
|
|
|
|
|
|
|
|
run: SelectionRun
|
|
|
|
|
source: SelectionExecutionSource
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RunZhixingB1:
|
|
|
|
|
"""Prepare, execute, and query persisted Zhixing B1 result batches."""
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
reader: SelectionUniverseReader,
|
|
|
|
|
store: SelectionRunStore,
|
|
|
|
|
evaluator: SelectionEvaluator | None = None,
|
2026-08-31 16:14:16 +08:00
|
|
|
pattern_case_loader: PatternCaseLibraryLoader | None = None,
|
|
|
|
|
pattern_scorer: PatternScorer | None = None,
|
2026-08-12 09:45:16 +08:00
|
|
|
*,
|
2026-08-31 16:14:16 +08:00
|
|
|
pattern_scoring_enabled: bool = False,
|
2026-08-12 09:45:16 +08:00
|
|
|
max_workers: int = 4,
|
|
|
|
|
batch_size: int = 200,
|
2026-08-09 09:34:46 +08:00
|
|
|
) -> None:
|
2026-08-12 09:45:16 +08:00
|
|
|
"""Inject storage ports and configure bounded chunk execution."""
|
2026-08-09 09:34:46 +08:00
|
|
|
|
2026-08-12 09:45:16 +08:00
|
|
|
if max_workers < 1:
|
|
|
|
|
raise ValueError("max_workers must be at least 1")
|
|
|
|
|
if batch_size < 1:
|
|
|
|
|
raise ValueError("batch_size must be at least 1")
|
2026-08-09 09:34:46 +08:00
|
|
|
self.reader = reader
|
|
|
|
|
self.store = store
|
|
|
|
|
self.evaluator = evaluator or EvaluateZhixingB1(reader)
|
2026-08-31 16:14:16 +08:00
|
|
|
self.pattern_case_loader = pattern_case_loader
|
|
|
|
|
self.pattern_scorer = pattern_scorer
|
|
|
|
|
self.pattern_scoring_enabled = pattern_scoring_enabled
|
2026-08-12 09:45:16 +08:00
|
|
|
self.max_workers = max_workers
|
|
|
|
|
self.batch_size = batch_size
|
2026-08-09 09:34:46 +08:00
|
|
|
|
|
|
|
|
def prepare(
|
|
|
|
|
self,
|
|
|
|
|
strategy: StrategyName,
|
|
|
|
|
target_trade_date: date,
|
|
|
|
|
*,
|
|
|
|
|
rerun: bool,
|
|
|
|
|
) -> PreparedSelectionRun:
|
|
|
|
|
"""Validate source eligibility before claiming the rerunnable key."""
|
|
|
|
|
|
|
|
|
|
source = self.reader.load_execution_source(strategy, target_trade_date)
|
|
|
|
|
run = self.store.prepare_run(
|
|
|
|
|
strategy,
|
|
|
|
|
target_trade_date,
|
|
|
|
|
source,
|
|
|
|
|
rerun=rerun,
|
|
|
|
|
)
|
|
|
|
|
return PreparedSelectionRun(run=run, source=source)
|
|
|
|
|
|
|
|
|
|
def execute(self, prepared: PreparedSelectionRun) -> None:
|
|
|
|
|
"""Evaluate every eligible stock and converge the persisted run status.
|
|
|
|
|
|
|
|
|
|
This method is the boundary used by FastAPI's in-process background
|
|
|
|
|
task. An unexpected batch-level error is recorded before the worker
|
|
|
|
|
returns so the UI never mistakes a lost worker exception for success.
|
|
|
|
|
"""
|
|
|
|
|
|
2026-08-12 09:45:16 +08:00
|
|
|
stocks = _unique_stocks(prepared.source.stocks)
|
2026-08-09 09:34:46 +08:00
|
|
|
evaluated_count = 0
|
|
|
|
|
selected_stock_count = 0
|
|
|
|
|
signal_count = 0
|
|
|
|
|
failed_count = 0
|
2026-08-12 09:45:16 +08:00
|
|
|
history_rows = 0
|
|
|
|
|
batch_count = _chunk_count(len(stocks), self.batch_size)
|
|
|
|
|
read_seconds = 0.0
|
|
|
|
|
evaluate_seconds = 0.0
|
|
|
|
|
persist_seconds = 0.0
|
2026-08-31 16:14:16 +08:00
|
|
|
scoring_seconds = 0.0
|
2026-08-09 09:34:46 +08:00
|
|
|
try:
|
2026-08-31 16:14:16 +08:00
|
|
|
pattern_cases, pattern_library_error = self._prepare_pattern_cases(prepared.run.id)
|
2026-08-12 09:45:16 +08:00
|
|
|
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
|
|
|
|
for batch_stocks in _chunks(stocks, self.batch_size):
|
|
|
|
|
read_started = time.perf_counter()
|
|
|
|
|
histories = self._load_histories(
|
|
|
|
|
batch_stocks,
|
2026-08-09 09:34:46 +08:00
|
|
|
prepared.source.target_trade_date,
|
|
|
|
|
)
|
2026-08-12 09:45:16 +08:00
|
|
|
read_seconds += time.perf_counter() - read_started
|
|
|
|
|
history_rows += sum(
|
|
|
|
|
len(history.bars) for history in histories if history is not None
|
2026-08-09 09:34:46 +08:00
|
|
|
)
|
2026-08-12 09:45:16 +08:00
|
|
|
|
|
|
|
|
evaluate_started = time.perf_counter()
|
2026-08-31 16:14:16 +08:00
|
|
|
evaluations = tuple(
|
|
|
|
|
executor.map(
|
|
|
|
|
self._evaluate_stock,
|
|
|
|
|
batch_stocks,
|
|
|
|
|
histories,
|
|
|
|
|
[prepared.source.target_trade_date] * len(batch_stocks),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
evaluate_seconds += time.perf_counter() - evaluate_started
|
|
|
|
|
|
|
|
|
|
scoring_started = time.perf_counter()
|
2026-08-12 09:45:16 +08:00
|
|
|
items = tuple(
|
|
|
|
|
_to_item(
|
|
|
|
|
stock.ts_code,
|
|
|
|
|
stock.name,
|
|
|
|
|
evaluation,
|
2026-08-31 16:14:16 +08:00
|
|
|
pattern_score=self._score_stock(
|
|
|
|
|
prepared.run.id,
|
|
|
|
|
stock,
|
|
|
|
|
history,
|
|
|
|
|
evaluation,
|
|
|
|
|
pattern_cases,
|
|
|
|
|
pattern_library_error,
|
|
|
|
|
),
|
2026-08-12 09:45:16 +08:00
|
|
|
)
|
2026-08-31 16:14:16 +08:00
|
|
|
for stock, history, evaluation in zip(
|
2026-08-12 09:45:16 +08:00
|
|
|
batch_stocks,
|
2026-08-31 16:14:16 +08:00
|
|
|
histories,
|
|
|
|
|
evaluations,
|
2026-08-12 09:45:16 +08:00
|
|
|
strict=True,
|
|
|
|
|
)
|
2026-08-09 09:34:46 +08:00
|
|
|
)
|
2026-08-31 16:14:16 +08:00
|
|
|
scoring_seconds += time.perf_counter() - scoring_started
|
2026-08-12 09:45:16 +08:00
|
|
|
|
|
|
|
|
evaluated_count += len(items)
|
|
|
|
|
selected_stock_count += sum(item.status == "selected" for item in items)
|
|
|
|
|
signal_count += sum(item.signal_count for item in items)
|
|
|
|
|
failed_count += sum(item.status in _FAILURE_STATUSES for item in items)
|
|
|
|
|
|
|
|
|
|
persist_started = time.perf_counter()
|
|
|
|
|
self._record_items(prepared.run.id, items)
|
|
|
|
|
persist_seconds += time.perf_counter() - persist_started
|
2026-08-09 09:34:46 +08:00
|
|
|
|
|
|
|
|
status = _run_status(evaluated_count, failed_count)
|
|
|
|
|
self.store.finish_run(
|
|
|
|
|
prepared.run.id,
|
|
|
|
|
status,
|
|
|
|
|
evaluated_count=evaluated_count,
|
|
|
|
|
selected_stock_count=selected_stock_count,
|
|
|
|
|
signal_count=signal_count,
|
|
|
|
|
failed_count=failed_count,
|
|
|
|
|
)
|
|
|
|
|
except Exception as exc: # noqa: BLE001 - worker boundary must persist failure state
|
2026-08-12 09:45:16 +08:00
|
|
|
logger.error(
|
|
|
|
|
"selection_run_failed run_id=%s error_type=%s reason=%s",
|
|
|
|
|
prepared.run.id,
|
|
|
|
|
exc.__class__.__name__,
|
|
|
|
|
_safe_item_error(exc),
|
|
|
|
|
)
|
2026-08-09 09:34:46 +08:00
|
|
|
try:
|
|
|
|
|
self.store.finish_run(
|
|
|
|
|
prepared.run.id,
|
|
|
|
|
"failed",
|
|
|
|
|
evaluated_count=evaluated_count,
|
|
|
|
|
selected_stock_count=selected_stock_count,
|
|
|
|
|
signal_count=signal_count,
|
|
|
|
|
failed_count=max(failed_count, 1),
|
|
|
|
|
error_type="batch_error",
|
|
|
|
|
error_message=str(exc),
|
|
|
|
|
)
|
|
|
|
|
except Exception: # noqa: BLE001 - preserve the original worker failure
|
2026-08-12 09:45:16 +08:00
|
|
|
logger.error(
|
|
|
|
|
"selection_run_failure_persist_failed run_id=%s",
|
|
|
|
|
prepared.run.id,
|
|
|
|
|
)
|
|
|
|
|
finally:
|
|
|
|
|
logger.info(
|
|
|
|
|
"selection_run_summary run_id=%s stock_count=%d history_rows=%d "
|
|
|
|
|
"batch_count=%d worker_count=%d read_seconds=%.3f "
|
2026-08-31 16:14:16 +08:00
|
|
|
"evaluate_seconds=%.3f scoring_seconds=%.3f persist_seconds=%.3f",
|
2026-08-12 09:45:16 +08:00
|
|
|
prepared.run.id,
|
|
|
|
|
len(stocks),
|
|
|
|
|
history_rows,
|
|
|
|
|
batch_count,
|
|
|
|
|
self.max_workers,
|
|
|
|
|
read_seconds,
|
|
|
|
|
evaluate_seconds,
|
2026-08-31 16:14:16 +08:00
|
|
|
scoring_seconds,
|
2026-08-12 09:45:16 +08:00
|
|
|
persist_seconds,
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-31 16:14:16 +08:00
|
|
|
def _prepare_pattern_cases(
|
|
|
|
|
self,
|
|
|
|
|
run_id: str,
|
|
|
|
|
) -> tuple[tuple[PatternCase, ...] | None, str | None]:
|
|
|
|
|
"""Load the complete case library once without failing selection."""
|
|
|
|
|
|
|
|
|
|
if not self.pattern_scoring_enabled:
|
|
|
|
|
return None, None
|
|
|
|
|
if self.pattern_case_loader is None or self.pattern_scorer is None:
|
|
|
|
|
reason = "pattern scoring is enabled but not configured"
|
|
|
|
|
logger.error("selection_pattern_library_failed run_id=%s reason=%s", run_id, reason)
|
|
|
|
|
return None, reason
|
|
|
|
|
try:
|
|
|
|
|
return self.pattern_case_loader.load(), None
|
|
|
|
|
except Exception as exc: # noqa: BLE001 - scoring enrichment must not fail selection
|
|
|
|
|
reason = _safe_item_error(exc)
|
|
|
|
|
logger.warning(
|
|
|
|
|
"selection_pattern_library_failed run_id=%s error_type=%s reason=%s",
|
|
|
|
|
run_id,
|
|
|
|
|
exc.__class__.__name__,
|
|
|
|
|
reason,
|
|
|
|
|
)
|
|
|
|
|
return None, reason
|
|
|
|
|
|
|
|
|
|
def _score_stock(
|
|
|
|
|
self,
|
|
|
|
|
run_id: str,
|
|
|
|
|
stock: SelectionStock,
|
|
|
|
|
history: StockHistory | None,
|
|
|
|
|
evaluation: SelectionEvaluation,
|
|
|
|
|
cases: tuple[PatternCase, ...] | None,
|
|
|
|
|
library_error: str | None,
|
|
|
|
|
) -> PatternScore:
|
|
|
|
|
"""Score one selected stock once and isolate enrichment failures."""
|
|
|
|
|
|
|
|
|
|
if not self.pattern_scoring_enabled or evaluation.status != "selected":
|
|
|
|
|
return PatternScore()
|
|
|
|
|
if library_error is not None:
|
|
|
|
|
return PatternScore.failed(library_error)
|
|
|
|
|
if history is None or cases is None or self.pattern_scorer is None:
|
|
|
|
|
return PatternScore.failed("pattern scoring history or case library is unavailable")
|
|
|
|
|
try:
|
|
|
|
|
return self.pattern_scorer.score(history, cases)
|
|
|
|
|
except Exception as exc: # noqa: BLE001 - one score must not fail the selection run
|
|
|
|
|
reason = _safe_item_error(exc)
|
|
|
|
|
logger.warning(
|
|
|
|
|
"selection_pattern_score_failed run_id=%s ts_code=%s error_type=%s reason=%s",
|
|
|
|
|
run_id,
|
|
|
|
|
stock.ts_code,
|
|
|
|
|
exc.__class__.__name__,
|
|
|
|
|
reason,
|
|
|
|
|
)
|
|
|
|
|
return PatternScore.failed(reason)
|
|
|
|
|
|
2026-08-12 09:45:16 +08:00
|
|
|
def _load_histories(
|
|
|
|
|
self,
|
|
|
|
|
stocks: Sequence[SelectionStock],
|
|
|
|
|
target_trade_date: date,
|
|
|
|
|
) -> tuple[StockHistory | None, ...]:
|
|
|
|
|
"""Load one chunk when the reader supports it, with old-path fallback."""
|
|
|
|
|
|
|
|
|
|
typed_stocks = tuple(stocks)
|
|
|
|
|
loader = getattr(self.reader, "load_histories", None)
|
|
|
|
|
if callable(loader):
|
|
|
|
|
batch_reader = cast(BatchSelectionUniverseReader, self.reader)
|
|
|
|
|
loaded = batch_reader.load_histories(typed_stocks, target_trade_date)
|
|
|
|
|
histories_by_code = {history.ts_code: history for history in loaded}
|
|
|
|
|
return tuple(
|
|
|
|
|
histories_by_code.get(
|
|
|
|
|
stock.ts_code,
|
|
|
|
|
StockHistory(ts_code=stock.ts_code, name=stock.name),
|
|
|
|
|
)
|
|
|
|
|
for stock in typed_stocks
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if isinstance(self.evaluator, EvaluateZhixingB1):
|
|
|
|
|
return tuple(
|
|
|
|
|
self.reader.load_history(stock.ts_code, target_trade_date) for stock in typed_stocks
|
|
|
|
|
)
|
|
|
|
|
return (None,) * len(typed_stocks)
|
|
|
|
|
|
|
|
|
|
def _evaluate_stock(
|
|
|
|
|
self,
|
|
|
|
|
stock: SelectionStock,
|
|
|
|
|
history: StockHistory | None,
|
|
|
|
|
target_trade_date: date,
|
|
|
|
|
) -> SelectionEvaluation:
|
|
|
|
|
"""Evaluate one stock inside a worker and isolate its exception."""
|
|
|
|
|
|
|
|
|
|
ts_code = stock.ts_code
|
|
|
|
|
try:
|
|
|
|
|
execute_history: Callable[[StockHistory, date], SelectionEvaluation] | None = getattr(
|
|
|
|
|
self.evaluator,
|
|
|
|
|
"execute_history",
|
|
|
|
|
None,
|
|
|
|
|
)
|
|
|
|
|
if history is not None and execute_history is not None:
|
|
|
|
|
return execute_history(history, target_trade_date)
|
|
|
|
|
return self.evaluator.execute(ts_code, target_trade_date)
|
|
|
|
|
except Exception as exc: # noqa: BLE001 - isolate one stock from the batch
|
|
|
|
|
logger.warning(
|
|
|
|
|
"selection_item_failed ts_code=%s error_type=%s reason=%s",
|
|
|
|
|
ts_code,
|
|
|
|
|
exc.__class__.__name__,
|
|
|
|
|
_safe_item_error(exc),
|
|
|
|
|
)
|
|
|
|
|
return SelectionEvaluation(
|
|
|
|
|
ts_code=ts_code,
|
|
|
|
|
target_trade_date=target_trade_date,
|
|
|
|
|
status="data_error",
|
|
|
|
|
reason=_safe_item_error(exc),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _record_items(self, run_id: str, items: Sequence[SelectionRunItem]) -> None:
|
|
|
|
|
"""Use batch persistence while retaining the old single-item seam."""
|
|
|
|
|
|
|
|
|
|
record_items = getattr(self.store, "record_items", None)
|
|
|
|
|
if callable(record_items):
|
|
|
|
|
batch_store = cast(BatchSelectionRunStore, self.store)
|
|
|
|
|
batch_store.record_items(run_id, tuple(items))
|
|
|
|
|
return
|
|
|
|
|
for item in items:
|
|
|
|
|
self.store.record_item(run_id, item)
|
2026-08-09 09:34:46 +08:00
|
|
|
|
2026-08-10 11:09:23 +08:00
|
|
|
def get_run(
|
|
|
|
|
self,
|
|
|
|
|
run_id: str,
|
|
|
|
|
*,
|
|
|
|
|
query: SelectionResultQuery | None = None,
|
|
|
|
|
) -> SelectionRun | None:
|
2026-08-09 09:34:46 +08:00
|
|
|
"""Read one persisted run for polling."""
|
|
|
|
|
|
2026-08-10 11:09:23 +08:00
|
|
|
return self.store.get_run(run_id, query=query)
|
2026-08-09 09:34:46 +08:00
|
|
|
|
|
|
|
|
def get_latest(
|
|
|
|
|
self,
|
|
|
|
|
strategy: StrategyName,
|
|
|
|
|
target_trade_date: date | None = None,
|
2026-08-10 11:09:23 +08:00
|
|
|
*,
|
|
|
|
|
query: SelectionResultQuery | None = None,
|
2026-08-09 09:34:46 +08:00
|
|
|
) -> SelectionRun | None:
|
|
|
|
|
"""Read the current result by date or the latest result for a strategy."""
|
|
|
|
|
|
2026-08-10 11:09:23 +08:00
|
|
|
return self.store.get_latest_run(strategy, target_trade_date, query=query)
|
2026-08-09 09:34:46 +08:00
|
|
|
|
|
|
|
|
|
2026-08-31 16:14:16 +08:00
|
|
|
def _to_item(
|
|
|
|
|
ts_code: str,
|
|
|
|
|
name: str,
|
|
|
|
|
evaluation: SelectionEvaluation,
|
|
|
|
|
*,
|
|
|
|
|
pattern_score: PatternScore | None = None,
|
|
|
|
|
) -> SelectionRunItem:
|
2026-08-09 09:34:46 +08:00
|
|
|
"""Translate a single-stock domain result into a stored item."""
|
|
|
|
|
|
|
|
|
|
return SelectionRunItem(
|
|
|
|
|
ts_code=ts_code,
|
|
|
|
|
name=name or (evaluation.signals[0].name if evaluation.signals else ""),
|
|
|
|
|
status=evaluation.status,
|
|
|
|
|
signal_count=len(evaluation.signals),
|
|
|
|
|
reason=evaluation.reason,
|
2026-08-31 16:14:16 +08:00
|
|
|
pattern_score=pattern_score or PatternScore(),
|
2026-08-09 09:34:46 +08:00
|
|
|
signals=evaluation.signals,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _run_status(evaluated_count: int, failed_count: int) -> SelectionRunStatus:
|
|
|
|
|
"""Map per-stock outcomes into a visible batch status."""
|
|
|
|
|
|
|
|
|
|
if failed_count == 0:
|
|
|
|
|
return "success"
|
|
|
|
|
if evaluated_count == 0 or failed_count >= evaluated_count:
|
|
|
|
|
return "failed"
|
|
|
|
|
return "partial_success"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _safe_item_error(error: Exception) -> str:
|
|
|
|
|
"""Keep per-stock failure context readable without persisting tracebacks."""
|
|
|
|
|
|
|
|
|
|
return " ".join(str(error).split())[:500] or error.__class__.__name__
|
|
|
|
|
|
|
|
|
|
|
2026-08-12 09:45:16 +08:00
|
|
|
def _chunks(
|
|
|
|
|
values: Sequence[SelectionStock],
|
|
|
|
|
size: int,
|
|
|
|
|
) -> tuple[tuple[SelectionStock, ...], ...]:
|
|
|
|
|
"""Split a stable stock sequence into bounded immutable chunks."""
|
|
|
|
|
|
|
|
|
|
return tuple(tuple(values[index : index + size]) for index in range(0, len(values), size))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _chunk_count(value_count: int, size: int) -> int:
|
|
|
|
|
"""Return the number of chunks without materializing empty chunks."""
|
|
|
|
|
|
|
|
|
|
return (value_count + size - 1) // size
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _unique_stocks(stocks: Sequence[SelectionStock]) -> tuple[SelectionStock, ...]:
|
|
|
|
|
"""Keep the first source row for each stock so it is evaluated once."""
|
|
|
|
|
|
|
|
|
|
seen: set[str] = set()
|
|
|
|
|
unique: list[SelectionStock] = []
|
|
|
|
|
for stock in stocks:
|
|
|
|
|
ts_code = stock.ts_code
|
|
|
|
|
if ts_code in seen:
|
|
|
|
|
continue
|
|
|
|
|
seen.add(ts_code)
|
|
|
|
|
unique.append(stock)
|
|
|
|
|
return tuple(unique)
|
|
|
|
|
|
|
|
|
|
|
2026-08-09 09:34:46 +08:00
|
|
|
__all__ = [
|
|
|
|
|
"PreparedSelectionRun",
|
|
|
|
|
"RunZhixingB1",
|
|
|
|
|
"SelectionRerunRequired",
|
|
|
|
|
"SelectionRunInProgress",
|
|
|
|
|
]
|