diff --git a/zhixing-server/src/zhixing_server/modules/selection/domain/runs.py b/zhixing-server/src/zhixing_server/modules/selection/domain/runs.py index 6452d94..9ccf519 100644 --- a/zhixing-server/src/zhixing_server/modules/selection/domain/runs.py +++ b/zhixing-server/src/zhixing_server/modules/selection/domain/runs.py @@ -79,7 +79,7 @@ class SelectionRun: finished_at: datetime | None = None items: tuple[SelectionRunItem, ...] = field(default_factory=tuple) signals: tuple[SelectionSignal, ...] = field(default_factory=tuple) - signals_total: int | None = None + stocks_total: int | None = None class SelectionRunError(RuntimeError): diff --git a/zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_runs.py b/zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_runs.py index c694dc2..71f9825 100644 --- a/zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_runs.py +++ b/zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_runs.py @@ -284,7 +284,7 @@ class PostgresSelectionRunRepository(SelectionRunStore): *, query: SelectionResultQuery | None = None, ) -> SelectionRun | None: - """Read one run with filtered, paged signals and item failures.""" + """Read one run with filtered, stock-paged signals and item failures.""" try: with self._connection() as connection: @@ -361,27 +361,47 @@ class PostgresSelectionRunRepository(SelectionRunStore): """, (run_id,), ).fetchall() - signal_filter, signal_parameters = _signal_filter(query, run_id) - signal_total_row = connection.execute( - f"SELECT COUNT(*) FROM selection_signal WHERE {signal_filter}", - tuple(signal_parameters), + stock_filter, stock_parameters = _stock_filter(query, run_id) + stock_total_row = connection.execute( + f"SELECT COUNT(DISTINCT ts_code) FROM selection_signal WHERE {stock_filter}", + tuple(stock_parameters), ).fetchone() - signal_total = int(signal_total_row[0] or 0) if signal_total_row else 0 + stock_total = int(stock_total_row[0] or 0) if stock_total_row else 0 offset = (query.page - 1) * query.page_size - signal_rows = connection.execute( - f""" - SELECT - ts_code, name, target_trade_date, strategy, category, close, details - FROM selection_signal - WHERE {signal_filter} - ORDER BY ts_code, {_SIGNAL_ORDER_SQL} - LIMIT %s OFFSET %s - """, - tuple((*signal_parameters, query.page_size, offset)), - ).fetchall() + stock_rows = cast( + list[tuple[object, ...]], + connection.execute( + f""" + SELECT DISTINCT ts_code + FROM selection_signal + WHERE {stock_filter} + ORDER BY ts_code + LIMIT %s OFFSET %s + """, + tuple((*stock_parameters, query.page_size, offset)), + ).fetchall(), + ) + stock_codes = [str(value[0]) for value in stock_rows] + signal_rows = ( + cast( + list[tuple[object, ...]], + connection.execute( + f""" + SELECT + ts_code, name, target_trade_date, strategy, category, close, details + FROM selection_signal + WHERE run_id = %s AND ts_code = ANY(%s) + ORDER BY ts_code, {_SIGNAL_ORDER_SQL} + """, + (run_id, stock_codes), + ).fetchall(), + ) + if stock_codes + else [] + ) signals = tuple( sorted( - (_signal_from_row(cast(tuple[object, ...], value)) for value in signal_rows), + (_signal_from_row(value) for value in signal_rows), key=lambda signal: ( signal.ts_code, _SIGNAL_PRIORITY.get(signal.category, len(_SIGNAL_PRIORITY)), @@ -430,7 +450,7 @@ class PostgresSelectionRunRepository(SelectionRunStore): finished_at=cast(datetime | None, row[15]), items=items, signals=signals, - signals_total=signal_total, + stocks_total=stock_total, ) @contextmanager @@ -481,8 +501,13 @@ def _signal_from_row(row: tuple[object, ...]) -> SelectionSignal: ) -def _signal_filter(query: SelectionResultQuery, run_id: str) -> tuple[str, list[object]]: - """Build the parameterized WHERE clause shared by count and page reads.""" +def _stock_filter(query: SelectionResultQuery, run_id: str) -> tuple[str, list[object]]: + """Build the signal predicate used to select distinct matching stocks. + + A category narrows which stocks qualify for the page. Once a stock + qualifies, the repository loads every signal for that stock so callers + can present all independently persisted categories together. + """ clauses = ["run_id = %s"] parameters: list[object] = [run_id] diff --git a/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py b/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py index bed0c8d..af51df5 100644 --- a/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py +++ b/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py @@ -12,6 +12,7 @@ from zhixing_server.bootstrap.config import Settings, get_settings from zhixing_server.modules.selection.application.run import ( RunZhixingB1, ) +from zhixing_server.modules.selection.domain.models import SelectionSignal from zhixing_server.modules.selection.domain.runs import ( SelectionRerunRequired, SelectionResultQuery, @@ -93,8 +94,25 @@ def _empty_signals() -> list[SelectionSignalResponse]: return [] +class SelectionStockResponse(BaseModel): + """One selected stock with all independently persisted signals.""" + + ts_code: str + name: str + target_trade_date: date + strategy: StrategyValue + close: float + signals: list[SelectionSignalResponse] = Field(default_factory=_empty_signals) + + +def _empty_stocks() -> list[SelectionStockResponse]: + """Create a typed default stock-result list.""" + + return [] + + class SelectionResultsResponse(BaseModel): - """Batch summary and one filtered page of signals consumed by the Web feature.""" + """Batch summary and one filtered page of selected stocks.""" strategy: StrategyValue target_trade_date: date | None @@ -114,9 +132,9 @@ class SelectionResultsResponse(BaseModel): finished_at: datetime | None = None page: int = Field(default=1, ge=1) page_size: int = Field(default=10, ge=1, le=100) - signals_total: int = Field(default=0, ge=0) + stocks_total: int = Field(default=0, ge=0) failures: list[SelectionFailureResponse] = Field(default_factory=_empty_failures) - signals: list[SelectionSignalResponse] = Field(default_factory=_empty_signals) + stocks: list[SelectionStockResponse] = Field(default_factory=_empty_stocks) def get_selection_service( @@ -247,7 +265,7 @@ def get_selection_results( coverage=0, page=query.page, page_size=query.page_size, - signals_total=0, + stocks_total=0, ) return _run_response(run, query=query) @@ -255,6 +273,10 @@ def get_selection_results( def _run_response(run: SelectionRun, *, query: SelectionResultQuery) -> SelectionResultsResponse: """Translate a domain run without exposing storage-specific fields.""" + signals_by_stock: dict[str, list[SelectionSignalResponse]] = {} + for signal in run.signals: + signals_by_stock.setdefault(signal.ts_code, []).append(_signal_response(signal)) + return SelectionResultsResponse( strategy=run.strategy, target_trade_date=run.target_trade_date, @@ -274,7 +296,9 @@ def _run_response(run: SelectionRun, *, query: SelectionResultQuery) -> Selectio finished_at=run.finished_at, page=query.page, page_size=query.page_size, - signals_total=run.signals_total if run.signals_total is not None else run.signal_count, + stocks_total=( + run.stocks_total if run.stocks_total is not None else run.selected_stock_count + ), failures=[ SelectionFailureResponse( ts_code=item.ts_code, @@ -285,21 +309,34 @@ def _run_response(run: SelectionRun, *, query: SelectionResultQuery) -> Selectio for item in run.items if item.status in {"insufficient_history", "missing_target_bar", "data_error"} ], - signals=[ - SelectionSignalResponse( - ts_code=signal.ts_code, - name=signal.name, - target_trade_date=signal.target_trade_date, - strategy=signal.strategy, - category=signal.category.value, - close=signal.close, - details=dict(signal.details), + stocks=[ + SelectionStockResponse( + ts_code=signals[0].ts_code, + name=signals[0].name, + target_trade_date=signals[0].target_trade_date, + strategy=signals[0].strategy, + close=signals[0].close, + signals=signals, ) - for signal in run.signals + for signals in signals_by_stock.values() ], ) +def _signal_response(signal: SelectionSignal) -> SelectionSignalResponse: + """Map a domain signal while preserving its category-specific details.""" + + return SelectionSignalResponse( + ts_code=signal.ts_code, + name=signal.name, + target_trade_date=signal.target_trade_date, + strategy=signal.strategy, + category=signal.category.value, + close=signal.close, + details=dict(signal.details), + ) + + def _result_query( page: int, page_size: int, @@ -330,6 +367,7 @@ __all__ = [ "SelectionResultsResponse", "SelectionRunAcceptedResponse", "SelectionRunRequest", + "SelectionStockResponse", "get_selection_service", "get_selection_postgres_pool", "selection_router", diff --git a/zhixing-server/tests/test_selection_http.py b/zhixing-server/tests/test_selection_http.py index c113776..f98196e 100644 --- a/zhixing-server/tests/test_selection_http.py +++ b/zhixing-server/tests/test_selection_http.py @@ -91,7 +91,7 @@ class FakeSelectionService: def _run(run_id: str, status: str) -> SelectionRun: - signal = SelectionSignal( + original_signal = SelectionSignal( ts_code="000001.SZ", name="平安银行", target_trade_date=TARGET, @@ -100,6 +100,15 @@ def _run(run_id: str, status: str) -> SelectionRun: close=10.5, details={"j": 12.0}, ) + pullback_signal = SelectionSignal( + ts_code="000001.SZ", + name="平安银行", + target_trade_date=TARGET, + strategy="zhixing_b1", + category=ZhixingB1Category.PULLBACK_WHITE, + close=10.5, + details={"j": 13.0, "rsi": 20.0}, + ) from zhixing_server.modules.selection.domain.runs import SelectionRunItem return SelectionRun( @@ -112,7 +121,7 @@ def _run(run_id: str, status: str) -> SelectionRun: eligible_count=1, evaluated_count=1, selected_stock_count=1, - signal_count=1, + signal_count=2, failed_count=0, coverage=Decimal("1"), items=( @@ -120,11 +129,12 @@ def _run(run_id: str, status: str) -> SelectionRun: ts_code="000001.SZ", name="平安银行", status="selected", - signal_count=1, - signals=(signal,), + signal_count=2, + signals=(original_signal, pullback_signal), ), ), - signals=(signal,), + signals=(original_signal, pullback_signal), + stocks_total=1, ) @@ -209,7 +219,7 @@ def test_query_returns_no_data_without_fabricating_a_result() -> None: assert response.status_code == 200 assert response.json()["status"] == "no_data" - assert response.json()["signals"] == [] + assert response.json()["stocks"] == [] def test_query_returns_persisted_signal_details() -> None: @@ -220,12 +230,22 @@ def test_query_returns_persisted_signal_details() -> None: assert response.status_code == 200 body = response.json() assert body["run_id"] == "run-http" - assert body["signal_count"] == 1 + assert body["signal_count"] == 2 assert body["page"] == 1 assert body["page_size"] == 10 - assert body["signals_total"] == 1 - assert body["signals"][0]["category"] == "zhixing_b1_original_b1" - assert body["signals"][0]["details"] == {"j": 12.0} + assert body["stocks_total"] == 1 + assert "signals_total" not in body + assert "signals" not in body + assert len(body["stocks"]) == 1 + assert body["stocks"][0]["ts_code"] == "000001.SZ" + assert [signal["category"] for signal in body["stocks"][0]["signals"]] == [ + "zhixing_b1_original_b1", + "zhixing_b1_pullback_white", + ] + assert [signal["details"] for signal in body["stocks"][0]["signals"]] == [ + {"j": 12.0}, + {"j": 13.0, "rsi": 20.0}, + ] def test_query_forwards_pagination_and_filters() -> None: @@ -269,4 +289,4 @@ def test_run_polling_returns_the_persisted_terminal_result() -> None: assert response.status_code == 200 assert response.json()["status"] == "success" - assert response.json()["signals"][0]["category"] == "zhixing_b1_original_b1" + assert response.json()["stocks"][0]["signals"][0]["category"] == ("zhixing_b1_original_b1") diff --git a/zhixing-server/tests/unit/selection/test_postgres_runs.py b/zhixing-server/tests/unit/selection/test_postgres_runs.py index b862b74..ceaf968 100644 --- a/zhixing-server/tests/unit/selection/test_postgres_runs.py +++ b/zhixing-server/tests/unit/selection/test_postgres_runs.py @@ -283,8 +283,10 @@ class LoadConnection: ) if "FROM selection_run_item" in query: return LoadResult(rows=[("000001.SZ", "平安银行", "selected", 2, None)]) - if "COUNT(*) FROM selection_signal" in query: + if "COUNT(DISTINCT ts_code) FROM selection_signal" in query: return LoadResult(row=(2,)) + if "SELECT DISTINCT ts_code" in query: + return LoadResult(rows=[("000001.SZ",)]) return LoadResult( rows=[ ( @@ -301,7 +303,7 @@ class LoadConnection: "平安银行", TARGET, "zhixing_b1", - ZHIXING_B1_SIGNAL_ORDER[0].value, + ZhixingB1Category.ORIGINAL_B1.value, Decimal("10.5"), {}, ), @@ -309,6 +311,16 @@ class LoadConnection: ) +class EmptyStockPageConnection(LoadConnection): + """Return a non-zero filtered total with no stocks on the requested page.""" + + def execute(self, query: str, parameters: tuple[object, ...]) -> "LoadResult": + if "SELECT DISTINCT ts_code" in query: + self.statements.append((query, parameters)) + return LoadResult(rows=[]) + return super().execute(query, parameters) + + class LoadResult: def __init__( self, @@ -326,7 +338,9 @@ class LoadResult: return self.rows -def test_get_run_orders_signals_by_formula_priority(monkeypatch: pytest.MonkeyPatch) -> None: +def test_get_run_pages_stocks_and_loads_all_signals_for_category_matches( + monkeypatch: pytest.MonkeyPatch, +) -> None: connection = LoadConnection() def connect(database_url: str) -> LoadConnection: @@ -337,7 +351,7 @@ def test_get_run_orders_signals_by_formula_priority(monkeypatch: pytest.MonkeyPa run = PostgresSelectionRunRepository("postgresql://test").get_run( "run-1", query=SelectionResultQuery( - page=2, + page=1, page_size=1, search="100%", category="pullback", @@ -346,20 +360,57 @@ def test_get_run_orders_signals_by_formula_priority(monkeypatch: pytest.MonkeyPa assert run is not None assert [signal.category for signal in run.signals] == [ - ZHIXING_B1_SIGNAL_ORDER[0], + ZhixingB1Category.ORIGINAL_B1, ZHIXING_B1_SIGNAL_ORDER[-1], ] + assert run.stocks_total == 2 count_query, count_parameters = next( (query, parameters) for query, parameters in connection.statements - if "COUNT(*) FROM selection_signal" in query + if "COUNT(DISTINCT ts_code) FROM selection_signal" in query ) assert "name ILIKE %s ESCAPE" in count_query assert count_parameters == ("run-1", "%100\\%%", "%100\\%%", "zhixing_b1_pullback_%") - page_query, page_parameters = next( + stock_page_query, page_parameters = next( (query, parameters) for query, parameters in connection.statements - if "LIMIT %s OFFSET %s" in query + if "SELECT DISTINCT ts_code" in query ) - assert "ORDER BY ts_code, CASE category" in page_query - assert page_parameters[-2:] == (1, 1) + assert "ORDER BY ts_code" in stock_page_query + assert page_parameters[-2:] == (1, 0) + signal_query, signal_parameters = next( + (query, parameters) + for query, parameters in connection.statements + if "ts_code = ANY(%s)" in query and "SELECT\n" in query + ) + assert "ORDER BY ts_code, CASE category" in signal_query + assert "category LIKE" not in signal_query + assert signal_parameters == ("run-1", ["000001.SZ"]) + + +def test_get_run_does_not_load_signals_for_an_empty_stock_page( + monkeypatch: pytest.MonkeyPatch, +) -> None: + connection = EmptyStockPageConnection() + + def connect(database_url: str) -> EmptyStockPageConnection: + assert database_url == "postgresql://test" + return connection + + monkeypatch.setattr(psycopg, "connect", connect) + run = PostgresSelectionRunRepository("postgresql://test").get_run( + "run-1", + query=SelectionResultQuery(page=3, page_size=1), + ) + + assert run is not None + assert run.stocks_total == 2 + assert run.signals == () + stock_page_query, stock_page_parameters = next( + (query, parameters) + for query, parameters in connection.statements + if "SELECT DISTINCT ts_code" in query + ) + assert "ORDER BY ts_code" in stock_page_query + assert stock_page_parameters[-2:] == (1, 2) + assert not any("ts_code = ANY(%s)" in query for query, _ in connection.statements) diff --git a/zhixing-web/src/features/selection/api/selection.types.ts b/zhixing-web/src/features/selection/api/selection.types.ts index 61cd8db..368d044 100644 --- a/zhixing-web/src/features/selection/api/selection.types.ts +++ b/zhixing-web/src/features/selection/api/selection.types.ts @@ -43,6 +43,15 @@ export interface SelectionSignal { details: Record } +export interface SelectionStockResult { + ts_code: string + name: string + target_trade_date: string + strategy: SelectionStrategy + close: number + signals: SelectionSignal[] +} + export interface SelectionFailure { ts_code: string name: string @@ -69,7 +78,7 @@ export interface SelectionResults { finished_at: string | null page: number page_size: number - signals_total: number + stocks_total: number failures: SelectionFailure[] - signals: SelectionSignal[] + stocks: SelectionStockResult[] } diff --git a/zhixing-web/src/features/selection/components/selection-presentation.ts b/zhixing-web/src/features/selection/components/selection-presentation.ts index 713fab9..5cb9d50 100644 --- a/zhixing-web/src/features/selection/components/selection-presentation.ts +++ b/zhixing-web/src/features/selection/components/selection-presentation.ts @@ -2,6 +2,7 @@ import type { SelectionCategoryFilter, SelectionRunStatus, SelectionSignal, + SelectionStockResult, } from "../api/selection.types" export type SelectionRunStatusPresentation = { @@ -73,6 +74,10 @@ export function getSignalKey(signal: SelectionSignal) { return `${signal.ts_code}-${signal.category}` } +export function getStockKey(stock: SelectionStockResult) { + return stock.ts_code +} + export function getSignalDetailItems(details: SelectionSignal["details"]) { return Object.entries(details).map(([key, value]) => ({ key, @@ -102,11 +107,11 @@ function formatSignalDetailValue( } export function matchesCategory( - signal: SelectionSignal, + stock: SelectionStockResult, category: SignalCategoryFilter, ) { if (category === "all") return true - return signal.category.includes(category) + return stock.signals.some((signal) => signal.category.includes(category)) } export function categoryToneClass(category: string) { diff --git a/zhixing-web/src/features/selection/components/selection-results-workbench.tsx b/zhixing-web/src/features/selection/components/selection-results-workbench.tsx index e7ad1ea..0b2bf50 100644 --- a/zhixing-web/src/features/selection/components/selection-results-workbench.tsx +++ b/zhixing-web/src/features/selection/components/selection-results-workbench.tsx @@ -12,12 +12,15 @@ import { SelectValue, } from "@/shared/ui/select" -import type { SelectionResults, SelectionSignal } from "../api/selection.types" +import type { + SelectionResults, + SelectionStockResult, +} from "../api/selection.types" import { ExecutionStatusTrigger } from "./execution-status-trigger" import { SignalDetailPanel } from "./signal-detail-panel" import { SignalRecordList } from "./signal-record-list" import { - getSignalKey, + getStockKey, matchesCategory, signalCategoryOptions, type SignalCategoryFilter, @@ -48,26 +51,26 @@ export function SelectionResultsWorkbench({ new Set(), ) - const visibleSignals = useMemo(() => { + const visibleStocks = useMemo(() => { const normalized = query.trim().toLowerCase() - return result.signals.filter((signal) => { + return result.stocks.filter((stock) => { const matchesQuery = normalized.length === 0 || - signal.name.toLowerCase().includes(normalized) || - signal.ts_code.toLowerCase().includes(normalized) - return matchesQuery && matchesCategory(signal, category) + stock.name.toLowerCase().includes(normalized) || + stock.ts_code.toLowerCase().includes(normalized) + return matchesQuery && matchesCategory(stock, category) }) - }, [category, query, result.signals]) + }, [category, query, result.stocks]) const page = search.page const pageSize = search.pageSize - const signalsTotal = result.signals_total ?? result.signal_count - const pageCount = Math.max(1, Math.ceil(signalsTotal / pageSize)) + const stocksTotal = result.stocks_total ?? result.selected_stock_count + const pageCount = Math.max(1, Math.ceil(stocksTotal / pageSize)) const currentPage = Math.min(page, pageCount) - const currentSignals = visibleSignals - const selectedSignal = - currentSignals.find((signal) => getSignalKey(signal) === selectedKey) ?? - currentSignals[0] + const currentStocks = visibleStocks + const selectedStock = + currentStocks.find((stock) => getStockKey(stock) === selectedKey) ?? + currentStocks[0] useEffect(() => { if (page > pageCount) { void navigate({ @@ -93,8 +96,8 @@ export function SelectionResultsWorkbench({ updateSearch({ category: value, page: 1 }) } - function handleToggleExpanded(signal: SelectionSignal) { - const key = getSignalKey(signal) + function handleToggleExpanded(stock: SelectionStockResult) { + const key = getStockKey(stock) setExpandedKeys((previous) => { const next = new Set(previous) if (next.has(key)) next.delete(key) @@ -108,7 +111,7 @@ export function SelectionResultsWorkbench({
handleQueryChange(event.target.value)} placeholder="搜索股票名称或代码" @@ -130,7 +133,7 @@ export function SelectionResultsWorkbench({ > - + {signalCategoryOptions.map((option) => ( @@ -141,7 +144,7 @@ export function SelectionResultsWorkbench({ - 筛选结果 {signalsTotal} 条 + 筛选结果 {stocksTotal} 只 setSelectedKey(getSignalKey(signal))} - selectedKey={selectedSignal ? getSignalKey(selectedSignal) : null} - signals={currentSignals} + onSelect={(stock) => setSelectedKey(getStockKey(stock))} + selectedKey={selectedStock ? getStockKey(selectedStock) : null} + stocks={currentStocks} /> setSelectedKey(getSignalKey(signal))} + onSelect={(stock) => setSelectedKey(getStockKey(stock))} onToggleExpanded={handleToggleExpanded} - signals={currentSignals} + stocks={currentStocks} />
@@ -173,11 +176,11 @@ export function SelectionResultsWorkbench({ page={currentPage} pageSize={pageSize} pageSizeOptions={PAGE_SIZE_OPTIONS} - total={signalsTotal} + total={stocksTotal} />
- + ) } diff --git a/zhixing-web/src/features/selection/components/signal-detail-panel.tsx b/zhixing-web/src/features/selection/components/signal-detail-panel.tsx index 607070c..89886e9 100644 --- a/zhixing-web/src/features/selection/components/signal-detail-panel.tsx +++ b/zhixing-web/src/features/selection/components/signal-detail-panel.tsx @@ -3,16 +3,16 @@ import { Database, Search } from "lucide-react" import { Badge } from "@/shared/ui/badge" import { Card } from "@/shared/ui/card" -import type { SelectionSignal } from "../api/selection.types" +import type { SelectionStockResult } from "../api/selection.types" import { categoryToneClass, getCategoryLabel } from "./selection-presentation" import { SignalDetails } from "./signal-details" interface SignalDetailPanelProps { - signal: SelectionSignal | undefined + stock: SelectionStockResult | undefined } -export function SignalDetailPanel({ signal }: SignalDetailPanelProps) { - if (!signal) { +export function SignalDetailPanel({ stock }: SignalDetailPanelProps) { + if (!stock) { return ( @@ -38,15 +38,15 @@ export function SignalDetailPanel({ signal }: SignalDetailPanelProps) { className="hidden min-h-0 overflow-auto rounded-md border border-border/80 bg-card p-3 md:block" >

- Selected signal + Selected stock

- {signal.name} + {stock.name}

- {signal.ts_code} + {stock.ts_code}

- ¥ {signal.close.toFixed(2)} + ¥ {stock.close.toFixed(2)}

- - {getCategoryLabel(signal.category)} - +
+ {stock.signals.map((signal) => ( + + {getCategoryLabel(signal.category)} + + ))} +
目标交易日
- {signal.target_trade_date} + {stock.target_trade_date}
@@ -86,7 +91,16 @@ export function SignalDetailPanel({ signal }: SignalDetailPanelProps) { > 关键指标 - +
+ {stock.signals.map((signal) => ( +
+

+ {getCategoryLabel(signal.category)} +

+ +
+ ))} +
) diff --git a/zhixing-web/src/features/selection/components/signal-record-list.tsx b/zhixing-web/src/features/selection/components/signal-record-list.tsx index 3a2627a..351c38b 100644 --- a/zhixing-web/src/features/selection/components/signal-record-list.tsx +++ b/zhixing-web/src/features/selection/components/signal-record-list.tsx @@ -3,29 +3,29 @@ import { ChevronDown, ChevronUp } from "lucide-react" import { Badge } from "@/shared/ui/badge" import { Button } from "@/shared/ui/button" -import type { SelectionSignal } from "../api/selection.types" +import type { SelectionStockResult } from "../api/selection.types" import { categoryToneClass, getCategoryLabel, getSignalJValue, - getSignalKey, + getStockKey, } from "./selection-presentation" import { SignalDetails } from "./signal-details" interface SignalRecordListProps { - signals: SelectionSignal[] + stocks: SelectionStockResult[] expandedKeys: ReadonlySet - onSelect: (signal: SelectionSignal) => void - onToggleExpanded: (signal: SelectionSignal) => void + onSelect: (stock: SelectionStockResult) => void + onToggleExpanded: (stock: SelectionStockResult) => void } export function SignalRecordList({ - signals, + stocks, expandedKeys, onSelect, onToggleExpanded, }: SignalRecordListProps) { - if (signals.length === 0) { + if (stocks.length === 0) { return (
没有符合当前筛选条件的信号。 @@ -35,46 +35,53 @@ export function SignalRecordList({ return (
- {signals.map((signal) => { - const key = getSignalKey(signal) + {stocks.map((stock) => { + const key = getStockKey(stock) const expanded = expandedKeys.has(key) return (
- - {getCategoryLabel(signal.category)} - +
+ {stock.signals.map((signal) => ( + + {getCategoryLabel(signal.category)} + + ))} +
J  - {getSignalJValue(signal.details)} + {stock.signals + .map((signal) => getSignalJValue(signal.details)) + .join(" / ")}
{expanded ? ( - +
+ {stock.signals.map((signal) => ( +
+

+ {getCategoryLabel(signal.category)} +

+ +
+ ))} +
) : null}
) diff --git a/zhixing-web/src/features/selection/components/signal-table.tsx b/zhixing-web/src/features/selection/components/signal-table.tsx index 856c004..267dab7 100644 --- a/zhixing-web/src/features/selection/components/signal-table.tsx +++ b/zhixing-web/src/features/selection/components/signal-table.tsx @@ -2,32 +2,32 @@ import type { KeyboardEvent } from "react" import { Badge } from "@/shared/ui/badge" -import type { SelectionSignal } from "../api/selection.types" +import type { SelectionStockResult } from "../api/selection.types" import { categoryToneClass, getCategoryLabel, getSignalJValue, - getSignalKey, + getStockKey, } from "./selection-presentation" interface SignalTableProps { - signals: SelectionSignal[] + stocks: SelectionStockResult[] selectedKey: string | null - onSelect: (signal: SelectionSignal) => void + onSelect: (stock: SelectionStockResult) => void } export function SignalTable({ - signals, + stocks, selectedKey, onSelect, }: SignalTableProps) { function handleRowKeyDown( event: KeyboardEvent, - signal: SelectionSignal, + stock: SelectionStockResult, ) { if (event.key !== "Enter" && event.key !== " ") return event.preventDefault() - onSelect(signal) + onSelect(stock) } return ( @@ -43,38 +43,47 @@ export function SignalTable({ - {signals.length > 0 ? ( - signals.map((signal) => { - const selected = selectedKey === getSignalKey(signal) + {stocks.length > 0 ? ( + stocks.map((stock) => { + const selected = selectedKey === getStockKey(stock) return ( onSelect(signal)} - onKeyDown={(event) => handleRowKeyDown(event, signal)} + onClick={() => onSelect(stock)} + onKeyDown={(event) => handleRowKeyDown(event, stock)} tabIndex={0} > -

{signal.name}

+

{stock.name}

- {signal.ts_code} + {stock.ts_code}

- - {getCategoryLabel(signal.category)} - +
+ {stock.signals.map((signal) => ( + + {getCategoryLabel(signal.category)} + + ))} +
- {getSignalJValue(signal.details)} + {stock.signals.map((signal) => ( + + {getSignalJValue(signal.details)} + + ))} - ¥ {signal.close.toFixed(2)} + ¥ {stock.close.toFixed(2)} ) diff --git a/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx b/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx index 5116d65..f12442d 100644 --- a/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx +++ b/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx @@ -45,22 +45,31 @@ const selectedResult: SelectionResults = { run_id: "run-1", selected_stock_count: 1, signal_count: 2, - signals_total: 2, - signals: [ + stocks_total: 1, + stocks: [ { - category: "zhixing_b1_original_b1", close: 10.5, - details: { j: 12, rsi: 20 }, - name: "平安银行", - strategy: "zhixing_b1", - target_trade_date: "2026-08-08", - ts_code: "000001.SZ", - }, - { - category: "zhixing_b1_pullback_white", - close: 10.5, - details: { j: 12 }, name: "平安银行", + signals: [ + { + category: "zhixing_b1_original_b1", + close: 10.5, + details: { j: 12, rsi: 20 }, + name: "平安银行", + strategy: "zhixing_b1", + target_trade_date: "2026-08-08", + ts_code: "000001.SZ", + }, + { + category: "zhixing_b1_pullback_white", + close: 10.5, + details: { j: 13 }, + name: "平安银行", + strategy: "zhixing_b1", + target_trade_date: "2026-08-08", + ts_code: "000001.SZ", + }, + ], strategy: "zhixing_b1", target_trade_date: "2026-08-08", ts_code: "000001.SZ", @@ -113,14 +122,15 @@ describe("SelectionResultsPage", () => { ).not.toBeInTheDocument() expect(within(table).getByText("原始 B1")).toBeInTheDocument() expect(within(table).getByText("回踩白线 B")).toBeInTheDocument() - expect(within(table).getAllByText("12")).toHaveLength(2) - expect(within(table).getAllByText("平安银行")).toHaveLength(2) + expect(within(table).getByText("12")).toBeInTheDocument() + expect(within(table).getByText("13")).toBeInTheDocument() + expect(within(table).getByText("平安银行")).toBeInTheDocument() const detailPanel = screen.getByRole("complementary", { name: "当前股票详情", }) expect(within(detailPanel).getByText("关键指标")).toBeInTheDocument() - expect(within(detailPanel).getByText("J 值")).toBeInTheDocument() + expect(within(detailPanel).getAllByText("J 值")).toHaveLength(2) expect(within(detailPanel).getByText("RSI")).toBeInTheDocument() }) @@ -220,8 +230,8 @@ describe("SelectionResultsPage", () => { data: { ...selectedResult, signal_count: 0, - signals: [], - signals_total: 0, + stocks: [], + stocks_total: 0, }, isError: false, isPending: false, @@ -260,7 +270,7 @@ describe("SelectionResultsPage", () => { }, ], signal_count: 0, - signals: [], + stocks: [], status: "failed", }, isError: false, @@ -293,6 +303,9 @@ describe("SelectionResultsPage", () => { fireEvent.click(categoryTrigger) const oversoldOption = await screen.findByRole("option", { name: "超卖类" }) + const content = oversoldOption.closest('[data-slot="select-content"]') + expect(content).toHaveAttribute("data-align-trigger", "false") + expect(content?.parentElement).toHaveAttribute("data-align", "start") fireEvent.pointerDown(oversoldOption, { pointerType: "mouse" }) fireEvent.click(oversoldOption) @@ -316,18 +329,26 @@ describe("SelectionResultsPage", () => { }) it("filters signals, updates the detail panel, and expands mobile details", () => { - const extraSignal = { - ...selectedResult.signals[0], - category: "zhixing_b1_oversold_volume", - details: { macd: 3.2 }, + const extraStock = { + ...selectedResult.stocks[0], name: "招商银行", + signals: [ + { + ...selectedResult.stocks[0].signals[0], + category: "zhixing_b1_oversold_volume", + details: { macd: 3.2 }, + name: "招商银行", + ts_code: "600036.SH", + }, + ], ts_code: "600036.SH", } useSelectionResults.mockReturnValue({ data: { ...selectedResult, signal_count: 3, - signals: [...selectedResult.signals, extraSignal], + stocks: [...selectedResult.stocks, extraStock], + stocks_total: 2, }, isError: false, isPending: false, @@ -335,7 +356,7 @@ describe("SelectionResultsPage", () => { render() - fireEvent.change(screen.getByLabelText("搜索命中信号"), { + fireEvent.change(screen.getByLabelText("搜索命中股票"), { target: { value: "招商" }, }) const table = screen.getByRole("table") @@ -347,7 +368,7 @@ describe("SelectionResultsPage", () => { ).getByText("招商银行"), ).toBeInTheDocument() - const expandButton = screen.getAllByRole("button", { name: "查看详情" })[2] + const expandButton = screen.getAllByRole("button", { name: "查看详情" })[1] fireEvent.click(expandButton) expect(expandButton).toHaveAttribute("aria-expanded", "true") expect(screen.getAllByText("MACD").length).toBeGreaterThan(1) @@ -355,17 +376,25 @@ describe("SelectionResultsPage", () => { }) it("navigates to another page when the result list is paginated", () => { - const signals = Array.from({ length: 6 }, (_, index) => ({ - ...selectedResult.signals[0], + const stocks = Array.from({ length: 6 }, (_, index) => ({ + ...selectedResult.stocks[0], name: `股票${index + 1}`, + signals: [ + { + ...selectedResult.stocks[0].signals[0], + name: `股票${index + 1}`, + ts_code: `00000${index + 1}.SZ`, + }, + ], ts_code: `00000${index + 1}.SZ`, })) useSelectionResults.mockReturnValue({ data: { ...selectedResult, - signal_count: signals.length, - signals, - signals_total: signals.length, + selected_stock_count: stocks.length, + signal_count: stocks.length, + stocks, + stocks_total: stocks.length, }, isError: false, isPending: false, @@ -422,7 +451,7 @@ describe("SelectionResultsPage", () => { ], selected_stock_count: 0, signal_count: 0, - signals: [], + stocks: [], status: "failed", }, isError: false, @@ -442,7 +471,7 @@ describe("SelectionResultsPage", () => { data: { ...selectedResult, run_id: null, - signals: [], + stocks: [], status: "no_data", target_trade_date: "2026-08-08", }, @@ -490,12 +519,12 @@ describe("SelectionResultsPage", () => { it("continues polling a persisted running run after page reload", () => { useSelectionResults.mockReturnValue({ - data: { ...selectedResult, signals: [], status: "running" }, + data: { ...selectedResult, stocks: [], status: "running" }, isError: false, isPending: false, }) useSelectionRun.mockReturnValue({ - data: { ...selectedResult, signals: [], status: "running" }, + data: { ...selectedResult, stocks: [], status: "running" }, isError: false, isPending: false, })