refactor(selection): 统一信号与股票处理逻辑,重构相关数据结构与接口,更新前端展示以支持股票信息
This commit is contained in:
@@ -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):
|
||||||
|
|||||||
+40
-15
@@ -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",
|
||||||
|
|||||||
@@ -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
|
J
|
||||||
<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,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user