feat(selection): 集成 B1 FastDTW 图形评分

This commit is contained in:
yuxuanhui
2026-08-31 16:14:16 +08:00
parent 86762c0d9a
commit 6ce291e242
51 changed files with 2917 additions and 41 deletions
@@ -11,6 +11,12 @@ from datetime import date
from typing import Literal, Protocol, cast
from ..domain.models import SelectionEvaluation, StockHistory
from ..domain.pattern_scoring import (
PatternCase,
PatternCaseLibraryLoader,
PatternScore,
PatternScorer,
)
from ..domain.runs import (
BatchSelectionRunStore,
BatchSelectionUniverseReader,
@@ -54,7 +60,10 @@ class RunZhixingB1:
reader: SelectionUniverseReader,
store: SelectionRunStore,
evaluator: SelectionEvaluator | None = None,
pattern_case_loader: PatternCaseLibraryLoader | None = None,
pattern_scorer: PatternScorer | None = None,
*,
pattern_scoring_enabled: bool = False,
max_workers: int = 4,
batch_size: int = 200,
) -> None:
@@ -67,6 +76,9 @@ class RunZhixingB1:
self.reader = reader
self.store = store
self.evaluator = evaluator or EvaluateZhixingB1(reader)
self.pattern_case_loader = pattern_case_loader
self.pattern_scorer = pattern_scorer
self.pattern_scoring_enabled = pattern_scoring_enabled
self.max_workers = max_workers
self.batch_size = batch_size
@@ -106,7 +118,9 @@ class RunZhixingB1:
read_seconds = 0.0
evaluate_seconds = 0.0
persist_seconds = 0.0
scoring_seconds = 0.0
try:
pattern_cases, pattern_library_error = self._prepare_pattern_cases(prepared.run.id)
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
for batch_stocks in _chunks(stocks, self.batch_size):
read_started = time.perf_counter()
@@ -120,24 +134,39 @@ class RunZhixingB1:
)
evaluate_started = time.perf_counter()
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()
items = tuple(
_to_item(
stock.ts_code,
stock.name,
evaluation,
)
for stock, evaluation in zip(
batch_stocks,
executor.map(
self._evaluate_stock,
batch_stocks,
histories,
[prepared.source.target_trade_date] * len(batch_stocks),
pattern_score=self._score_stock(
prepared.run.id,
stock,
history,
evaluation,
pattern_cases,
pattern_library_error,
),
)
for stock, history, evaluation in zip(
batch_stocks,
histories,
evaluations,
strict=True,
)
)
evaluate_seconds += time.perf_counter() - evaluate_started
scoring_seconds += time.perf_counter() - scoring_started
evaluated_count += len(items)
selected_stock_count += sum(item.status == "selected" for item in items)
@@ -184,7 +213,7 @@ class RunZhixingB1:
logger.info(
"selection_run_summary run_id=%s stock_count=%d history_rows=%d "
"batch_count=%d worker_count=%d read_seconds=%.3f "
"evaluate_seconds=%.3f persist_seconds=%.3f",
"evaluate_seconds=%.3f scoring_seconds=%.3f persist_seconds=%.3f",
prepared.run.id,
len(stocks),
history_rows,
@@ -192,9 +221,64 @@ class RunZhixingB1:
self.max_workers,
read_seconds,
evaluate_seconds,
scoring_seconds,
persist_seconds,
)
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)
def _load_histories(
self,
stocks: Sequence[SelectionStock],
@@ -287,7 +371,13 @@ class RunZhixingB1:
return self.store.get_latest_run(strategy, target_trade_date, query=query)
def _to_item(ts_code: str, name: str, evaluation: SelectionEvaluation) -> SelectionRunItem:
def _to_item(
ts_code: str,
name: str,
evaluation: SelectionEvaluation,
*,
pattern_score: PatternScore | None = None,
) -> SelectionRunItem:
"""Translate a single-stock domain result into a stored item."""
return SelectionRunItem(
@@ -296,6 +386,7 @@ def _to_item(ts_code: str, name: str, evaluation: SelectionEvaluation) -> Select
status=evaluation.status,
signal_count=len(evaluation.signals),
reason=evaluation.reason,
pattern_score=pattern_score or PatternScore(),
signals=evaluation.signals,
)