refactor(selection): 统一信号与股票处理逻辑,重构相关数据结构与接口,更新前端展示以支持股票信息

This commit is contained in:
yuxuanhui
2026-08-28 11:39:05 +08:00
parent 3da992700b
commit ad9545ef55
12 changed files with 408 additions and 189 deletions
@@ -79,7 +79,7 @@ class SelectionRun:
finished_at: datetime | None = None finished_at: datetime | None = None
items: tuple[SelectionRunItem, ...] = field(default_factory=tuple) items: tuple[SelectionRunItem, ...] = field(default_factory=tuple)
signals: tuple[SelectionSignal, ...] = field(default_factory=tuple) signals: tuple[SelectionSignal, ...] = field(default_factory=tuple)
signals_total: int | None = None stocks_total: int | None = None
class SelectionRunError(RuntimeError): class SelectionRunError(RuntimeError):
@@ -284,7 +284,7 @@ class PostgresSelectionRunRepository(SelectionRunStore):
*, *,
query: SelectionResultQuery | None = None, query: SelectionResultQuery | None = None,
) -> SelectionRun | 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: try:
with self._connection() as connection: with self._connection() as connection:
@@ -361,27 +361,47 @@ class PostgresSelectionRunRepository(SelectionRunStore):
""", """,
(run_id,), (run_id,),
).fetchall() ).fetchall()
signal_filter, signal_parameters = _signal_filter(query, run_id) stock_filter, stock_parameters = _stock_filter(query, run_id)
signal_total_row = connection.execute( stock_total_row = connection.execute(
f"SELECT COUNT(*) FROM selection_signal WHERE {signal_filter}", f"SELECT COUNT(DISTINCT ts_code) FROM selection_signal WHERE {stock_filter}",
tuple(signal_parameters), tuple(stock_parameters),
).fetchone() ).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 offset = (query.page - 1) * query.page_size
signal_rows = connection.execute( 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""" f"""
SELECT SELECT
ts_code, name, target_trade_date, strategy, category, close, details ts_code, name, target_trade_date, strategy, category, close, details
FROM selection_signal FROM selection_signal
WHERE {signal_filter} WHERE run_id = %s AND ts_code = ANY(%s)
ORDER BY ts_code, {_SIGNAL_ORDER_SQL} ORDER BY ts_code, {_SIGNAL_ORDER_SQL}
LIMIT %s OFFSET %s
""", """,
tuple((*signal_parameters, query.page_size, offset)), (run_id, stock_codes),
).fetchall() ).fetchall(),
)
if stock_codes
else []
)
signals = tuple( signals = tuple(
sorted( 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: ( key=lambda signal: (
signal.ts_code, signal.ts_code,
_SIGNAL_PRIORITY.get(signal.category, len(_SIGNAL_PRIORITY)), _SIGNAL_PRIORITY.get(signal.category, len(_SIGNAL_PRIORITY)),
@@ -430,7 +450,7 @@ class PostgresSelectionRunRepository(SelectionRunStore):
finished_at=cast(datetime | None, row[15]), finished_at=cast(datetime | None, row[15]),
items=items, items=items,
signals=signals, signals=signals,
signals_total=signal_total, stocks_total=stock_total,
) )
@contextmanager @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]]: def _stock_filter(query: SelectionResultQuery, run_id: str) -> tuple[str, list[object]]:
"""Build the parameterized WHERE clause shared by count and page reads.""" """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"] clauses = ["run_id = %s"]
parameters: list[object] = [run_id] parameters: list[object] = [run_id]
@@ -12,6 +12,7 @@ from zhixing_server.bootstrap.config import Settings, get_settings
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.runs import ( from zhixing_server.modules.selection.domain.runs import (
SelectionRerunRequired, SelectionRerunRequired,
SelectionResultQuery, SelectionResultQuery,
@@ -93,8 +94,25 @@ def _empty_signals() -> list[SelectionSignalResponse]:
return [] 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): 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 strategy: StrategyValue
target_trade_date: date | None target_trade_date: date | None
@@ -114,9 +132,9 @@ class SelectionResultsResponse(BaseModel):
finished_at: datetime | None = None finished_at: datetime | None = None
page: int = Field(default=1, ge=1) page: int = Field(default=1, ge=1)
page_size: int = Field(default=10, ge=1, le=100) 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) 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( def get_selection_service(
@@ -247,7 +265,7 @@ def get_selection_results(
coverage=0, coverage=0,
page=query.page, page=query.page,
page_size=query.page_size, page_size=query.page_size,
signals_total=0, stocks_total=0,
) )
return _run_response(run, query=query) return _run_response(run, query=query)
@@ -255,6 +273,10 @@ def get_selection_results(
def _run_response(run: SelectionRun, *, query: SelectionResultQuery) -> SelectionResultsResponse: def _run_response(run: SelectionRun, *, query: SelectionResultQuery) -> SelectionResultsResponse:
"""Translate a domain run without exposing storage-specific fields.""" """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( return SelectionResultsResponse(
strategy=run.strategy, strategy=run.strategy,
target_trade_date=run.target_trade_date, target_trade_date=run.target_trade_date,
@@ -274,7 +296,9 @@ def _run_response(run: SelectionRun, *, query: SelectionResultQuery) -> Selectio
finished_at=run.finished_at, finished_at=run.finished_at,
page=query.page, page=query.page,
page_size=query.page_size, 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=[ failures=[
SelectionFailureResponse( SelectionFailureResponse(
ts_code=item.ts_code, ts_code=item.ts_code,
@@ -285,8 +309,24 @@ def _run_response(run: SelectionRun, *, query: SelectionResultQuery) -> Selectio
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", "data_error"}
], ],
signals=[ stocks=[
SelectionSignalResponse( 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 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, ts_code=signal.ts_code,
name=signal.name, name=signal.name,
target_trade_date=signal.target_trade_date, target_trade_date=signal.target_trade_date,
@@ -295,9 +335,6 @@ def _run_response(run: SelectionRun, *, query: SelectionResultQuery) -> Selectio
close=signal.close, close=signal.close,
details=dict(signal.details), details=dict(signal.details),
) )
for signal in run.signals
],
)
def _result_query( def _result_query(
@@ -330,6 +367,7 @@ __all__ = [
"SelectionResultsResponse", "SelectionResultsResponse",
"SelectionRunAcceptedResponse", "SelectionRunAcceptedResponse",
"SelectionRunRequest", "SelectionRunRequest",
"SelectionStockResponse",
"get_selection_service", "get_selection_service",
"get_selection_postgres_pool", "get_selection_postgres_pool",
"selection_router", "selection_router",
+31 -11
View File
@@ -91,7 +91,7 @@ class FakeSelectionService:
def _run(run_id: str, status: str) -> SelectionRun: def _run(run_id: str, status: str) -> SelectionRun:
signal = SelectionSignal( original_signal = SelectionSignal(
ts_code="000001.SZ", ts_code="000001.SZ",
name="平安银行", name="平安银行",
target_trade_date=TARGET, target_trade_date=TARGET,
@@ -100,6 +100,15 @@ def _run(run_id: str, status: str) -> SelectionRun:
close=10.5, close=10.5,
details={"j": 12.0}, 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 from zhixing_server.modules.selection.domain.runs import SelectionRunItem
return SelectionRun( return SelectionRun(
@@ -112,7 +121,7 @@ def _run(run_id: str, status: str) -> SelectionRun:
eligible_count=1, eligible_count=1,
evaluated_count=1, evaluated_count=1,
selected_stock_count=1, selected_stock_count=1,
signal_count=1, signal_count=2,
failed_count=0, failed_count=0,
coverage=Decimal("1"), coverage=Decimal("1"),
items=( items=(
@@ -120,11 +129,12 @@ def _run(run_id: str, status: str) -> SelectionRun:
ts_code="000001.SZ", ts_code="000001.SZ",
name="平安银行", name="平安银行",
status="selected", status="selected",
signal_count=1, signal_count=2,
signals=(signal,), 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.status_code == 200
assert response.json()["status"] == "no_data" assert response.json()["status"] == "no_data"
assert response.json()["signals"] == [] assert response.json()["stocks"] == []
def test_query_returns_persisted_signal_details() -> None: 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 assert response.status_code == 200
body = response.json() body = response.json()
assert body["run_id"] == "run-http" assert body["run_id"] == "run-http"
assert body["signal_count"] == 1 assert body["signal_count"] == 2
assert body["page"] == 1 assert body["page"] == 1
assert body["page_size"] == 10 assert body["page_size"] == 10
assert body["signals_total"] == 1 assert body["stocks_total"] == 1
assert body["signals"][0]["category"] == "zhixing_b1_original_b1" assert "signals_total" not in body
assert body["signals"][0]["details"] == {"j": 12.0} 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: 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.status_code == 200
assert response.json()["status"] == "success" 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")
@@ -283,8 +283,10 @@ class LoadConnection:
) )
if "FROM selection_run_item" in query: if "FROM selection_run_item" in query:
return LoadResult(rows=[("000001.SZ", "平安银行", "selected", 2, None)]) 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,)) return LoadResult(row=(2,))
if "SELECT DISTINCT ts_code" in query:
return LoadResult(rows=[("000001.SZ",)])
return LoadResult( return LoadResult(
rows=[ rows=[
( (
@@ -301,7 +303,7 @@ class LoadConnection:
"平安银行", "平安银行",
TARGET, TARGET,
"zhixing_b1", "zhixing_b1",
ZHIXING_B1_SIGNAL_ORDER[0].value, ZhixingB1Category.ORIGINAL_B1.value,
Decimal("10.5"), 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: class LoadResult:
def __init__( def __init__(
self, self,
@@ -326,7 +338,9 @@ class LoadResult:
return self.rows 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() connection = LoadConnection()
def connect(database_url: str) -> 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 = PostgresSelectionRunRepository("postgresql://test").get_run(
"run-1", "run-1",
query=SelectionResultQuery( query=SelectionResultQuery(
page=2, page=1,
page_size=1, page_size=1,
search="100%", search="100%",
category="pullback", category="pullback",
@@ -346,20 +360,57 @@ def test_get_run_orders_signals_by_formula_priority(monkeypatch: pytest.MonkeyPa
assert run is not None assert run is not None
assert [signal.category for signal in run.signals] == [ assert [signal.category for signal in run.signals] == [
ZHIXING_B1_SIGNAL_ORDER[0], ZhixingB1Category.ORIGINAL_B1,
ZHIXING_B1_SIGNAL_ORDER[-1], ZHIXING_B1_SIGNAL_ORDER[-1],
] ]
assert run.stocks_total == 2
count_query, count_parameters = next( count_query, count_parameters = next(
(query, parameters) (query, parameters)
for query, parameters in connection.statements 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 "name ILIKE %s ESCAPE" in count_query
assert count_parameters == ("run-1", "%100\\%%", "%100\\%%", "zhixing_b1_pullback_%") assert count_parameters == ("run-1", "%100\\%%", "%100\\%%", "zhixing_b1_pullback_%")
page_query, page_parameters = next( stock_page_query, page_parameters = next(
(query, parameters) (query, parameters)
for query, parameters in connection.statements 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 "ORDER BY ts_code" in stock_page_query
assert page_parameters[-2:] == (1, 1) 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)
@@ -43,6 +43,15 @@ export interface SelectionSignal {
details: Record<string, number | string | null> details: Record<string, number | string | null>
} }
export interface SelectionStockResult {
ts_code: string
name: string
target_trade_date: string
strategy: SelectionStrategy
close: number
signals: SelectionSignal[]
}
export interface SelectionFailure { export interface SelectionFailure {
ts_code: string ts_code: string
name: string name: string
@@ -69,7 +78,7 @@ export interface SelectionResults {
finished_at: string | null finished_at: string | null
page: number page: number
page_size: number page_size: number
signals_total: number stocks_total: number
failures: SelectionFailure[] failures: SelectionFailure[]
signals: SelectionSignal[] stocks: SelectionStockResult[]
} }
@@ -2,6 +2,7 @@ import type {
SelectionCategoryFilter, SelectionCategoryFilter,
SelectionRunStatus, SelectionRunStatus,
SelectionSignal, SelectionSignal,
SelectionStockResult,
} from "../api/selection.types" } from "../api/selection.types"
export type SelectionRunStatusPresentation = { export type SelectionRunStatusPresentation = {
@@ -73,6 +74,10 @@ export function getSignalKey(signal: SelectionSignal) {
return `${signal.ts_code}-${signal.category}` return `${signal.ts_code}-${signal.category}`
} }
export function getStockKey(stock: SelectionStockResult) {
return stock.ts_code
}
export function getSignalDetailItems(details: SelectionSignal["details"]) { export function getSignalDetailItems(details: SelectionSignal["details"]) {
return Object.entries(details).map(([key, value]) => ({ return Object.entries(details).map(([key, value]) => ({
key, key,
@@ -102,11 +107,11 @@ function formatSignalDetailValue(
} }
export function matchesCategory( export function matchesCategory(
signal: SelectionSignal, stock: SelectionStockResult,
category: SignalCategoryFilter, category: SignalCategoryFilter,
) { ) {
if (category === "all") return true if (category === "all") return true
return signal.category.includes(category) return stock.signals.some((signal) => signal.category.includes(category))
} }
export function categoryToneClass(category: string) { export function categoryToneClass(category: string) {
@@ -12,12 +12,15 @@ import {
SelectValue, SelectValue,
} from "@/shared/ui/select" } 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 { ExecutionStatusTrigger } from "./execution-status-trigger"
import { SignalDetailPanel } from "./signal-detail-panel" import { SignalDetailPanel } from "./signal-detail-panel"
import { SignalRecordList } from "./signal-record-list" import { SignalRecordList } from "./signal-record-list"
import { import {
getSignalKey, getStockKey,
matchesCategory, matchesCategory,
signalCategoryOptions, signalCategoryOptions,
type SignalCategoryFilter, type SignalCategoryFilter,
@@ -48,26 +51,26 @@ export function SelectionResultsWorkbench({
new Set(), new Set(),
) )
const visibleSignals = useMemo(() => { const visibleStocks = useMemo(() => {
const normalized = query.trim().toLowerCase() const normalized = query.trim().toLowerCase()
return result.signals.filter((signal) => { return result.stocks.filter((stock) => {
const matchesQuery = const matchesQuery =
normalized.length === 0 || normalized.length === 0 ||
signal.name.toLowerCase().includes(normalized) || stock.name.toLowerCase().includes(normalized) ||
signal.ts_code.toLowerCase().includes(normalized) stock.ts_code.toLowerCase().includes(normalized)
return matchesQuery && matchesCategory(signal, category) return matchesQuery && matchesCategory(stock, category)
}) })
}, [category, query, result.signals]) }, [category, query, result.stocks])
const page = search.page const page = search.page
const pageSize = search.pageSize const pageSize = search.pageSize
const signalsTotal = result.signals_total ?? result.signal_count const stocksTotal = result.stocks_total ?? result.selected_stock_count
const pageCount = Math.max(1, Math.ceil(signalsTotal / pageSize)) const pageCount = Math.max(1, Math.ceil(stocksTotal / pageSize))
const currentPage = Math.min(page, pageCount) const currentPage = Math.min(page, pageCount)
const currentSignals = visibleSignals const currentStocks = visibleStocks
const selectedSignal = const selectedStock =
currentSignals.find((signal) => getSignalKey(signal) === selectedKey) ?? currentStocks.find((stock) => getStockKey(stock) === selectedKey) ??
currentSignals[0] currentStocks[0]
useEffect(() => { useEffect(() => {
if (page > pageCount) { if (page > pageCount) {
void navigate({ void navigate({
@@ -93,8 +96,8 @@ export function SelectionResultsWorkbench({
updateSearch({ category: value, page: 1 }) updateSearch({ category: value, page: 1 })
} }
function handleToggleExpanded(signal: SelectionSignal) { function handleToggleExpanded(stock: SelectionStockResult) {
const key = getSignalKey(signal) const key = getStockKey(stock)
setExpandedKeys((previous) => { setExpandedKeys((previous) => {
const next = new Set(previous) const next = new Set(previous)
if (next.has(key)) next.delete(key) if (next.has(key)) next.delete(key)
@@ -108,7 +111,7 @@ export function SelectionResultsWorkbench({
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-visible rounded-md border border-border/80 bg-card md:overflow-hidden"> <section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-visible rounded-md border border-border/80 bg-card md:overflow-hidden">
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/60 p-2.5"> <div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/60 p-2.5">
<Input <Input
aria-label="搜索命中信号" aria-label="搜索命中股票"
className="h-11 min-w-0 flex-1 bg-background text-sm sm:max-w-[260px] md:h-8" className="h-11 min-w-0 flex-1 bg-background text-sm sm:max-w-[260px] md:h-8"
onChange={(event) => handleQueryChange(event.target.value)} onChange={(event) => handleQueryChange(event.target.value)}
placeholder="搜索股票名称或代码" placeholder="搜索股票名称或代码"
@@ -130,7 +133,7 @@ export function SelectionResultsWorkbench({
> >
<SelectValue placeholder="筛选信号分类" /> <SelectValue placeholder="筛选信号分类" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent align="start" alignItemWithTrigger={false}>
<SelectGroup> <SelectGroup>
{signalCategoryOptions.map((option) => ( {signalCategoryOptions.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
@@ -141,7 +144,7 @@ export function SelectionResultsWorkbench({
</SelectContent> </SelectContent>
</Select> </Select>
<span className="text-xs tabular-nums text-muted-foreground sm:ml-auto"> <span className="text-xs tabular-nums text-muted-foreground sm:ml-auto">
筛选结果 {signalsTotal} 条 筛选结果 {stocksTotal} 只
</span> </span>
<ExecutionStatusTrigger <ExecutionStatusTrigger
drawerOpen={drawerOpen} drawerOpen={drawerOpen}
@@ -153,15 +156,15 @@ export function SelectionResultsWorkbench({
<div className="flex min-h-0 min-w-0 flex-col md:flex-1 md:overflow-hidden"> <div className="flex min-h-0 min-w-0 flex-col md:flex-1 md:overflow-hidden">
<SignalTable <SignalTable
onSelect={(signal) => setSelectedKey(getSignalKey(signal))} onSelect={(stock) => setSelectedKey(getStockKey(stock))}
selectedKey={selectedSignal ? getSignalKey(selectedSignal) : null} selectedKey={selectedStock ? getStockKey(selectedStock) : null}
signals={currentSignals} stocks={currentStocks}
/> />
<SignalRecordList <SignalRecordList
expandedKeys={expandedKeys} expandedKeys={expandedKeys}
onSelect={(signal) => setSelectedKey(getSignalKey(signal))} onSelect={(stock) => setSelectedKey(getStockKey(stock))}
onToggleExpanded={handleToggleExpanded} onToggleExpanded={handleToggleExpanded}
signals={currentSignals} stocks={currentStocks}
/> />
</div> </div>
@@ -173,11 +176,11 @@ export function SelectionResultsWorkbench({
page={currentPage} page={currentPage}
pageSize={pageSize} pageSize={pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS} pageSizeOptions={PAGE_SIZE_OPTIONS}
total={signalsTotal} total={stocksTotal}
/> />
</section> </section>
<SignalDetailPanel signal={selectedSignal} /> <SignalDetailPanel stock={selectedStock} />
</div> </div>
) )
} }
@@ -3,16 +3,16 @@ import { Database, Search } from "lucide-react"
import { Badge } from "@/shared/ui/badge" import { Badge } from "@/shared/ui/badge"
import { Card } from "@/shared/ui/card" 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 { categoryToneClass, getCategoryLabel } from "./selection-presentation"
import { SignalDetails } from "./signal-details" import { SignalDetails } from "./signal-details"
interface SignalDetailPanelProps { interface SignalDetailPanelProps {
signal: SelectionSignal | undefined stock: SelectionStockResult | undefined
} }
export function SignalDetailPanel({ signal }: SignalDetailPanelProps) { export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
if (!signal) { if (!stock) {
return ( return (
<Card <Card
aria-label="当前股票详情" aria-label="当前股票详情"
@@ -23,9 +23,9 @@ export function SignalDetailPanel({ signal }: SignalDetailPanelProps) {
className="mx-auto size-5 text-muted-foreground" className="mx-auto size-5 text-muted-foreground"
aria-hidden="true" aria-hidden="true"
/> />
<p className="font-medium">暂无选中信号</p> <p className="font-medium">暂无选中股票</p>
<p className="text-xs leading-5 text-muted-foreground"> <p className="text-xs leading-5 text-muted-foreground">
从左侧列表选择一条信号查看详情。 从左侧列表选择一只股票查看详情。
</p> </p>
</div> </div>
</Card> </Card>
@@ -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" className="hidden min-h-0 overflow-auto rounded-md border border-border/80 bg-card p-3 md:block"
> >
<p className="text-[10px] font-bold tracking-[0.08em] text-muted-foreground uppercase"> <p className="text-[10px] font-bold tracking-[0.08em] text-muted-foreground uppercase">
Selected signal Selected stock
</p> </p>
<div className="mt-2 flex items-start justify-between gap-3"> <div className="mt-2 flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<h2 className="truncate text-xl font-semibold tracking-tight"> <h2 className="truncate text-xl font-semibold tracking-tight">
{signal.name} {stock.name}
</h2> </h2>
<p className="font-mono text-xs text-muted-foreground"> <p className="font-mono text-xs text-muted-foreground">
{signal.ts_code} {stock.ts_code}
</p> </p>
</div> </div>
<Database <Database
@@ -56,21 +56,26 @@ export function SignalDetailPanel({ signal }: SignalDetailPanelProps) {
</div> </div>
<p className="mt-4 text-3xl font-semibold tabular-nums tracking-tight"> <p className="mt-4 text-3xl font-semibold tabular-nums tracking-tight">
¥ {signal.close.toFixed(2)} ¥ {stock.close.toFixed(2)}
</p> </p>
<div className="mt-3 flex flex-wrap gap-1">
{stock.signals.map((signal) => (
<Badge <Badge
className={`mt-3 ${categoryToneClass(signal.category)}`} key={signal.category}
className={categoryToneClass(signal.category)}
variant="outline" variant="outline"
> >
{getCategoryLabel(signal.category)} {getCategoryLabel(signal.category)}
</Badge> </Badge>
))}
</div>
<dl className="mt-4 divide-y divide-border/60 border-y border-border/60 text-xs"> <dl className="mt-4 divide-y divide-border/60 border-y border-border/60 text-xs">
<div className="flex items-center justify-between gap-3 py-2.5"> <div className="flex items-center justify-between gap-3 py-2.5">
<dt className="text-muted-foreground">目标交易日</dt> <dt className="text-muted-foreground">目标交易日</dt>
<dd className="font-medium tabular-nums"> <dd className="font-medium tabular-nums">
{signal.target_trade_date} {stock.target_trade_date}
</dd> </dd>
</div> </div>
<div className="flex items-center justify-between gap-3 py-2.5"> <div className="flex items-center justify-between gap-3 py-2.5">
@@ -86,8 +91,17 @@ export function SignalDetailPanel({ signal }: SignalDetailPanelProps) {
> >
关键指标 关键指标
</h3> </h3>
<div className="space-y-4">
{stock.signals.map((signal) => (
<section key={signal.category} className="space-y-2">
<h4 className="text-xs font-medium text-foreground">
{getCategoryLabel(signal.category)}
</h4>
<SignalDetails details={signal.details} /> <SignalDetails details={signal.details} />
</section> </section>
))}
</div>
</section>
</aside> </aside>
) )
} }
@@ -3,29 +3,29 @@ import { ChevronDown, ChevronUp } from "lucide-react"
import { Badge } from "@/shared/ui/badge" import { Badge } from "@/shared/ui/badge"
import { Button } from "@/shared/ui/button" import { Button } from "@/shared/ui/button"
import type { SelectionSignal } from "../api/selection.types" import type { SelectionStockResult } from "../api/selection.types"
import { import {
categoryToneClass, categoryToneClass,
getCategoryLabel, getCategoryLabel,
getSignalJValue, getSignalJValue,
getSignalKey, getStockKey,
} from "./selection-presentation" } from "./selection-presentation"
import { SignalDetails } from "./signal-details" import { SignalDetails } from "./signal-details"
interface SignalRecordListProps { interface SignalRecordListProps {
signals: SelectionSignal[] stocks: SelectionStockResult[]
expandedKeys: ReadonlySet<string> expandedKeys: ReadonlySet<string>
onSelect: (signal: SelectionSignal) => void onSelect: (stock: SelectionStockResult) => void
onToggleExpanded: (signal: SelectionSignal) => void onToggleExpanded: (stock: SelectionStockResult) => void
} }
export function SignalRecordList({ export function SignalRecordList({
signals, stocks,
expandedKeys, expandedKeys,
onSelect, onSelect,
onToggleExpanded, onToggleExpanded,
}: SignalRecordListProps) { }: SignalRecordListProps) {
if (signals.length === 0) { if (stocks.length === 0) {
return ( return (
<div className="flex min-h-32 items-center justify-center px-4 text-sm text-muted-foreground md:hidden"> <div className="flex min-h-32 items-center justify-center px-4 text-sm text-muted-foreground md:hidden">
没有符合当前筛选条件的信号。 没有符合当前筛选条件的信号。
@@ -35,46 +35,53 @@ export function SignalRecordList({
return ( return (
<div className="divide-y divide-border/60 md:hidden"> <div className="divide-y divide-border/60 md:hidden">
{signals.map((signal) => { {stocks.map((stock) => {
const key = getSignalKey(signal) const key = getStockKey(stock)
const expanded = expandedKeys.has(key) const expanded = expandedKeys.has(key)
return ( return (
<article key={key} className="space-y-2 px-3 py-3"> <article key={key} className="space-y-2 px-3 py-3">
<button <button
type="button" type="button"
className="flex min-h-11 w-full items-center justify-between gap-3 text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50" className="flex min-h-11 w-full items-center justify-between gap-3 text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
onClick={() => onSelect(signal)} onClick={() => onSelect(stock)}
> >
<span className="min-w-0"> <span className="min-w-0">
<span className="block truncate font-medium text-foreground"> <span className="block truncate font-medium text-foreground">
{signal.name} {stock.name}
</span> </span>
<span className="font-mono text-[10px] text-muted-foreground"> <span className="font-mono text-[10px] text-muted-foreground">
{signal.ts_code} {stock.ts_code}
</span> </span>
</span> </span>
<span className="shrink-0 font-medium tabular-nums text-foreground"> <span className="shrink-0 font-medium tabular-nums text-foreground">
¥ {signal.close.toFixed(2)} ¥ {stock.close.toFixed(2)}
</span> </span>
</button> </button>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex flex-wrap gap-1">
{stock.signals.map((signal) => (
<Badge <Badge
key={signal.category}
className={categoryToneClass(signal.category)} className={categoryToneClass(signal.category)}
variant="outline" variant="outline"
> >
{getCategoryLabel(signal.category)} {getCategoryLabel(signal.category)}
</Badge> </Badge>
))}
</div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-xs tabular-nums text-muted-foreground"> <span className="text-xs tabular-nums text-muted-foreground">
J&nbsp; J&nbsp;
<strong className="font-semibold text-foreground"> <strong className="font-semibold text-foreground">
{getSignalJValue(signal.details)} {stock.signals
.map((signal) => getSignalJValue(signal.details))
.join(" / ")}
</strong> </strong>
</span> </span>
<Button <Button
aria-expanded={expanded} aria-expanded={expanded}
className="min-h-11 gap-1 px-2 text-xs text-muted-foreground" className="min-h-11 gap-1 px-2 text-xs text-muted-foreground"
onClick={() => onToggleExpanded(signal)} onClick={() => onToggleExpanded(stock)}
variant="ghost" variant="ghost"
> >
{expanded ? "收起详情" : "查看详情"} {expanded ? "收起详情" : "查看详情"}
@@ -87,10 +94,19 @@ export function SignalRecordList({
</div> </div>
</div> </div>
{expanded ? ( {expanded ? (
<div className="space-y-3">
{stock.signals.map((signal) => (
<section key={signal.category} className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
{getCategoryLabel(signal.category)}
</p>
<SignalDetails <SignalDetails
className="sm:grid-cols-3" className="sm:grid-cols-3"
details={signal.details} details={signal.details}
/> />
</section>
))}
</div>
) : null} ) : null}
</article> </article>
) )
@@ -2,32 +2,32 @@ import type { KeyboardEvent } from "react"
import { Badge } from "@/shared/ui/badge" import { Badge } from "@/shared/ui/badge"
import type { SelectionSignal } from "../api/selection.types" import type { SelectionStockResult } from "../api/selection.types"
import { import {
categoryToneClass, categoryToneClass,
getCategoryLabel, getCategoryLabel,
getSignalJValue, getSignalJValue,
getSignalKey, getStockKey,
} from "./selection-presentation" } from "./selection-presentation"
interface SignalTableProps { interface SignalTableProps {
signals: SelectionSignal[] stocks: SelectionStockResult[]
selectedKey: string | null selectedKey: string | null
onSelect: (signal: SelectionSignal) => void onSelect: (stock: SelectionStockResult) => void
} }
export function SignalTable({ export function SignalTable({
signals, stocks,
selectedKey, selectedKey,
onSelect, onSelect,
}: SignalTableProps) { }: SignalTableProps) {
function handleRowKeyDown( function handleRowKeyDown(
event: KeyboardEvent<HTMLTableRowElement>, event: KeyboardEvent<HTMLTableRowElement>,
signal: SelectionSignal, stock: SelectionStockResult,
) { ) {
if (event.key !== "Enter" && event.key !== " ") return if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault() event.preventDefault()
onSelect(signal) onSelect(stock)
} }
return ( return (
@@ -43,38 +43,47 @@ export function SignalTable({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{signals.length > 0 ? ( {stocks.length > 0 ? (
signals.map((signal) => { stocks.map((stock) => {
const selected = selectedKey === getSignalKey(signal) const selected = selectedKey === getStockKey(stock)
return ( return (
<tr <tr
key={getSignalKey(signal)} key={getStockKey(stock)}
aria-selected={selected} aria-selected={selected}
className="cursor-pointer border-b border-border/50 outline-none transition-colors hover:bg-muted/50 focus-visible:bg-muted/60 data-[selected=true]:bg-muted/70" className="cursor-pointer border-b border-border/50 outline-none transition-colors hover:bg-muted/50 focus-visible:bg-muted/60 data-[selected=true]:bg-muted/70"
data-selected={selected} data-selected={selected}
onClick={() => onSelect(signal)} onClick={() => onSelect(stock)}
onKeyDown={(event) => handleRowKeyDown(event, signal)} onKeyDown={(event) => handleRowKeyDown(event, stock)}
tabIndex={0} tabIndex={0}
> >
<td className="h-11 px-3 align-middle"> <td className="h-11 px-3 align-middle">
<p className="font-medium text-foreground">{signal.name}</p> <p className="font-medium text-foreground">{stock.name}</p>
<p className="font-mono text-[10px] text-muted-foreground"> <p className="font-mono text-[10px] text-muted-foreground">
{signal.ts_code} {stock.ts_code}
</p> </p>
</td> </td>
<td className="px-3 align-middle"> <td className="px-3 align-middle">
<div className="flex flex-wrap gap-1">
{stock.signals.map((signal) => (
<Badge <Badge
key={signal.category}
className={categoryToneClass(signal.category)} className={categoryToneClass(signal.category)}
variant="outline" variant="outline"
> >
{getCategoryLabel(signal.category)} {getCategoryLabel(signal.category)}
</Badge> </Badge>
))}
</div>
</td> </td>
<td className="px-3 text-right align-middle font-semibold tabular-nums text-foreground"> <td className="px-3 text-right align-middle font-semibold tabular-nums text-foreground">
{stock.signals.map((signal) => (
<span key={signal.category} className="block">
{getSignalJValue(signal.details)} {getSignalJValue(signal.details)}
</span>
))}
</td> </td>
<td className="px-3 text-right align-middle font-medium tabular-nums text-foreground"> <td className="px-3 text-right align-middle font-medium tabular-nums text-foreground">
¥ {signal.close.toFixed(2)} ¥ {stock.close.toFixed(2)}
</td> </td>
</tr> </tr>
) )
@@ -45,7 +45,11 @@ const selectedResult: SelectionResults = {
run_id: "run-1", run_id: "run-1",
selected_stock_count: 1, selected_stock_count: 1,
signal_count: 2, signal_count: 2,
signals_total: 2, stocks_total: 1,
stocks: [
{
close: 10.5,
name: "平安银行",
signals: [ signals: [
{ {
category: "zhixing_b1_original_b1", category: "zhixing_b1_original_b1",
@@ -59,13 +63,18 @@ const selectedResult: SelectionResults = {
{ {
category: "zhixing_b1_pullback_white", category: "zhixing_b1_pullback_white",
close: 10.5, close: 10.5,
details: { j: 12 }, details: { j: 13 },
name: "平安银行", name: "平安银行",
strategy: "zhixing_b1", strategy: "zhixing_b1",
target_trade_date: "2026-08-08", target_trade_date: "2026-08-08",
ts_code: "000001.SZ", ts_code: "000001.SZ",
}, },
], ],
strategy: "zhixing_b1",
target_trade_date: "2026-08-08",
ts_code: "000001.SZ",
},
],
status: "success", status: "success",
strategy: "zhixing_b1", strategy: "zhixing_b1",
target_count: 2, target_count: 2,
@@ -113,14 +122,15 @@ describe("SelectionResultsPage", () => {
).not.toBeInTheDocument() ).not.toBeInTheDocument()
expect(within(table).getByText("原始 B1")).toBeInTheDocument() expect(within(table).getByText("原始 B1")).toBeInTheDocument()
expect(within(table).getByText("回踩白线 B")).toBeInTheDocument() expect(within(table).getByText("回踩白线 B")).toBeInTheDocument()
expect(within(table).getAllByText("12")).toHaveLength(2) expect(within(table).getByText("12")).toBeInTheDocument()
expect(within(table).getAllByText("平安银行")).toHaveLength(2) expect(within(table).getByText("13")).toBeInTheDocument()
expect(within(table).getByText("平安银行")).toBeInTheDocument()
const detailPanel = screen.getByRole("complementary", { const detailPanel = screen.getByRole("complementary", {
name: "当前股票详情", name: "当前股票详情",
}) })
expect(within(detailPanel).getByText("关键指标")).toBeInTheDocument() expect(within(detailPanel).getByText("关键指标")).toBeInTheDocument()
expect(within(detailPanel).getByText("J 值")).toBeInTheDocument() expect(within(detailPanel).getAllByText("J 值")).toHaveLength(2)
expect(within(detailPanel).getByText("RSI")).toBeInTheDocument() expect(within(detailPanel).getByText("RSI")).toBeInTheDocument()
}) })
@@ -220,8 +230,8 @@ describe("SelectionResultsPage", () => {
data: { data: {
...selectedResult, ...selectedResult,
signal_count: 0, signal_count: 0,
signals: [], stocks: [],
signals_total: 0, stocks_total: 0,
}, },
isError: false, isError: false,
isPending: false, isPending: false,
@@ -260,7 +270,7 @@ describe("SelectionResultsPage", () => {
}, },
], ],
signal_count: 0, signal_count: 0,
signals: [], stocks: [],
status: "failed", status: "failed",
}, },
isError: false, isError: false,
@@ -293,6 +303,9 @@ describe("SelectionResultsPage", () => {
fireEvent.click(categoryTrigger) fireEvent.click(categoryTrigger)
const oversoldOption = await screen.findByRole("option", { name: "超卖类" }) 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.pointerDown(oversoldOption, { pointerType: "mouse" })
fireEvent.click(oversoldOption) fireEvent.click(oversoldOption)
@@ -316,18 +329,26 @@ describe("SelectionResultsPage", () => {
}) })
it("filters signals, updates the detail panel, and expands mobile details", () => { it("filters signals, updates the detail panel, and expands mobile details", () => {
const extraSignal = { const extraStock = {
...selectedResult.signals[0], ...selectedResult.stocks[0],
name: "招商银行",
signals: [
{
...selectedResult.stocks[0].signals[0],
category: "zhixing_b1_oversold_volume", category: "zhixing_b1_oversold_volume",
details: { macd: 3.2 }, details: { macd: 3.2 },
name: "招商银行", name: "招商银行",
ts_code: "600036.SH", ts_code: "600036.SH",
},
],
ts_code: "600036.SH",
} }
useSelectionResults.mockReturnValue({ useSelectionResults.mockReturnValue({
data: { data: {
...selectedResult, ...selectedResult,
signal_count: 3, signal_count: 3,
signals: [...selectedResult.signals, extraSignal], stocks: [...selectedResult.stocks, extraStock],
stocks_total: 2,
}, },
isError: false, isError: false,
isPending: false, isPending: false,
@@ -335,7 +356,7 @@ describe("SelectionResultsPage", () => {
render(<SelectionResultsPage />) render(<SelectionResultsPage />)
fireEvent.change(screen.getByLabelText("搜索命中信号"), { fireEvent.change(screen.getByLabelText("搜索命中股票"), {
target: { value: "招商" }, target: { value: "招商" },
}) })
const table = screen.getByRole("table") const table = screen.getByRole("table")
@@ -347,7 +368,7 @@ describe("SelectionResultsPage", () => {
).getByText("招商银行"), ).getByText("招商银行"),
).toBeInTheDocument() ).toBeInTheDocument()
const expandButton = screen.getAllByRole("button", { name: "查看详情" })[2] const expandButton = screen.getAllByRole("button", { name: "查看详情" })[1]
fireEvent.click(expandButton) fireEvent.click(expandButton)
expect(expandButton).toHaveAttribute("aria-expanded", "true") expect(expandButton).toHaveAttribute("aria-expanded", "true")
expect(screen.getAllByText("MACD").length).toBeGreaterThan(1) expect(screen.getAllByText("MACD").length).toBeGreaterThan(1)
@@ -355,17 +376,25 @@ describe("SelectionResultsPage", () => {
}) })
it("navigates to another page when the result list is paginated", () => { it("navigates to another page when the result list is paginated", () => {
const signals = Array.from({ length: 6 }, (_, index) => ({ const stocks = Array.from({ length: 6 }, (_, index) => ({
...selectedResult.signals[0], ...selectedResult.stocks[0],
name: `股票${index + 1}`, name: `股票${index + 1}`,
signals: [
{
...selectedResult.stocks[0].signals[0],
name: `股票${index + 1}`,
ts_code: `00000${index + 1}.SZ`,
},
],
ts_code: `00000${index + 1}.SZ`, ts_code: `00000${index + 1}.SZ`,
})) }))
useSelectionResults.mockReturnValue({ useSelectionResults.mockReturnValue({
data: { data: {
...selectedResult, ...selectedResult,
signal_count: signals.length, selected_stock_count: stocks.length,
signals, signal_count: stocks.length,
signals_total: signals.length, stocks,
stocks_total: stocks.length,
}, },
isError: false, isError: false,
isPending: false, isPending: false,
@@ -422,7 +451,7 @@ describe("SelectionResultsPage", () => {
], ],
selected_stock_count: 0, selected_stock_count: 0,
signal_count: 0, signal_count: 0,
signals: [], stocks: [],
status: "failed", status: "failed",
}, },
isError: false, isError: false,
@@ -442,7 +471,7 @@ describe("SelectionResultsPage", () => {
data: { data: {
...selectedResult, ...selectedResult,
run_id: null, run_id: null,
signals: [], stocks: [],
status: "no_data", status: "no_data",
target_trade_date: "2026-08-08", target_trade_date: "2026-08-08",
}, },
@@ -490,12 +519,12 @@ describe("SelectionResultsPage", () => {
it("continues polling a persisted running run after page reload", () => { it("continues polling a persisted running run after page reload", () => {
useSelectionResults.mockReturnValue({ useSelectionResults.mockReturnValue({
data: { ...selectedResult, signals: [], status: "running" }, data: { ...selectedResult, stocks: [], status: "running" },
isError: false, isError: false,
isPending: false, isPending: false,
}) })
useSelectionRun.mockReturnValue({ useSelectionRun.mockReturnValue({
data: { ...selectedResult, signals: [], status: "running" }, data: { ...selectedResult, stocks: [], status: "running" },
isError: false, isError: false,
isPending: false, isPending: false,
}) })