feat(selection): implement gold brick resonance strategy with evaluation and logging enhancements

This commit is contained in:
yuxuanhui
2026-09-05 10:22:08 +08:00
parent 9163590070
commit 79476252b1
20 changed files with 1060 additions and 97 deletions
@@ -0,0 +1,5 @@
{"file":".trellis/spec/backend/selection.md","reason":"复核策略语义、目标日边界、批量读取和持久化契约"}
{"file":".trellis/spec/backend/market-data-sync.md","reason":"复核数据来源、qfq 口径和目标日 daily_basic 完整性"}
{"file":".trellis/spec/backend/tushare-listed-stock-universe.md","reason":"复核沪深非 ST 股票范围未扩大"}
{"file":".trellis/spec/backend/error-handling.md","reason":"复核日志可诊断且不泄露敏感配置"}
{"file":".trellis/spec/frontend/type-safety.md","reason":"复核前后端策略标识和展示类型一致"}
@@ -0,0 +1,6 @@
{"file":".trellis/spec/backend/selection.md","reason":"历史选股策略、qfq 读取、运行持久化与策略扩展约束"}
{"file":".trellis/spec/backend/market-data-sync.md","reason":"目标日行情与 daily_basic 数据可用性契约"}
{"file":".trellis/spec/backend/tushare-listed-stock-universe.md","reason":"当前上市沪深非 ST 股票范围约束"}
{"file":".trellis/spec/backend/error-handling.md","reason":"可复制诊断日志与错误边界约束"}
{"file":".trellis/spec/backend/directory-structure.md","reason":"selection bounded context 分层与导入方向"}
{"file":".trellis/spec/frontend/type-safety.md","reason":"新增策略标识的前端联合类型与接口契约"}
@@ -0,0 +1,40 @@
# 新增金砖共振选股策略
## Goal
在现有历史选股能力中增加“金砖共振”独立策略,按收盘后、Tushare qfq 日线、当前上市沪深非 ST A 股口径运行,让用户能够在线上完整数据上执行并通过日志定位失败股票和数据问题。
## Background
- 原始公式来自 `../zgnb-project/docs/references/formulas/金砖共振选gu(通达信).txt`。
- 公式使用日线 OHLCV、股票代码、通达信 B1 七类子信号、KDJ、3 日 RSI、趋势线、砖型图、动能和目标日换手率。
- 项目已同步六年 qfq 日线和 `daily_basic.turnover_rate`,并已实现 B1 七类子信号及通达信风格指标。
- 通达信 `DYNAINFO(37) >= 0.0099` 对应百分数口径换手率至少 `0.99`;Tushare `turnover_rate` 直接使用百分数口径。
## Requirements
1. 新增独立、可执行、可持久化和可查询的金砖共振策略,不改变现有 `zhixing_b1` 语义和结果。
2. 金砖策略复用现有 B1 指标与七类子信号计算,并补充原公式中的砖型图、黄柱、X 动能、强红、趋势、上影线、换手率和两类共振条件。
3. 策略只读取目标交易日及之前的数据,价格口径固定为 qfq,股票范围继续使用当前上市沪深非 ST A 股。
4. 批量选股读取必须为每只股票提供目标交易日的 `turnover_rate`;缺失换手率时该股票不得产生金砖信号,并记录可定位原因。
5. 金砖策略至少要求 200 根升序日线;目标日行情缺失、历史不足、换手率缺失、公式结果无效或单股计算异常时,必须形成明确状态或错误原因。
6. 在策略准备、批量读取、批量计算、结果持久化和失败收敛位置增加包含策略名、目标交易日、批次、股票代码、历史行数、换手率状态、结果状态及异常类型的结构化日志。日志不得包含 Token、数据库连接串或完整异常敏感上下文。
7. 前后端沿用现有策略选择、执行、进度和结果查看流程,并向用户展示“金砖共振”策略名称。
8. 本次不执行测试、lint、type-check、构建或线上数据请求;由用户部署到线上后提供日志进行后续排查。
## Acceptance Criteria
- [ ] 用户能在现有选股入口选择并执行“金砖共振”,运行记录和查询接口使用稳定的独立策略标识。
- [ ] 符合原公式最终 `买入条件` 的股票被选中,不符合、缺少目标日换手率或历史少于 200 根的股票不会被误选。
- [ ] 全市场批量执行能够读取目标日 `turnover_rate`,并将通达信阈值正确换算为 Tushare 的 `0.99` 百分数口径。
- [ ] 现有 `zhixing_b1` 仍走原有公式和结果契约,不因新增换手率读取或策略路由发生语义变化。
- [ ] 线上运行发生准备失败、读取失败、批次失败或单股失败时,日志能够通过策略名、目标交易日、批次与股票代码关联完整路径,且不泄露敏感配置。
- [ ] 本地未运行任何测试或验证命令,最终交付明确列出未验证风险和建议复制的日志范围。
## Out of Scope
- 盘中实时行情和实时预警。
- 北交所、ST、退市股票或无幸存者偏差历史股票池。
- 未复权、后复权或多复权口径切换。
- 新增 Tushare 数据接口、财务数据、资金流、板块或涨跌停条件。
- 调整原始公式参数或优化策略收益表现。
@@ -0,0 +1,26 @@
{
"id": "add-gold-brick-strategy",
"name": "add-gold-brick-strategy",
"title": "新增金砖共振选股策略",
"description": "按收盘后、qfq、沪深非ST口径新增金砖共振策略,复用B1指标并增加可复制诊断日志。",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P1",
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-09-04",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -0,0 +1,59 @@
"""Application use case for one-stock historical gold-brick evaluation."""
from __future__ import annotations
from collections.abc import Sequence
from datetime import date
from ..domain.gold_brick import GoldBrickStrategy
from ..domain.models import SelectionEvaluation, StockHistory
from ..domain.ports import MarketDataReader, MarketDataReaderError
class EvaluateGoldBrick:
"""Read one history, evaluate gold-brick, and map read failures."""
def __init__(
self,
reader: MarketDataReader,
strategy: GoldBrickStrategy | None = None,
) -> None:
"""Inject the market-data port and optionally a strategy instance."""
self.reader = reader
self.strategy = strategy or GoldBrickStrategy()
def execute(self, ts_code: str, target_trade_date: date) -> SelectionEvaluation:
"""Evaluate ``ts_code`` on the exact requested trading date."""
try:
history = self.reader.load_history(ts_code, target_trade_date)
except MarketDataReaderError as exc:
return SelectionEvaluation(
ts_code=ts_code,
target_trade_date=target_trade_date,
status="data_error",
reason=str(exc),
)
return self.strategy.evaluate(history, target_trade_date)
def execute_history(
self,
history: StockHistory,
target_trade_date: date,
) -> SelectionEvaluation:
"""Evaluate a history already loaded by the bounded batch reader."""
return self.strategy.evaluate(history, target_trade_date)
def execute_histories(
self,
histories: Sequence[StockHistory],
target_trade_date: date,
) -> tuple[SelectionEvaluation, ...]:
"""Evaluate loaded histories without issuing one read per stock."""
return tuple(self.execute_history(history, target_trade_date) for history in histories)
__all__ = ["EvaluateGoldBrick"]
@@ -1,16 +1,17 @@
"""Application orchestration for persisted whole-universe B1 runs.""" """Application orchestration for persisted whole-universe strategy runs."""
from __future__ import annotations from __future__ import annotations
import logging import logging
import time import time
from collections.abc import Callable, Sequence from collections import Counter
from collections.abc import Callable, Mapping, Sequence
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date
from typing import Literal, Protocol, cast from typing import Protocol, cast
from ..domain.models import SelectionEvaluation, StockHistory from ..domain.models import SelectionEvaluation, SelectionStrategyName, StockHistory
from ..domain.pattern_scoring import ( from ..domain.pattern_scoring import (
PatternCase, PatternCase,
PatternCaseLibraryLoader, PatternCaseLibraryLoader,
@@ -33,9 +34,16 @@ from ..domain.runs import (
) )
from .evaluate import EvaluateZhixingB1 from .evaluate import EvaluateZhixingB1
logger = logging.getLogger(__name__) # Selection runs are started by the ASGI service in production. A child of
StrategyName = Literal["zhixing_b1"] # Uvicorn's configured logger keeps INFO diagnostics visible in container logs.
_FAILURE_STATUSES = {"insufficient_history", "missing_target_bar", "data_error"} logger = logging.getLogger("uvicorn.error.zhixing.selection.run")
StrategyName = SelectionStrategyName
_FAILURE_STATUSES = {
"insufficient_history",
"missing_target_bar",
"missing_turnover_rate",
"data_error",
}
class SelectionEvaluator(Protocol): class SelectionEvaluator(Protocol):
@@ -53,13 +61,18 @@ class PreparedSelectionRun:
class RunZhixingB1: class RunZhixingB1:
"""Prepare, execute, and query persisted Zhixing B1 result batches.""" """Prepare, execute, and query persisted selection strategy batches.
The historical class name remains as a compatibility seam for existing
composition and tests while strategy routing is now explicit.
"""
def __init__( def __init__(
self, self,
reader: SelectionUniverseReader, reader: SelectionUniverseReader,
store: SelectionRunStore, store: SelectionRunStore,
evaluator: SelectionEvaluator | None = None, evaluator: SelectionEvaluator | None = None,
evaluators: Mapping[StrategyName, SelectionEvaluator] | None = None,
pattern_case_loader: PatternCaseLibraryLoader | None = None, pattern_case_loader: PatternCaseLibraryLoader | None = None,
pattern_scorer: PatternScorer | None = None, pattern_scorer: PatternScorer | None = None,
*, *,
@@ -76,6 +89,11 @@ class RunZhixingB1:
self.reader = reader self.reader = reader
self.store = store self.store = store
self.evaluator = evaluator or EvaluateZhixingB1(reader) self.evaluator = evaluator or EvaluateZhixingB1(reader)
self.evaluators: dict[StrategyName, SelectionEvaluator] = {
"zhixing_b1": self.evaluator,
}
if evaluators is not None:
self.evaluators.update(evaluators)
self.pattern_case_loader = pattern_case_loader self.pattern_case_loader = pattern_case_loader
self.pattern_scorer = pattern_scorer self.pattern_scorer = pattern_scorer
self.pattern_scoring_enabled = pattern_scoring_enabled self.pattern_scoring_enabled = pattern_scoring_enabled
@@ -91,12 +109,43 @@ class RunZhixingB1:
) -> PreparedSelectionRun: ) -> PreparedSelectionRun:
"""Validate source eligibility before claiming the rerunnable key.""" """Validate source eligibility before claiming the rerunnable key."""
source = self.reader.load_execution_source(strategy, target_trade_date) logger.info(
run = self.store.prepare_run( "selection_run_prepare_started strategy=%s target_trade_date=%s rerun=%s",
strategy, strategy,
target_trade_date, target_trade_date.isoformat(),
source, rerun,
rerun=rerun, )
if strategy not in self.evaluators:
raise ValueError(f"selection evaluator is not configured for strategy: {strategy}")
try:
source = self.reader.load_execution_source(strategy, target_trade_date)
run = self.store.prepare_run(
strategy,
target_trade_date,
source,
rerun=rerun,
)
except Exception as exc: # noqa: BLE001 - log the safe prepare boundary and preserve type
logger.warning(
"selection_run_prepare_failed strategy=%s target_trade_date=%s "
"status=failed error_type=%s reason=%s",
strategy,
target_trade_date.isoformat(),
exc.__class__.__name__,
_safe_item_error(exc),
)
raise
logger.info(
"selection_run_prepared strategy=%s target_trade_date=%s run_id=%s "
"market_sync_batch_id=%s target_count=%d eligible_count=%d "
"coverage=%s status=running",
strategy,
target_trade_date.isoformat(),
run.id,
source.market_sync_batch_id,
source.target_count,
len(source.stocks),
source.coverage,
) )
return PreparedSelectionRun(run=run, source=source) return PreparedSelectionRun(run=run, source=source)
@@ -109,29 +158,76 @@ class RunZhixingB1:
""" """
stocks = _unique_stocks(prepared.source.stocks) stocks = _unique_stocks(prepared.source.stocks)
strategy = prepared.run.strategy
target_trade_date = prepared.source.target_trade_date
evaluator = self.evaluators[strategy]
evaluated_count = 0 evaluated_count = 0
selected_stock_count = 0 selected_stock_count = 0
signal_count = 0 signal_count = 0
failed_count = 0 failed_count = 0
missing_turnover_count = 0
insufficient_history_count = 0
history_rows = 0 history_rows = 0
batch_count = _chunk_count(len(stocks), self.batch_size) batch_count = _chunk_count(len(stocks), self.batch_size)
current_batch = 0
final_status: SelectionRunStatus = "failed"
read_seconds = 0.0 read_seconds = 0.0
evaluate_seconds = 0.0 evaluate_seconds = 0.0
persist_seconds = 0.0 persist_seconds = 0.0
scoring_seconds = 0.0 scoring_seconds = 0.0
logger.info(
"selection_run_started strategy=%s target_trade_date=%s run_id=%s "
"market_sync_batch_id=%s stock_count=%d batch_count=%d worker_count=%d "
"status=running",
strategy,
target_trade_date.isoformat(),
prepared.run.id,
prepared.source.market_sync_batch_id,
len(stocks),
batch_count,
self.max_workers,
)
try: try:
pattern_cases, pattern_library_error = self._prepare_pattern_cases(prepared.run.id) if strategy == "zhixing_b1":
pattern_cases, pattern_library_error = self._prepare_pattern_cases(prepared.run.id)
else:
pattern_cases, pattern_library_error = None, None
with ThreadPoolExecutor(max_workers=self.max_workers) as executor: with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
for batch_stocks in _chunks(stocks, self.batch_size): for batch_index, batch_stocks in enumerate(
_chunks(stocks, self.batch_size),
start=1,
):
current_batch = batch_index
read_started = time.perf_counter() read_started = time.perf_counter()
histories = self._load_histories( histories = self._load_histories(
batch_stocks, batch_stocks,
prepared.source.target_trade_date, target_trade_date,
evaluator,
) )
read_seconds += time.perf_counter() - read_started batch_read_seconds = time.perf_counter() - read_started
history_rows += sum( read_seconds += batch_read_seconds
batch_history_rows = sum(
len(history.bars) for history in histories if history is not None len(history.bars) for history in histories if history is not None
) )
history_rows += batch_history_rows
batch_missing_turnover = sum(
not _turnover_present(history, target_trade_date)
for history in histories
)
logger.info(
"selection_read_batch_summary strategy=%s target_trade_date=%s "
"run_id=%s batch=%d batch_count=%d stock_count=%d history_rows=%d "
"turnover_missing_count=%d status=success read_seconds=%.3f",
strategy,
target_trade_date.isoformat(),
prepared.run.id,
batch_index,
batch_count,
len(batch_stocks),
batch_history_rows,
batch_missing_turnover,
batch_read_seconds,
)
evaluate_started = time.perf_counter() evaluate_started = time.perf_counter()
evaluations = tuple( evaluations = tuple(
@@ -139,10 +235,46 @@ class RunZhixingB1:
self._evaluate_stock, self._evaluate_stock,
batch_stocks, batch_stocks,
histories, histories,
[prepared.source.target_trade_date] * len(batch_stocks), [target_trade_date] * len(batch_stocks),
[evaluator] * len(batch_stocks),
[strategy] * len(batch_stocks),
[prepared.run.id] * len(batch_stocks),
[batch_index] * len(batch_stocks),
) )
) )
evaluate_seconds += time.perf_counter() - evaluate_started batch_evaluate_seconds = time.perf_counter() - evaluate_started
evaluate_seconds += batch_evaluate_seconds
status_counts = Counter(evaluation.status for evaluation in evaluations)
no_signal_reasons = Counter(
evaluation.reason or "unspecified"
for evaluation in evaluations
if evaluation.status == "no_signal"
)
missing_turnover_count += status_counts["missing_turnover_rate"]
insufficient_history_count += status_counts["insufficient_history"]
for stock, history, evaluation in zip(
batch_stocks,
histories,
evaluations,
strict=True,
):
if evaluation.status not in _FAILURE_STATUSES:
continue
logger.warning(
"selection_item_incomplete strategy=%s target_trade_date=%s "
"run_id=%s batch=%d ts_code=%s history_rows=%d "
"turnover_present=%s status=%s error_type=%s reason=%s",
strategy,
target_trade_date.isoformat(),
prepared.run.id,
batch_index,
stock.ts_code,
len(history.bars) if history is not None else 0,
_turnover_present(history, target_trade_date),
evaluation.status,
evaluation.status,
evaluation.reason or evaluation.status,
)
scoring_started = time.perf_counter() scoring_started = time.perf_counter()
items = tuple( items = tuple(
@@ -151,6 +283,7 @@ class RunZhixingB1:
stock.name, stock.name,
evaluation, evaluation,
pattern_score=self._score_stock( pattern_score=self._score_stock(
strategy,
prepared.run.id, prepared.run.id,
stock, stock,
history, history,
@@ -173,23 +306,79 @@ class RunZhixingB1:
signal_count += sum(item.signal_count 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) failed_count += sum(item.status in _FAILURE_STATUSES for item in items)
logger.info(
"selection_evaluate_batch_summary strategy=%s target_trade_date=%s "
"run_id=%s batch=%d batch_count=%d stock_count=%d selected_count=%d "
"no_signal_count=%d insufficient_history_count=%d "
"missing_target_bar_count=%d missing_turnover_count=%d "
"data_error_count=%d no_signal_reasons=%s status=complete "
"evaluate_seconds=%.3f",
strategy,
target_trade_date.isoformat(),
prepared.run.id,
batch_index,
batch_count,
len(items),
status_counts["selected"],
status_counts["no_signal"],
status_counts["insufficient_history"],
status_counts["missing_target_bar"],
status_counts["missing_turnover_rate"],
status_counts["data_error"],
dict(no_signal_reasons),
batch_evaluate_seconds,
)
persist_started = time.perf_counter() persist_started = time.perf_counter()
self._record_items(prepared.run.id, items) self._record_items(prepared.run.id, items)
persist_seconds += time.perf_counter() - persist_started batch_persist_seconds = time.perf_counter() - persist_started
persist_seconds += batch_persist_seconds
logger.info(
"selection_persist_batch_summary strategy=%s target_trade_date=%s "
"run_id=%s batch=%d batch_count=%d item_count=%d signal_count=%d "
"status=success persist_seconds=%.3f",
strategy,
target_trade_date.isoformat(),
prepared.run.id,
batch_index,
batch_count,
len(items),
sum(item.signal_count for item in items),
batch_persist_seconds,
)
status = _run_status(evaluated_count, failed_count) final_status = _run_status(evaluated_count, failed_count)
self.store.finish_run( self.store.finish_run(
prepared.run.id, prepared.run.id,
status, final_status,
evaluated_count=evaluated_count, evaluated_count=evaluated_count,
selected_stock_count=selected_stock_count, selected_stock_count=selected_stock_count,
signal_count=signal_count, signal_count=signal_count,
failed_count=failed_count, failed_count=failed_count,
) )
except Exception as exc: # noqa: BLE001 - worker boundary must persist failure state logger.info(
logger.error( "selection_run_converged strategy=%s target_trade_date=%s run_id=%s "
"selection_run_failed run_id=%s error_type=%s reason=%s", "batch=%d evaluated_count=%d selected_stock_count=%d signal_count=%d "
"failed_count=%d status=%s",
strategy,
target_trade_date.isoformat(),
prepared.run.id, prepared.run.id,
current_batch,
evaluated_count,
selected_stock_count,
signal_count,
failed_count,
final_status,
)
except Exception as exc: # noqa: BLE001 - worker boundary must persist failure state
final_status = "failed"
logger.error(
"selection_run_failed strategy=%s target_trade_date=%s run_id=%s "
"batch=%d status=failed error_type=%s reason=%s",
strategy,
target_trade_date.isoformat(),
prepared.run.id,
current_batch,
exc.__class__.__name__, exc.__class__.__name__,
_safe_item_error(exc), _safe_item_error(exc),
) )
@@ -202,23 +391,41 @@ class RunZhixingB1:
signal_count=signal_count, signal_count=signal_count,
failed_count=max(failed_count, 1), failed_count=max(failed_count, 1),
error_type="batch_error", error_type="batch_error",
error_message=str(exc), error_message=_safe_item_error(exc),
) )
except Exception: # noqa: BLE001 - preserve the original worker failure except Exception: # noqa: BLE001 - preserve the original worker failure
logger.error( logger.error(
"selection_run_failure_persist_failed run_id=%s", "selection_run_failure_persist_failed strategy=%s target_trade_date=%s "
"run_id=%s batch=%d status=failed error_type=finish_run_failed",
strategy,
target_trade_date.isoformat(),
prepared.run.id, prepared.run.id,
current_batch,
) )
finally: finally:
logger.info( logger.info(
"selection_run_summary run_id=%s stock_count=%d history_rows=%d " "selection_run_summary strategy=%s target_trade_date=%s run_id=%s "
"batch_count=%d worker_count=%d read_seconds=%.3f " "market_sync_batch_id=%s stock_count=%d history_rows=%d batch_count=%d "
"last_batch=%d worker_count=%d evaluated_count=%d selected_stock_count=%d "
"signal_count=%d failed_count=%d insufficient_history_count=%d "
"missing_turnover_count=%d status=%s read_seconds=%.3f "
"evaluate_seconds=%.3f scoring_seconds=%.3f persist_seconds=%.3f", "evaluate_seconds=%.3f scoring_seconds=%.3f persist_seconds=%.3f",
strategy,
target_trade_date.isoformat(),
prepared.run.id, prepared.run.id,
prepared.source.market_sync_batch_id,
len(stocks), len(stocks),
history_rows, history_rows,
batch_count, batch_count,
current_batch,
self.max_workers, self.max_workers,
evaluated_count,
selected_stock_count,
signal_count,
failed_count,
insufficient_history_count,
missing_turnover_count,
final_status,
read_seconds, read_seconds,
evaluate_seconds, evaluate_seconds,
scoring_seconds, scoring_seconds,
@@ -251,6 +458,7 @@ class RunZhixingB1:
def _score_stock( def _score_stock(
self, self,
strategy: StrategyName,
run_id: str, run_id: str,
stock: SelectionStock, stock: SelectionStock,
history: StockHistory | None, history: StockHistory | None,
@@ -260,7 +468,11 @@ class RunZhixingB1:
) -> PatternScore: ) -> PatternScore:
"""Score one selected stock once and isolate enrichment failures.""" """Score one selected stock once and isolate enrichment failures."""
if not self.pattern_scoring_enabled or evaluation.status != "selected": if (
strategy != "zhixing_b1"
or not self.pattern_scoring_enabled
or evaluation.status != "selected"
):
return PatternScore() return PatternScore()
if library_error is not None: if library_error is not None:
return PatternScore.failed(library_error) return PatternScore.failed(library_error)
@@ -283,6 +495,7 @@ class RunZhixingB1:
self, self,
stocks: Sequence[SelectionStock], stocks: Sequence[SelectionStock],
target_trade_date: date, target_trade_date: date,
evaluator: SelectionEvaluator,
) -> tuple[StockHistory | None, ...]: ) -> tuple[StockHistory | None, ...]:
"""Load one chunk when the reader supports it, with old-path fallback.""" """Load one chunk when the reader supports it, with old-path fallback."""
@@ -300,7 +513,8 @@ class RunZhixingB1:
for stock in typed_stocks for stock in typed_stocks
) )
if isinstance(self.evaluator, EvaluateZhixingB1): execute_history = getattr(evaluator, "execute_history", None)
if callable(execute_history):
return tuple( return tuple(
self.reader.load_history(stock.ts_code, target_trade_date) for stock in typed_stocks self.reader.load_history(stock.ts_code, target_trade_date) for stock in typed_stocks
) )
@@ -311,23 +525,35 @@ class RunZhixingB1:
stock: SelectionStock, stock: SelectionStock,
history: StockHistory | None, history: StockHistory | None,
target_trade_date: date, target_trade_date: date,
evaluator: SelectionEvaluator,
strategy: StrategyName,
run_id: str,
batch_index: int,
) -> SelectionEvaluation: ) -> SelectionEvaluation:
"""Evaluate one stock inside a worker and isolate its exception.""" """Evaluate one stock inside a worker and isolate its exception."""
ts_code = stock.ts_code ts_code = stock.ts_code
try: try:
execute_history: Callable[[StockHistory, date], SelectionEvaluation] | None = getattr( execute_history: Callable[[StockHistory, date], SelectionEvaluation] | None = getattr(
self.evaluator, evaluator,
"execute_history", "execute_history",
None, None,
) )
if history is not None and execute_history is not None: if history is not None and execute_history is not None:
return execute_history(history, target_trade_date) return execute_history(history, target_trade_date)
return self.evaluator.execute(ts_code, target_trade_date) return evaluator.execute(ts_code, target_trade_date)
except Exception as exc: # noqa: BLE001 - isolate one stock from the batch except Exception as exc: # noqa: BLE001 - isolate one stock from the batch
logger.warning( logger.warning(
"selection_item_failed ts_code=%s error_type=%s reason=%s", "selection_item_failed strategy=%s target_trade_date=%s run_id=%s "
"batch=%d ts_code=%s history_rows=%d turnover_present=%s "
"status=data_error error_type=%s reason=%s",
strategy,
target_trade_date.isoformat(),
run_id,
batch_index,
ts_code, ts_code,
len(history.bars) if history is not None else 0,
_turnover_present(history, target_trade_date),
exc.__class__.__name__, exc.__class__.__name__,
_safe_item_error(exc), _safe_item_error(exc),
) )
@@ -407,6 +633,15 @@ def _safe_item_error(error: Exception) -> str:
return " ".join(str(error).split())[:500] or error.__class__.__name__ return " ".join(str(error).split())[:500] or error.__class__.__name__
def _turnover_present(history: StockHistory | None, target_trade_date: date) -> bool:
"""Return whether target-day Tushare turnover is available for diagnostics."""
if history is None:
return False
basic = history.daily_basic.get(target_trade_date)
return basic is not None and basic.turnover_rate is not None
def _chunks( def _chunks(
values: Sequence[SelectionStock], values: Sequence[SelectionStock],
size: int, size: int,
@@ -0,0 +1,381 @@
"""Formula-level implementation of the independent gold-brick strategy."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
import numpy as np
import pandas as pd
from .indicators import EXIST, HHV, LLV, REF, SMA, serializable_metrics
from .models import (
GoldBrickCategory,
SelectionEvaluation,
SelectionSignal,
StockHistory,
)
from .zhixing_b1 import compute_signal_masks, prepare_zhixing_b1_indicators
GOLD_BRICK_MINIMUM_HISTORY = 200
GOLD_BRICK_TURNOVER_RATE_THRESHOLD = 0.99
GOLD_BRICK_SIGNAL_ORDER: tuple[GoldBrickCategory, ...] = (
GoldBrickCategory.RESONANCE,
)
def _safe_ratio(numerator: pd.Series, denominator: pd.Series) -> pd.Series:
"""Divide two series while retaining invalid zero denominators as NaN."""
return numerator.div(denominator.replace(0, np.nan))
def prepare_gold_brick_indicators(frame: pd.DataFrame, code: str) -> pd.DataFrame:
"""Prepare the original gold-brick formula on ascending qfq OHLCV rows.
Args:
frame: Ascending qfq rows with ``open``, ``high``, ``low``, ``close``
and ``volume`` columns.
code: Tushare-style stock code used by the reused B1 width rules.
Returns:
A prepared frame containing the seven B1 masks, brick chart, momentum,
trend, upper-shadow, and both resonance conditions.
"""
result = prepare_zhixing_b1_indicators(frame, code)
close = result["close"].astype(float)
high = result["high"].astype(float)
low = result["low"].astype(float)
open_price = result["open"].astype(float)
volume = result["volume"].astype(float)
previous_close = REF(close, 1)
previous_volume = REF(volume, 1)
range_high = HHV(high, 4)
range_low = LLV(low, 4)
range_width = range_high - range_low
var1a = _safe_ratio(range_high - close, range_width).mul(100).sub(90)
var2a = SMA(var1a, 4, 1).add(100)
var3a = _safe_ratio(close - range_low, range_width).mul(100)
var4a = SMA(var3a, 6, 1)
var5a = SMA(var4a, 6, 1).add(100)
var6a = var5a - var2a
result["brick_chart"] = (var6a - 4).where(var6a > 4, 0.0)
b1_masks = compute_signal_masks(result)
existing_b1 = pd.Series(False, index=result.index, dtype=bool)
for mask in b1_masks.values():
existing_b1 |= mask
result["existing_b1"] = existing_b1
j_momentum = result["j"] - REF(result["j"], 1)
rsi_momentum = result["rsi"] - REF(result["rsi"], 1)
momentum_sum = j_momentum + rsi_momentum
previous_momentum_sum = REF(j_momentum, 1) + REF(rsi_momentum, 1)
volume_ratio = _safe_ratio(volume, previous_volume)
volume_coefficient = pd.Series(
np.where(
volume < previous_volume * 0.99,
(1 - 5 * _safe_ratio(previous_volume - volume, previous_volume)) * 0.8,
1.0,
),
index=result.index,
dtype=float,
)
multiple_volume_coefficient = pd.Series(
np.where(volume_ratio >= 4, 1.4, volume_ratio * 0.1 + 1),
index=result.index,
dtype=float,
)
multiple_volume_bonus = pd.Series(
np.where(
(close > open_price)
& (close > previous_close)
& (volume > previous_volume * 1.8),
multiple_volume_coefficient,
1.0,
),
index=result.index,
dtype=float,
)
shadow_floor = pd.Series(
np.minimum(open_price.to_numpy(float), previous_close.to_numpy(float)),
index=result.index,
dtype=float,
)
shadow_coefficient = pd.Series(
np.where(
(close > previous_close) & (close > open_price),
(0.75 - _safe_ratio(high - close, high - shadow_floor)) * 1.3,
1.0,
),
index=result.index,
dtype=float,
)
result["j_momentum"] = j_momentum
result["rsi_momentum"] = rsi_momentum
result["yellow_column"] = (
momentum_sum.div(2).mul(shadow_coefficient).mul(multiple_volume_bonus)
)
x_condition = (
(close > open_price)
& (close > previous_close)
& (momentum_sum > previous_momentum_sum)
)
result["x_momentum"] = (
momentum_sum.sub(previous_momentum_sum)
.div(2)
.mul(shadow_coefficient)
.mul(volume_coefficient)
.mul(multiple_volume_bonus)
.where(x_condition, 0.0)
)
brick = result["brick_chart"]
current_red = brick > REF(brick, 1)
current_green = brick <= REF(brick, 1)
previous_green = REF(current_green.astype(float), 1) == 1
red_length = (brick - REF(brick, 1)).where(current_red, 0.0)
brick_length = brick - REF(brick, 1)
previous_green_length = (REF(brick, 2) - REF(brick, 1)).where(
previous_green,
0.0,
)
red_green_ratio = _safe_ratio(red_length, previous_green_length).where(
previous_green_length > 0,
0.0,
)
result["brick_length"] = brick_length
result["strong_red"] = current_red & previous_green & (red_green_ratio > 0.666)
result["gold_trend_condition"] = (
(result["trend_white"] >= result["trend_yellow"] * 0.995)
& (result["trend_yellow"] >= REF(result["trend_yellow"], 1) * 0.997)
& (close >= result["trend_yellow"] * 0.997)
)
upper_shadow_floor = pd.Series(
np.minimum(low.to_numpy(float), previous_close.to_numpy(float)),
index=result.index,
dtype=float,
)
result["upper_shadow_strength"] = 1 - _safe_ratio(
high - close,
high - upper_shadow_floor,
)
result["upper_shadow_condition"] = (
((close >= open_price) | (close > previous_close))
& (result["upper_shadow_strength"] > 0.618)
)
long = result["long_oscillator"]
short = result["short_oscillator"]
result["resonance_condition_1"] = (
result["strong_red"]
& ((result["yellow_column"] >= 7.5) | (result["x_momentum"] >= 7.5))
& (EXIST(existing_b1, 2) | ((REF(long, 1) > 85) & (REF(short, 1) < 30)))
)
result["resonance_condition_2"] = (
result["strong_red"]
& ((result["yellow_column"] >= 10) | (result["x_momentum"] >= 10))
& (
(EXIST((long - short) > 60, 4) & (long > 98) & (short > 98))
| ((result["yellow_column"] > 20) & (close > result["trend_white"]))
| (result["yellow_column"] > 30)
| ((result["yellow_column"] + brick_length) > 50)
| (result["x_momentum"] > 40)
)
)
result["resonance_condition"] = (
result["resonance_condition_1"] | result["resonance_condition_2"]
)
return result
@dataclass(frozen=True, slots=True)
class GoldBrickStrategy:
"""Evaluate the close-of-day gold-brick resonance formula."""
name: str = "gold_brick"
def evaluate(
self,
history: StockHistory,
target_trade_date: date,
) -> SelectionEvaluation:
"""Evaluate one explicit date and fail closed on incomplete formula inputs."""
bars_by_date = {bar.trade_date: bar for bar in history.bars}
target_bar = bars_by_date.get(target_trade_date)
if target_bar is None or any(
value is None
for value in (
target_bar.open,
target_bar.high,
target_bar.low,
target_bar.close,
target_bar.volume,
)
):
return SelectionEvaluation(
history.ts_code,
target_trade_date,
"missing_target_bar",
reason="target trade date has no complete qfq daily bar",
)
selected_bars = tuple(
sorted(
(bar for bar in bars_by_date.values() if bar.trade_date <= target_trade_date),
key=lambda bar: bar.trade_date,
)
)
if len(selected_bars) < GOLD_BRICK_MINIMUM_HISTORY:
return SelectionEvaluation(
history.ts_code,
target_trade_date,
"insufficient_history",
reason=(
f"gold brick needs at least {GOLD_BRICK_MINIMUM_HISTORY} "
"ascending bars before evaluation"
),
)
if any(
value is None
for bar in selected_bars
for value in (bar.open, bar.high, bar.low, bar.close, bar.volume)
):
return SelectionEvaluation(
history.ts_code,
target_trade_date,
"data_error",
reason="gold brick history contains incomplete qfq OHLCV values",
)
target_basic = history.daily_basic.get(target_trade_date)
turnover_rate = target_basic.turnover_rate if target_basic is not None else None
if turnover_rate is None:
return SelectionEvaluation(
history.ts_code,
target_trade_date,
"missing_turnover_rate",
reason="target trade date has no Tushare turnover_rate",
)
frame = pd.DataFrame(
{
"trade_date": [bar.trade_date for bar in selected_bars],
"open": [bar.open for bar in selected_bars],
"high": [bar.high for bar in selected_bars],
"low": [bar.low for bar in selected_bars],
"close": [bar.close for bar in selected_bars],
"volume": [bar.volume for bar in selected_bars],
}
)
prepared = prepare_gold_brick_indicators(frame, history.ts_code)
target_index = int(prepared.index[prepared["trade_date"] == target_trade_date][0])
required_metrics = (
"brick_chart",
"brick_length",
"yellow_column",
"x_momentum",
"trend_white",
"trend_yellow",
"upper_shadow_strength",
)
invalid_metrics = tuple(
metric
for metric in required_metrics
if not np.isfinite(float(prepared.at[target_index, metric]))
)
if invalid_metrics:
return SelectionEvaluation(
history.ts_code,
target_trade_date,
"data_error",
reason=(
"gold brick formula produced non-finite target metrics: "
+ ", ".join(invalid_metrics)
),
)
row = prepared.iloc[target_index]
turnover_condition = turnover_rate >= GOLD_BRICK_TURNOVER_RATE_THRESHOLD
matched = bool(
row["resonance_condition"]
and row["upper_shadow_condition"]
and row["gold_trend_condition"]
and turnover_condition
)
if not matched:
gate_state = (
f"resonance={int(bool(row['resonance_condition']))} "
f"upper_shadow={int(bool(row['upper_shadow_condition']))} "
f"trend={int(bool(row['gold_trend_condition']))} "
f"turnover={int(turnover_condition)}"
)
return SelectionEvaluation(
history.ts_code,
target_trade_date,
"no_signal",
reason=f"gold brick gates did not match: {gate_state}",
)
resonance_types = ";".join(
label
for label, condition in (
("共振条件1", row["resonance_condition_1"]),
("共振条件2", row["resonance_condition_2"]),
)
if bool(condition)
)
details = serializable_metrics(
(
("signal", "金砖共振"),
("resonance_type", resonance_types),
("brick_chart", row["brick_chart"]),
("brick_length", row["brick_length"]),
("yellow_column", row["yellow_column"]),
("x_momentum", row["x_momentum"]),
("j", row["j"]),
("rsi", row["rsi"]),
("trend_white", row["trend_white"]),
("trend_yellow", row["trend_yellow"]),
("upper_shadow_strength", row["upper_shadow_strength"]),
("turnover_rate", turnover_rate),
)
)
signal = SelectionSignal(
ts_code=history.ts_code,
name=history.name,
target_trade_date=target_trade_date,
strategy="gold_brick",
category=GoldBrickCategory.RESONANCE,
close=float(row["close"]),
details=details,
)
return SelectionEvaluation(
history.ts_code,
target_trade_date,
"selected",
signals=(signal,),
)
def select(
self,
history: StockHistory,
target_trade_date: date,
) -> tuple[SelectionSignal, ...]:
"""Return only signals for callers that do not need evaluation status."""
return self.evaluate(history, target_trade_date).signals
__all__ = [
"GOLD_BRICK_MINIMUM_HISTORY",
"GOLD_BRICK_SIGNAL_ORDER",
"GOLD_BRICK_TURNOVER_RATE_THRESHOLD",
"GoldBrickStrategy",
"prepare_gold_brick_indicators",
]
@@ -9,6 +9,8 @@ from enum import StrEnum
from math import isfinite from math import isfinite
from typing import Literal from typing import Literal
SelectionStrategyName = Literal["zhixing_b1", "gold_brick"]
def _validate_number(value: float | None, field_name: str) -> None: def _validate_number(value: float | None, field_name: str) -> None:
"""Reject infinities while allowing ``None`` for incomplete source rows.""" """Reject infinities while allowing ``None`` for incomplete source rows."""
@@ -91,15 +93,24 @@ class ZhixingB1Category(StrEnum):
PULLBACK_YELLOW = "zhixing_b1_pullback_yellow" PULLBACK_YELLOW = "zhixing_b1_pullback_yellow"
class GoldBrickCategory(StrEnum):
"""The independently persisted final signal from the gold-brick formula."""
RESONANCE = "gold_brick_resonance"
SelectionSignalCategory = ZhixingB1Category | GoldBrickCategory
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class SelectionSignal: class SelectionSignal:
"""One explainable B1 hit with a stable identity.""" """One explainable strategy hit with a stable persistence identity."""
ts_code: str ts_code: str
name: str name: str
target_trade_date: date target_trade_date: date
strategy: Literal["zhixing_b1"] strategy: SelectionStrategyName
category: ZhixingB1Category category: SelectionSignalCategory
close: float close: float
details: Mapping[str, float | str | None] = field( details: Mapping[str, float | str | None] = field(
default_factory=lambda: dict[str, float | str | None]() default_factory=lambda: dict[str, float | str | None]()
@@ -122,6 +133,7 @@ SelectionEvaluationStatus = Literal[
"no_signal", "no_signal",
"insufficient_history", "insufficient_history",
"missing_target_bar", "missing_target_bar",
"missing_turnover_rate",
"data_error", "data_error",
] ]
@@ -8,12 +8,17 @@ from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from typing import Literal, Protocol from typing import Literal, Protocol
from .models import SelectionEvaluationStatus, SelectionSignal, StockHistory from .models import (
SelectionEvaluationStatus,
SelectionSignal,
SelectionStrategyName,
StockHistory,
)
from .pattern_scoring import PatternScore from .pattern_scoring import PatternScore
SelectionRunStatus = Literal["running", "success", "partial_success", "failed"] SelectionRunStatus = Literal["running", "success", "partial_success", "failed"]
SelectionRunItemStatus = SelectionEvaluationStatus SelectionRunItemStatus = SelectionEvaluationStatus
SelectionSignalCategoryFilter = Literal["pullback", "oversold", "original"] SelectionSignalCategoryFilter = Literal["pullback", "oversold", "original", "resonance"]
SelectionResultSort = Literal["code", "score_desc", "score_asc"] SelectionResultSort = Literal["code", "score_desc", "score_asc"]
@@ -66,7 +71,7 @@ class SelectionRun:
"""A current execution attempt and its materialized result rows.""" """A current execution attempt and its materialized result rows."""
id: str id: str
strategy: Literal["zhixing_b1"] strategy: SelectionStrategyName
target_trade_date: date target_trade_date: date
market_sync_batch_id: str | None market_sync_batch_id: str | None
status: SelectionRunStatus status: SelectionRunStatus
@@ -107,7 +112,7 @@ class SelectionRunStore(Protocol):
def prepare_run( def prepare_run(
self, self,
strategy: Literal["zhixing_b1"], strategy: SelectionStrategyName,
target_trade_date: date, target_trade_date: date,
source: SelectionExecutionSource, source: SelectionExecutionSource,
*, *,
@@ -138,7 +143,7 @@ class SelectionRunStore(Protocol):
def get_latest_run( def get_latest_run(
self, self,
strategy: Literal["zhixing_b1"], strategy: SelectionStrategyName,
target_trade_date: date | None = None, target_trade_date: date | None = None,
*, *,
query: SelectionResultQuery | None = None, query: SelectionResultQuery | None = None,
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import logging
from collections.abc import Generator, Sequence from collections.abc import Generator, Sequence
from contextlib import contextmanager from contextlib import contextmanager
from datetime import date, datetime from datetime import date, datetime
@@ -22,6 +23,9 @@ from ..domain.ports import MarketDataReaderError
from ..domain.runs import SelectionExecutionSource, SelectionStock from ..domain.runs import SelectionExecutionSource, SelectionStock
from .postgres_pool import SelectionConnectionPool, SelectionPostgresPool from .postgres_pool import SelectionConnectionPool, SelectionPostgresPool
# Use Uvicorn's configured logger so preflight diagnostics reach container logs.
logger = logging.getLogger("uvicorn.error.zhixing.selection.reader")
class SelectionReaderError(MarketDataReaderError): class SelectionReaderError(MarketDataReaderError):
"""Database read failure with stock and target-date context.""" """Database read failure with stock and target-date context."""
@@ -40,10 +44,16 @@ SELECT
bar.high, bar.high,
bar.low, bar.low,
bar.close, bar.close,
bar.vol bar.vol,
basic.turnover_rate,
basic.total_mv
FROM market_daily_bar AS bar FROM market_daily_bar AS bar
LEFT JOIN market_stock AS stock LEFT JOIN market_stock AS stock
ON stock.ts_code = bar.ts_code ON stock.ts_code = bar.ts_code
LEFT JOIN market_daily_basic AS basic
ON basic.ts_code = bar.ts_code
AND basic.trade_date = bar.trade_date
AND basic.trade_date = %s
WHERE bar.ts_code = ANY(%s) WHERE bar.ts_code = ANY(%s)
AND bar.source_adj = 'qfq' AND bar.source_adj = 'qfq'
AND bar.trade_date <= %s AND bar.trade_date <= %s
@@ -109,6 +119,25 @@ WHERE stock.is_active = true
ORDER BY stock.ts_code ORDER BY stock.ts_code
""" """
_GOLD_BRICK_ELIGIBLE_STOCKS_QUERY = """
SELECT stock.ts_code, stock.name
FROM market_stock AS stock
WHERE stock.is_active = true
AND EXISTS (
SELECT 1
FROM market_daily_bar AS bar
WHERE bar.ts_code = stock.ts_code
AND bar.trade_date = %s
AND bar.source_adj = 'qfq'
AND bar.open IS NOT NULL
AND bar.high IS NOT NULL
AND bar.low IS NOT NULL
AND bar.close IS NOT NULL
AND bar.vol IS NOT NULL
)
ORDER BY stock.ts_code
"""
_PATTERN_CASES_QUERY = """ _PATTERN_CASES_QUERY = """
WITH case_definition AS ( WITH case_definition AS (
SELECT * SELECT *
@@ -238,10 +267,9 @@ class PostgresMarketDataReader:
) -> tuple[StockHistory, ...]: ) -> tuple[StockHistory, ...]:
"""Read one bounded stock chunk with one parameterized qfq query. """Read one bounded stock chunk with one parameterized qfq query.
Historical daily-basic values are deliberately not joined here: B1 Same-day daily-basic rows are left joined so strategies that require
only needs OHLCV for its historical formula. The execution-source target-day liquidity can fail closed without changing the OHLCV-only
query still requires a complete target-day basic row before a stock is Zhixing B1 formula.
admitted to a run.
""" """
normalized = tuple( normalized = tuple(
@@ -255,7 +283,7 @@ class PostgresMarketDataReader:
with self._connection() as connection: with self._connection() as connection:
rows = connection.execute( rows = connection.execute(
_HISTORY_QUERY, _HISTORY_QUERY,
(codes, target_trade_date), (target_trade_date, codes, target_trade_date),
).fetchall() ).fetchall()
except SelectionReaderError: except SelectionReaderError:
raise raise
@@ -273,9 +301,8 @@ class PostgresMarketDataReader:
"""Load the qualified market-data snapshot for a strategy run. """Load the qualified market-data snapshot for a strategy run.
Args: Args:
strategy: Supported strategy identity. The current reader accepts strategy: Supported ``zhixing_b1`` or ``gold_brick`` identity,
``zhixing_b1`` and keeps the parameter explicit for future used to apply strategy-specific target-day eligibility rules.
strategy-specific eligibility rules.
target_trade_date: Historical trading date to evaluate. target_trade_date: Historical trading date to evaluate.
Returns: Returns:
@@ -287,19 +314,36 @@ class PostgresMarketDataReader:
SelectionReaderError: If PostgreSQL cannot complete the read. SelectionReaderError: If PostgreSQL cannot complete the read.
""" """
if strategy != "zhixing_b1": if strategy not in {"zhixing_b1", "gold_brick"}:
raise SelectionMarketDataNotReady(f"unsupported selection strategy: {strategy}") raise SelectionMarketDataNotReady(f"unsupported selection strategy: {strategy}")
logger.info(
"selection_source_precheck_started strategy=%s target_trade_date=%s",
strategy,
target_trade_date.isoformat(),
)
try: try:
with self._connection() as connection: with self._connection() as connection:
source_row = connection.execute(_SOURCE_QUERY, (target_trade_date,)).fetchone() source_row = connection.execute(_SOURCE_QUERY, (target_trade_date,)).fetchone()
if source_row is None: if source_row is None:
logger.warning(
"selection_source_precheck_failed strategy=%s target_trade_date=%s "
"status=market_data_not_ready error_type=missing_eligible_sync_batch",
strategy,
target_trade_date.isoformat(),
)
raise SelectionMarketDataNotReady( raise SelectionMarketDataNotReady(
f"market data is not strategy-eligible for {target_trade_date.isoformat()}" f"market data is not strategy-eligible for {target_trade_date.isoformat()}"
) )
stock_rows = connection.execute( if strategy == "gold_brick":
_ELIGIBLE_STOCKS_QUERY, stock_rows = connection.execute(
(target_trade_date, target_trade_date), _GOLD_BRICK_ELIGIBLE_STOCKS_QUERY,
).fetchall() (target_trade_date,),
).fetchall()
else:
stock_rows = connection.execute(
_ELIGIBLE_STOCKS_QUERY,
(target_trade_date, target_trade_date),
).fetchall()
except (SelectionMarketDataNotReady, SelectionReaderError): except (SelectionMarketDataNotReady, SelectionReaderError):
raise raise
except Exception as exc: # noqa: BLE001 - redact driver/pool details at the port boundary except Exception as exc: # noqa: BLE001 - redact driver/pool details at the port boundary
@@ -311,9 +355,29 @@ class PostgresMarketDataReader:
SelectionStock(ts_code=str(row[0]), name=str(row[1] or "")) for row in stock_rows SelectionStock(ts_code=str(row[0]), name=str(row[1] or "")) for row in stock_rows
) )
if not stocks: if not stocks:
logger.warning(
"selection_source_precheck_failed strategy=%s target_trade_date=%s "
"market_sync_batch_id=%s status=market_data_not_ready "
"error_type=no_eligible_stocks",
strategy,
target_trade_date.isoformat(),
source_row[0],
)
raise SelectionMarketDataNotReady( raise SelectionMarketDataNotReady(
f"no eligible stocks have complete market data for {target_trade_date.isoformat()}" f"no eligible stocks have complete market data for {target_trade_date.isoformat()}"
) )
logger.info(
"selection_source_precheck_ready strategy=%s target_trade_date=%s "
"market_sync_batch_id=%s target_count=%s valid_count=%s "
"eligible_count=%d coverage=%s status=ready",
strategy,
target_trade_date.isoformat(),
source_row[0],
source_row[1],
source_row[2],
len(stocks),
source_row[3],
)
return SelectionExecutionSource( return SelectionExecutionSource(
market_sync_batch_id=str(source_row[0]), market_sync_batch_id=str(source_row[0]),
target_trade_date=target_trade_date, target_trade_date=target_trade_date,
@@ -14,7 +14,14 @@ from uuid import uuid4
import psycopg import psycopg
from psycopg.types.json import Jsonb from psycopg.types.json import Jsonb
from ..domain.models import SelectionSignal, ZhixingB1Category from ..domain.gold_brick import GOLD_BRICK_SIGNAL_ORDER
from ..domain.models import (
GoldBrickCategory,
SelectionSignal,
SelectionSignalCategory,
SelectionStrategyName,
ZhixingB1Category,
)
from ..domain.pattern_scoring import ( from ..domain.pattern_scoring import (
ZHIXING_B1_PATTERN_CASES, ZHIXING_B1_PATTERN_CASES,
PatternScore, PatternScore,
@@ -35,19 +42,26 @@ from ..domain.runs import (
from ..domain.zhixing_b1 import ZHIXING_B1_SIGNAL_ORDER from ..domain.zhixing_b1 import ZHIXING_B1_SIGNAL_ORDER
from .postgres_pool import SelectionConnectionPool, SelectionPostgresPool from .postgres_pool import SelectionConnectionPool, SelectionPostgresPool
_SIGNAL_PRIORITY = {category: index for index, category in enumerate(ZHIXING_B1_SIGNAL_ORDER)} _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)
}
_CATEGORY_PREFIXES = { _CATEGORY_PREFIXES = {
"pullback": "zhixing_b1_pullback_", "pullback": "zhixing_b1_pullback_",
"oversold": "zhixing_b1_oversold_", "oversold": "zhixing_b1_oversold_",
"original": "zhixing_b1_original_b1", "original": "zhixing_b1_original_b1",
"resonance": "gold_brick_resonance",
} }
_SIGNAL_ORDER_SQL = ( _SIGNAL_ORDER_SQL = (
"CASE category " "CASE category "
+ " ".join( + " ".join(
f"WHEN '{category.value}' THEN {index}" f"WHEN '{category.value}' THEN {index}"
for index, category in enumerate(ZHIXING_B1_SIGNAL_ORDER) for index, category in enumerate(_SELECTION_SIGNAL_ORDER)
) )
+ f" ELSE {len(ZHIXING_B1_SIGNAL_ORDER)} END" + f" ELSE {len(_SELECTION_SIGNAL_ORDER)} END"
) )
_PATTERN_CASES_BY_ID = {definition.id: definition for definition in ZHIXING_B1_PATTERN_CASES} _PATTERN_CASES_BY_ID = {definition.id: definition for definition in ZHIXING_B1_PATTERN_CASES}
_STOCK_ORDER_SQL = { _STOCK_ORDER_SQL = {
@@ -120,7 +134,7 @@ class PostgresSelectionRunRepository(SelectionRunStore):
def prepare_run( def prepare_run(
self, self,
strategy: Literal["zhixing_b1"], strategy: SelectionStrategyName,
target_trade_date: date, target_trade_date: date,
source: SelectionExecutionSource, source: SelectionExecutionSource,
*, *,
@@ -332,7 +346,7 @@ class PostgresSelectionRunRepository(SelectionRunStore):
def get_latest_run( def get_latest_run(
self, self,
strategy: Literal["zhixing_b1"], strategy: SelectionStrategyName,
target_trade_date: date | None = None, target_trade_date: date | None = None,
*, *,
query: SelectionResultQuery | None = None, query: SelectionResultQuery | None = None,
@@ -463,6 +477,7 @@ class PostgresSelectionRunRepository(SelectionRunStore):
"no_signal", "no_signal",
"insufficient_history", "insufficient_history",
"missing_target_bar", "missing_target_bar",
"missing_turnover_rate",
"data_error", "data_error",
], ],
str(value[2]), str(value[2]),
@@ -476,7 +491,7 @@ class PostgresSelectionRunRepository(SelectionRunStore):
) )
return SelectionRun( return SelectionRun(
id=str(row[0]), id=str(row[0]),
strategy=cast(Literal["zhixing_b1"], str(row[1])), strategy=cast(SelectionStrategyName, str(row[1])),
target_trade_date=_as_date(row[2]), target_trade_date=_as_date(row[2]),
market_sync_batch_id=str(row[3]) if row[3] is not None else None, market_sync_batch_id=str(row[3]) if row[3] is not None else None,
status=cast(SelectionRunStatus, str(row[4])), status=cast(SelectionRunStatus, str(row[4])),
@@ -537,13 +552,22 @@ def _signal_from_row(row: tuple[object, ...]) -> SelectionSignal:
ts_code=str(row[0]), ts_code=str(row[0]),
name=str(row[1] or ""), name=str(row[1] or ""),
target_trade_date=_as_date(row[2]), target_trade_date=_as_date(row[2]),
strategy=cast(Literal["zhixing_b1"], str(row[3])), strategy=cast(SelectionStrategyName, str(row[3])),
category=ZhixingB1Category(str(row[4])), category=_signal_category(str(row[4])),
close=float(str(row[5])), close=float(str(row[5])),
details=_details(row[6]), details=_details(row[6]),
) )
def _signal_category(value: str) -> SelectionSignalCategory:
"""Map a persisted category for either supported selection strategy."""
try:
return ZhixingB1Category(value)
except ValueError:
return GoldBrickCategory(value)
def _stock_filter(query: SelectionResultQuery, run_id: str) -> tuple[str, list[object]]: def _stock_filter(query: SelectionResultQuery, run_id: str) -> tuple[str, list[object]]:
"""Build the signal predicate used to select distinct matching stocks. """Build the signal predicate used to select distinct matching stocks.
@@ -14,10 +14,16 @@ from zhixing_server.modules.selection.application.chart import (
SelectionChart, SelectionChart,
SelectionChartNotFound, SelectionChartNotFound,
) )
from zhixing_server.modules.selection.application.evaluate_gold_brick import (
EvaluateGoldBrick,
)
from zhixing_server.modules.selection.application.run import ( from zhixing_server.modules.selection.application.run import (
RunZhixingB1, RunZhixingB1,
) )
from zhixing_server.modules.selection.domain.models import SelectionSignal from zhixing_server.modules.selection.domain.models import (
SelectionSignal,
SelectionStrategyName,
)
from zhixing_server.modules.selection.domain.pattern_scoring import ( from zhixing_server.modules.selection.domain.pattern_scoring import (
PatternScore, PatternScore,
ZhixingB1PatternScorer, ZhixingB1PatternScorer,
@@ -45,7 +51,7 @@ selection_router = APIRouter()
_SELECTION_POOL_CACHE_LOCK = threading.Lock() _SELECTION_POOL_CACHE_LOCK = threading.Lock()
_SELECTION_POOL_CACHE: dict[tuple[str, int], SelectionPostgresPool] = {} _SELECTION_POOL_CACHE: dict[tuple[str, int], SelectionPostgresPool] = {}
StrategyValue = Literal["zhixing_b1"] StrategyValue = SelectionStrategyName
SelectionStatusValue = Literal[ SelectionStatusValue = Literal[
"no_data", "no_data",
"running", "running",
@@ -216,6 +222,7 @@ def get_selection_service(
return RunZhixingB1( return RunZhixingB1(
reader, reader,
store, store,
evaluators={"gold_brick": EvaluateGoldBrick(reader)},
pattern_case_loader=pattern_case_loader, pattern_case_loader=pattern_case_loader,
pattern_scorer=ZhixingB1PatternScorer(), pattern_scorer=ZhixingB1PatternScorer(),
pattern_scoring_enabled=settings.selection_pattern_scoring_enabled, pattern_scoring_enabled=settings.selection_pattern_scoring_enabled,
@@ -324,7 +331,9 @@ def get_selection_run(
page: Annotated[int, Query(ge=1)] = 1, page: Annotated[int, Query(ge=1)] = 1,
page_size: Annotated[int, Query(ge=1, le=100)] = 10, page_size: Annotated[int, Query(ge=1, le=100)] = 10,
search: Annotated[str | None, Query(max_length=100)] = None, search: Annotated[str | None, Query(max_length=100)] = None,
category: Literal["pullback", "oversold", "original"] | None = None, category: Literal[
"pullback", "oversold", "original", "resonance"
] | None = None,
sort: Literal["code", "score_desc", "score_asc"] = "code", sort: Literal["code", "score_desc", "score_asc"] = "code",
) -> SelectionResultsResponse: ) -> SelectionResultsResponse:
"""Return one run for asynchronous polling.""" """Return one run for asynchronous polling."""
@@ -347,7 +356,9 @@ def get_selection_results(
page: Annotated[int, Query(ge=1)] = 1, page: Annotated[int, Query(ge=1)] = 1,
page_size: Annotated[int, Query(ge=1, le=100)] = 10, page_size: Annotated[int, Query(ge=1, le=100)] = 10,
search: Annotated[str | None, Query(max_length=100)] = None, search: Annotated[str | None, Query(max_length=100)] = None,
category: Literal["pullback", "oversold", "original"] | None = None, category: Literal[
"pullback", "oversold", "original", "resonance"
] | None = None,
sort: Literal["code", "score_desc", "score_asc"] = "code", sort: Literal["code", "score_desc", "score_asc"] = "code",
) -> SelectionResultsResponse: ) -> SelectionResultsResponse:
"""Return the current persisted result for a strategy and optional date.""" """Return the current persisted result for a strategy and optional date."""
@@ -410,7 +421,13 @@ def _run_response(run: SelectionRun, *, query: SelectionResultQuery) -> Selectio
reason=item.reason, reason=item.reason,
) )
for item in run.items for item in run.items
if item.status in {"insufficient_history", "missing_target_bar", "data_error"} if item.status
in {
"insufficient_history",
"missing_target_bar",
"missing_turnover_rate",
"data_error",
}
], ],
stocks=[ stocks=[
SelectionStockResponse( SelectionStockResponse(
@@ -496,7 +513,7 @@ def _result_query(
page: int, page: int,
page_size: int, page_size: int,
search: str | None, search: str | None,
category: Literal["pullback", "oversold", "original"] | None, category: Literal["pullback", "oversold", "original", "resonance"] | None,
sort: Literal["code", "score_desc", "score_asc"], sort: Literal["code", "score_desc", "score_asc"],
) -> SelectionResultQuery: ) -> SelectionResultQuery:
"""Normalize HTTP query values before handing them to the selection port.""" """Normalize HTTP query values before handing them to the selection port."""
@@ -109,7 +109,7 @@ def test_reader_parameterizes_target_and_maps_left_join(monkeypatch: pytest.Monk
assert "trade_date <= %s" in cast(str, connection.query) assert "trade_date <= %s" in cast(str, connection.query)
def test_reader_batches_qfq_rows_by_stock_without_historical_basic_join() -> None: def test_reader_batches_qfq_rows_with_target_day_turnover_only() -> None:
connection = FakeConnection( connection = FakeConnection(
[ [
( (
@@ -131,6 +131,8 @@ def test_reader_batches_qfq_rows_by_stock_without_historical_basic_join() -> Non
"10", "10",
"10.8", "10.8",
"1200", "1200",
"1.2",
"100000",
), ),
( (
"000001.SZ", "000001.SZ",
@@ -160,10 +162,15 @@ def test_reader_batches_qfq_rows_by_stock_without_historical_basic_join() -> Non
date(2024, 1, 2), date(2024, 1, 2),
date(2024, 1, 3), date(2024, 1, 3),
] ]
assert histories[0].daily_basic == {} assert histories[0].daily_basic[date(2024, 1, 3)].turnover_rate == 1.2
assert connection.parameters == (["000001.SZ", "600000.SH"], date(2024, 1, 3)) assert connection.parameters == (
date(2024, 1, 3),
["000001.SZ", "600000.SH"],
date(2024, 1, 3),
)
assert "bar.ts_code = ANY(%s)" in cast(str, connection.query) assert "bar.ts_code = ANY(%s)" in cast(str, connection.query)
assert "market_daily_basic" not in cast(str, connection.query) assert "market_daily_basic" in cast(str, connection.query)
assert "basic.trade_date = %s" in cast(str, connection.query)
assert pool.opened == 1 assert pool.opened == 1
+1 -1
View File
@@ -77,7 +77,7 @@ export const routePresentation: Record<string, RoutePresentation> = {
"/selection": { "/selection": {
id: "selection", id: "selection",
breadcrumb: "研究工作台", breadcrumb: "研究工作台",
title: "知行 B1 执行结果", title: "选股策略执行结果",
}, },
"/sector-radar": { "/sector-radar": {
id: "sector-radar", id: "sector-radar",
@@ -1,13 +1,16 @@
export type SelectionStrategy = "zhixing_b1" export const selectionStrategies = ["zhixing_b1", "gold_brick"] as const
export type SelectionStrategy = (typeof selectionStrategies)[number]
export type SelectionCategoryFilter = export type SelectionCategoryFilter =
"all" | "pullback" | "oversold" | "original" "all" | "pullback" | "oversold" | "original" | "resonance"
export const selectionCategoryFilters = [ export const selectionCategoryFilters = [
"all", "all",
"pullback", "pullback",
"oversold", "oversold",
"original", "original",
"resonance",
] as const ] as const
export type SelectionSort = "code" | "score_desc" | "score_asc" export type SelectionSort = "code" | "score_desc" | "score_asc"
@@ -15,7 +15,10 @@ import {
import type { SelectionResults } from "../api/selection.types" import type { SelectionResults } from "../api/selection.types"
import { IncompleteEvaluationTable } from "./incomplete-evaluation-table" import { IncompleteEvaluationTable } from "./incomplete-evaluation-table"
import { getSelectionRunStatusPresentation } from "./selection-presentation" import {
getSelectionRunStatusPresentation,
getSelectionStrategyLabel,
} from "./selection-presentation"
interface ExecutionStatusDrawerProps { interface ExecutionStatusDrawerProps {
finalFocus: RefObject<HTMLElement | null> finalFocus: RefObject<HTMLElement | null>
@@ -44,7 +47,9 @@ export function ExecutionStatusDrawer({
showCloseButton={false} showCloseButton={false}
> >
<DialogHeader className="shrink-0 gap-1 pr-10"> <DialogHeader className="shrink-0 gap-1 pr-10">
<DialogTitle className="text-lg">知行 B1 执行状态</DialogTitle> <DialogTitle className="text-lg">
{getSelectionStrategyLabel(result.strategy)}执行状态
</DialogTitle>
<DialogDescription> <DialogDescription>
目标交易日:{tradeDate};当前状态:{statusLabel};未完成评估: 目标交易日:{tradeDate};当前状态:{statusLabel};未完成评估:
{result.failures.length} 条。 {result.failures.length} 条。
@@ -8,6 +8,7 @@ const failureStatusLabels: Record<string, string> = {
data_error: "数据读取失败", data_error: "数据读取失败",
insufficient_history: "历史数据不足", insufficient_history: "历史数据不足",
missing_target_bar: "缺少目标交易日行情", missing_target_bar: "缺少目标交易日行情",
missing_turnover_rate: "缺少目标交易日换手率",
} }
export function IncompleteEvaluationTable({ export function IncompleteEvaluationTable({
@@ -2,6 +2,7 @@ import type {
SelectionCategoryFilter, SelectionCategoryFilter,
SelectionRunStatus, SelectionRunStatus,
SelectionSignal, SelectionSignal,
SelectionStrategy,
SelectionStockResult, SelectionStockResult,
} from "../api/selection.types" } from "../api/selection.types"
@@ -20,6 +21,7 @@ export const selectionRunStatusPresentation: Record<
} }
export const categoryLabels: Record<string, string> = { export const categoryLabels: Record<string, string> = {
gold_brick_resonance: "金砖共振",
zhixing_b1_extreme_volume: "超卖超缩量 B", zhixing_b1_extreme_volume: "超卖超缩量 B",
zhixing_b1_original_b1: "原始 B1", zhixing_b1_original_b1: "原始 B1",
zhixing_b1_oversold_turn: "超卖缩量拐头 B", zhixing_b1_oversold_turn: "超卖缩量拐头 B",
@@ -30,18 +32,35 @@ export const categoryLabels: Record<string, string> = {
} }
const detailLabels: Record<string, string> = { const detailLabels: Record<string, string> = {
brick_chart: "砖型图",
brick_length: "砖柱变化",
daily_amplitude: "日振幅", daily_amplitude: "日振幅",
daily_change: "日涨跌幅", daily_change: "日涨跌幅",
j: "J 值", j: "J 值",
macd: "MACD", macd: "MACD",
rsi: "RSI", rsi: "RSI",
resonance_type: "共振条件",
signal: "信号",
sub_signal: "命中信号", sub_signal: "命中信号",
trend_white: "知行白线", trend_white: "知行白线",
trend_yellow: "知行黄线", trend_yellow: "知行黄线",
turnover_rate: "换手率",
upper_shadow_strength: "上影线强度",
volume: "成交量", volume: "成交量",
x_momentum: "X 动能",
yellow_column: "黄柱",
} }
const percentageDetailKeys = new Set(["daily_amplitude", "daily_change"]) const percentageDetailKeys = new Set([
"daily_amplitude",
"daily_change",
"turnover_rate",
])
const strategyLabels: Record<SelectionStrategy, string> = {
gold_brick: "金砖共振",
zhixing_b1: "知行 B1",
}
export type SignalCategoryFilter = SelectionCategoryFilter export type SignalCategoryFilter = SelectionCategoryFilter
@@ -53,8 +72,13 @@ export const signalCategoryOptions: ReadonlyArray<{
{ label: "回踩类", value: "pullback" }, { label: "回踩类", value: "pullback" },
{ label: "超卖类", value: "oversold" }, { label: "超卖类", value: "oversold" },
{ label: "原始 B1", value: "original" }, { label: "原始 B1", value: "original" },
{ label: "金砖共振", value: "resonance" },
] ]
export function getSelectionStrategyLabel(strategy: SelectionStrategy) {
return strategyLabels[strategy]
}
export function getCategoryLabel(category: string) { export function getCategoryLabel(category: string) {
return categoryLabels[category] ?? category return categoryLabels[category] ?? category
} }
@@ -151,6 +175,9 @@ export function matchesCategory(
} }
export function categoryToneClass(category: string) { export function categoryToneClass(category: string) {
if (category.includes("gold_brick")) {
return "border-warning/30 bg-warning/10 text-warning"
}
if (category.includes("pullback")) { if (category.includes("pullback")) {
return "border-warning/30 bg-warning/10 text-warning" return "border-warning/30 bg-warning/10 text-warning"
} }
@@ -7,7 +7,10 @@ import { Card } from "@/shared/ui/card"
import type { SelectionStockResult } from "../api/selection.types" import type { SelectionStockResult } from "../api/selection.types"
import { PatternScoreDetails } from "./pattern-score" import { PatternScoreDetails } from "./pattern-score"
import { PatternCaseImage } from "./pattern-case-image" import { PatternCaseImage } from "./pattern-case-image"
import { getStockBasicMetrics } from "./selection-presentation" import {
getSelectionStrategyLabel,
getStockBasicMetrics,
} from "./selection-presentation"
const SelectionChart = lazy(() => const SelectionChart = lazy(() =>
import("./selection-chart").then((module) => ({ import("./selection-chart").then((module) => ({
@@ -49,7 +52,11 @@ export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
label: "目标交易日", label: "目标交易日",
value: stock.target_trade_date, value: stock.target_trade_date,
}, },
{ key: "strategy", label: "策略", value: "知行 B1" }, {
key: "strategy",
label: "策略",
value: getSelectionStrategyLabel(stock.strategy),
},
] ]
return ( return (
@@ -1,5 +1,5 @@
import { AlertTriangle, Play, RefreshCw, Search } from "lucide-react" import { AlertTriangle, Play, RefreshCw, Search } from "lucide-react"
import { useSearch } from "@tanstack/react-router" import { useNavigate, useSearch } from "@tanstack/react-router"
import { useRef, useState, type RefObject } from "react" import { useRef, useState, type RefObject } from "react"
import { PageLayout } from "@/app/layout/page-layout" import { PageLayout } from "@/app/layout/page-layout"
@@ -11,8 +11,10 @@ import {
} from "@/features/selection/api/selection.query" } from "@/features/selection/api/selection.query"
import { import {
selectionResultPageSize, selectionResultPageSize,
selectionStrategies,
type SelectionResults, type SelectionResults,
type SelectionResultsQuery, type SelectionResultsQuery,
type SelectionStrategy,
} from "@/features/selection/api/selection.types" } from "@/features/selection/api/selection.types"
import { Button } from "@/shared/ui/button" import { Button } from "@/shared/ui/button"
import { import {
@@ -43,20 +45,27 @@ import { Skeleton } from "@/shared/ui/skeleton"
import { ExecutionStatusDrawer } from "../components/execution-status-drawer" import { ExecutionStatusDrawer } from "../components/execution-status-drawer"
import { ExecutionStatusTrigger } from "../components/execution-status-trigger" import { ExecutionStatusTrigger } from "../components/execution-status-trigger"
import { getSelectionStrategyLabel } from "../components/selection-presentation"
import { SelectionResultsWorkbench } from "../components/selection-results-workbench" import { SelectionResultsWorkbench } from "../components/selection-results-workbench"
const STRATEGY = "zhixing_b1" as const const DEFAULT_STRATEGY: SelectionStrategy = "zhixing_b1"
const strategyOptions = selectionStrategies.map((value) => ({
label: getSelectionStrategyLabel(value),
value,
}))
export function SelectionResultsPage() { export function SelectionResultsPage() {
// undefined follows the persisted result; null means the user cleared the picker. // undefined follows the persisted result; null means the user cleared the picker.
const [targetTradeDate, setTargetTradeDate] = useState< const [targetTradeDate, setTargetTradeDate] = useState<
string | null | undefined string | null | undefined
>(undefined) >(undefined)
const [strategy, setStrategy] = useState<SelectionStrategy>(DEFAULT_STRATEGY)
const [activeRunId, setActiveRunId] = useState<string | null>(null) const [activeRunId, setActiveRunId] = useState<string | null>(null)
const [executionStatusDrawerOpen, setExecutionStatusDrawerOpen] = const [executionStatusDrawerOpen, setExecutionStatusDrawerOpen] =
useState(false) useState(false)
const [rerunDialogOpen, setRerunDialogOpen] = useState(false) const [rerunDialogOpen, setRerunDialogOpen] = useState(false)
const executionStatusTriggerRef = useRef<HTMLButtonElement>(null) const executionStatusTriggerRef = useRef<HTMLButtonElement>(null)
const navigate = useNavigate({ from: "/selection" })
const search = useSearch({ from: "/_workspace/selection" }) const search = useSearch({ from: "/_workspace/selection" })
const resultQuery: Omit<SelectionResultsQuery, "page"> = { const resultQuery: Omit<SelectionResultsQuery, "page"> = {
pageSize: selectionResultPageSize, pageSize: selectionResultPageSize,
@@ -66,7 +75,7 @@ export function SelectionResultsPage() {
} }
const results = useSelectionResults( const results = useSelectionResults(
STRATEGY, strategy,
targetTradeDate || undefined, targetTradeDate || undefined,
resultQuery, resultQuery,
) )
@@ -120,7 +129,7 @@ export function SelectionResultsPage() {
trigger.mutate( trigger.mutate(
{ {
request: { request: {
strategy: STRATEGY, strategy,
target_trade_date: selectedTargetTradeDate, target_trade_date: selectedTargetTradeDate,
rerun, rerun,
}, },
@@ -136,6 +145,21 @@ export function SelectionResultsPage() {
trigger.reset() trigger.reset()
} }
function handleStrategyChange(value: string) {
const nextStrategy = selectionStrategies.find(
(candidate) => candidate === value,
)
if (!nextStrategy || nextStrategy === strategy) return
setStrategy(nextStrategy)
setExecutionStatusDrawerOpen(false)
setActiveRunId(null)
setRerunDialogOpen(false)
trigger.reset()
void navigate({
search: (previous) => ({ ...previous, category: "all" }),
})
}
return ( return (
<> <>
<PageLayout <PageLayout
@@ -148,8 +172,10 @@ export function SelectionResultsPage() {
onDateChange={handleDateChange} onDateChange={handleDateChange}
onExecute={handleExecute} onExecute={handleExecute}
onOpenExecutionStatus={() => setExecutionStatusDrawerOpen(true)} onOpenExecutionStatus={() => setExecutionStatusDrawerOpen(true)}
onStrategyChange={handleStrategyChange}
result={displayedResult} result={displayedResult}
selectedTargetTradeDate={selectedTargetTradeDate} selectedTargetTradeDate={selectedTargetTradeDate}
strategy={strategy}
triggerError={trigger.isError} triggerError={trigger.isError}
triggerRef={executionStatusTriggerRef} triggerRef={executionStatusTriggerRef}
/> />
@@ -168,7 +194,7 @@ export function SelectionResultsPage() {
{!isRunning && {!isRunning &&
!hasQueryError && !hasQueryError &&
displayedResult?.status === "no_data" ? ( displayedResult?.status === "no_data" ? (
<NoDataState /> <NoDataState strategy={strategy} />
) : null} ) : null}
{!isRunning && {!isRunning &&
!hasQueryError && !hasQueryError &&
@@ -196,8 +222,9 @@ export function SelectionResultsPage() {
<DialogHeader> <DialogHeader>
<DialogTitle>确认重新执行策略?</DialogTitle> <DialogTitle>确认重新执行策略?</DialogTitle>
<DialogDescription> <DialogDescription>
重新执行前会清空 {selectedTargetTradeDate} 的知行 B1 重新执行前会清空 {selectedTargetTradeDate} 的「
旧结果,再重新计算当前有效股票池。 旧结果清空后无法恢复。 {getSelectionStrategyLabel(strategy)}
」旧结果,再重新计算当前有效股票池。旧结果清空后无法恢复。
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
@@ -241,8 +268,10 @@ interface ExecutionToolbarProps {
onDateChange: (value: Date | undefined) => void onDateChange: (value: Date | undefined) => void
onExecute: () => void onExecute: () => void
onOpenExecutionStatus: () => void onOpenExecutionStatus: () => void
onStrategyChange: (value: string) => void
result: SelectionResults | undefined result: SelectionResults | undefined
selectedTargetTradeDate: string selectedTargetTradeDate: string
strategy: SelectionStrategy
triggerError: boolean triggerError: boolean
triggerRef: RefObject<HTMLButtonElement | null> triggerRef: RefObject<HTMLButtonElement | null>
} }
@@ -255,8 +284,10 @@ function ExecutionToolbar({
onDateChange, onDateChange,
onExecute, onExecute,
onOpenExecutionStatus, onOpenExecutionStatus,
onStrategyChange,
result, result,
selectedTargetTradeDate, selectedTargetTradeDate,
strategy,
triggerError, triggerError,
triggerRef, triggerRef,
}: ExecutionToolbarProps) { }: ExecutionToolbarProps) {
@@ -275,9 +306,12 @@ function ExecutionToolbar({
<div className="space-y-1 text-xs font-medium"> <div className="space-y-1 text-xs font-medium">
<span className="block">策略</span> <span className="block">策略</span>
<Select <Select
disabled disabled={isRunning}
items={[{ label: "知行 B1", value: STRATEGY }]} items={strategyOptions}
value={STRATEGY} onValueChange={(value) => {
if (typeof value === "string") onStrategyChange(value)
}}
value={strategy}
> >
<SelectTrigger <SelectTrigger
aria-label="策略" aria-label="策略"
@@ -287,7 +321,11 @@ function ExecutionToolbar({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectItem value={STRATEGY}>知行 B1</SelectItem> {strategyOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -418,7 +456,7 @@ function QueryError() {
) )
} }
function NoDataState() { function NoDataState({ strategy }: { strategy: SelectionStrategy }) {
return ( return (
<Card> <Card>
<CardHeader className="gap-1.5 p-4"> <CardHeader className="gap-1.5 p-4">
@@ -427,7 +465,8 @@ function NoDataState() {
暂无策略结果 暂无策略结果
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
请选择目标交易日并执行知行 B1,完成后这里会显示持久化结果。 请选择目标交易日并执行{getSelectionStrategyLabel(strategy)}
,完成后这里会显示持久化结果。
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
</Card> </Card>