feat(selection): 新增个股技术图表接口
This commit is contained in:
@@ -10,6 +10,11 @@ from fastapi.testclient import TestClient
|
||||
import zhixing_server.modules.selection.presentation.http as selection_http
|
||||
from zhixing_server.bootstrap.app import create_app
|
||||
from zhixing_server.bootstrap.config import Settings
|
||||
from zhixing_server.modules.selection.application.chart import (
|
||||
SelectionChart,
|
||||
SelectionChartNotFound,
|
||||
SelectionChartPoint,
|
||||
)
|
||||
from zhixing_server.modules.selection.application.run import PreparedSelectionRun
|
||||
from zhixing_server.modules.selection.domain.models import SelectionSignal, ZhixingB1Category
|
||||
from zhixing_server.modules.selection.domain.pattern_scoring import (
|
||||
@@ -18,6 +23,7 @@ from zhixing_server.modules.selection.domain.pattern_scoring import (
|
||||
PatternScore,
|
||||
PatternScoreBreakdown,
|
||||
)
|
||||
from zhixing_server.modules.selection.domain.ports import MarketDataReaderError
|
||||
from zhixing_server.modules.selection.domain.runs import (
|
||||
SelectionExecutionSource,
|
||||
SelectionRerunRequired,
|
||||
@@ -30,6 +36,7 @@ from zhixing_server.modules.selection.infrastructure.postgres_reader import (
|
||||
SelectionMarketDataNotReady,
|
||||
)
|
||||
from zhixing_server.modules.selection.presentation.http import (
|
||||
get_selection_chart_service,
|
||||
get_selection_postgres_pool,
|
||||
get_selection_service,
|
||||
)
|
||||
@@ -97,6 +104,40 @@ class FakeSelectionService:
|
||||
return self.run
|
||||
|
||||
|
||||
class FakeChartService:
|
||||
"""Return or fail one deterministic chart response."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.mode = "ok"
|
||||
self.request: tuple[str, date] | None = None
|
||||
|
||||
def execute(self, ts_code: str, target_trade_date: date) -> SelectionChart:
|
||||
self.request = (ts_code, target_trade_date)
|
||||
if self.mode == "missing":
|
||||
raise SelectionChartNotFound("chart unavailable")
|
||||
if self.mode == "storage_error":
|
||||
raise MarketDataReaderError("chart storage unavailable")
|
||||
return SelectionChart(
|
||||
ts_code=ts_code,
|
||||
name="平安银行",
|
||||
target_trade_date=target_trade_date,
|
||||
source_adj="qfq",
|
||||
points=(
|
||||
SelectionChartPoint(
|
||||
trade_date=target_trade_date,
|
||||
open=10.0,
|
||||
high=11.0,
|
||||
low=9.5,
|
||||
close=10.5,
|
||||
volume=1000.0,
|
||||
k=52.0,
|
||||
d=48.0,
|
||||
j=60.0,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _run(run_id: str, status: str) -> SelectionRun:
|
||||
original_signal = SelectionSignal(
|
||||
ts_code="000001.SZ",
|
||||
@@ -153,9 +194,14 @@ def _run(run_id: str, status: str) -> SelectionRun:
|
||||
)
|
||||
|
||||
|
||||
def _client(service: FakeSelectionService) -> TestClient:
|
||||
def _client(
|
||||
service: FakeSelectionService,
|
||||
chart_service: FakeChartService | None = None,
|
||||
) -> TestClient:
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_selection_service] = lambda: service
|
||||
if chart_service is not None:
|
||||
app.dependency_overrides[get_selection_chart_service] = lambda: chart_service
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@@ -295,12 +341,21 @@ def test_query_returns_persisted_signal_details() -> None:
|
||||
),
|
||||
{
|
||||
"status": "below_threshold",
|
||||
"value": None,
|
||||
"value": 42.5,
|
||||
"threshold": 60.0,
|
||||
"version": PATTERN_SCORING_VERSION,
|
||||
"case": None,
|
||||
"breakdown": None,
|
||||
"reason": "未匹配到评分阈值以上案例",
|
||||
"case": {
|
||||
"id": "case_001",
|
||||
"name": "华纳药厂",
|
||||
"breakout_date": "2025-05-12",
|
||||
},
|
||||
"breakdown": {
|
||||
"trend_structure": 40.0,
|
||||
"kdj_state": 42.0,
|
||||
"volume_pattern": 43.0,
|
||||
"price_shape": 44.0,
|
||||
},
|
||||
"reason": None,
|
||||
},
|
||||
),
|
||||
(
|
||||
@@ -370,6 +425,70 @@ def test_query_rejects_invalid_page_size() -> None:
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_chart_returns_bounded_qfq_contract() -> None:
|
||||
chart_service = FakeChartService()
|
||||
|
||||
response = _client(FakeSelectionService(), chart_service).get(
|
||||
"/api/v1/selection/stocks/000001.SZ/chart",
|
||||
params={"target_trade_date": "2026-08-08"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert chart_service.request == ("000001.SZ", TARGET)
|
||||
assert response.json() == {
|
||||
"ts_code": "000001.SZ",
|
||||
"name": "平安银行",
|
||||
"target_trade_date": "2026-08-08",
|
||||
"source_adj": "qfq",
|
||||
"points": [
|
||||
{
|
||||
"trade_date": "2026-08-08",
|
||||
"open": 10.0,
|
||||
"high": 11.0,
|
||||
"low": 9.5,
|
||||
"close": 10.5,
|
||||
"volume": 1000.0,
|
||||
"k": 52.0,
|
||||
"d": 48.0,
|
||||
"j": 60.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "status_code", "error_code"),
|
||||
[
|
||||
("missing", 404, "chart_data_not_found"),
|
||||
("storage_error", 503, "selection_storage_unavailable"),
|
||||
],
|
||||
)
|
||||
def test_chart_maps_application_errors(
|
||||
mode: str,
|
||||
status_code: int,
|
||||
error_code: str,
|
||||
) -> None:
|
||||
chart_service = FakeChartService()
|
||||
chart_service.mode = mode
|
||||
|
||||
response = _client(FakeSelectionService(), chart_service).get(
|
||||
"/api/v1/selection/stocks/000001.SZ/chart",
|
||||
params={"target_trade_date": "2026-08-08"},
|
||||
)
|
||||
|
||||
assert response.status_code == status_code
|
||||
assert response.json()["detail"]["code"] == error_code
|
||||
|
||||
|
||||
def test_chart_requires_a_valid_target_date() -> None:
|
||||
response = _client(FakeSelectionService(), FakeChartService()).get(
|
||||
"/api/v1/selection/stocks/000001.SZ/chart",
|
||||
params={"target_trade_date": "not-a-date"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_query_forwards_score_ascending_sort() -> None:
|
||||
service = FakeSelectionService(_run("run-http", "success"))
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Application tests for bounded qfq chart series."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from zhixing_server.modules.selection.application.chart import (
|
||||
GetSelectionChart,
|
||||
SelectionChartNotFound,
|
||||
)
|
||||
from zhixing_server.modules.selection.domain.indicators import compute_kdj
|
||||
from zhixing_server.modules.selection.domain.models import SelectionBar, StockHistory
|
||||
|
||||
|
||||
class FakeReader:
|
||||
"""Return one deterministic history through the requested date."""
|
||||
|
||||
def __init__(self, history: StockHistory) -> None:
|
||||
self.history = history
|
||||
self.request: tuple[str, date] | None = None
|
||||
|
||||
def load_history(self, ts_code: str, target_trade_date: date) -> StockHistory:
|
||||
self.request = (ts_code, target_trade_date)
|
||||
return self.history
|
||||
|
||||
|
||||
def _history(count: int) -> StockHistory:
|
||||
start = date(2025, 1, 1)
|
||||
bars = tuple(
|
||||
SelectionBar(
|
||||
trade_date=start + timedelta(days=index),
|
||||
open=10.0 + index / 100,
|
||||
high=11.0 + index / 100,
|
||||
low=9.0 + index / 100,
|
||||
close=10.5 + index / 100,
|
||||
volume=1000.0 + index,
|
||||
)
|
||||
for index in range(count)
|
||||
)
|
||||
return StockHistory(ts_code="000001.SZ", name="平安银行", bars=bars)
|
||||
|
||||
|
||||
def test_chart_computes_on_full_history_before_returning_last_250_points() -> None:
|
||||
history = _history(260)
|
||||
reader = FakeReader(history)
|
||||
target = history.bars[-1].trade_date
|
||||
|
||||
chart = GetSelectionChart(reader).execute(history.ts_code, target)
|
||||
|
||||
assert reader.request == ("000001.SZ", target)
|
||||
assert chart.source_adj == "qfq"
|
||||
assert len(chart.points) == 250
|
||||
assert chart.points[0].trade_date == history.bars[10].trade_date
|
||||
assert chart.points[-1].trade_date == target
|
||||
frame = pd.DataFrame(
|
||||
{
|
||||
"low": [bar.low for bar in history.bars],
|
||||
"high": [bar.high for bar in history.bars],
|
||||
"close": [bar.close for bar in history.bars],
|
||||
}
|
||||
)
|
||||
expected = compute_kdj(frame).iloc[-1]
|
||||
assert chart.points[-1].k == pytest.approx(float(expected["K"]))
|
||||
assert chart.points[-1].d == pytest.approx(float(expected["D"]))
|
||||
assert chart.points[-1].j == pytest.approx(float(expected["J"]))
|
||||
|
||||
|
||||
def test_chart_filters_future_rows_and_preserves_nullable_points() -> None:
|
||||
history = _history(12)
|
||||
target = history.bars[-2].trade_date
|
||||
incomplete = SelectionBar(
|
||||
trade_date=history.bars[3].trade_date,
|
||||
open=history.bars[3].open,
|
||||
high=None,
|
||||
low=history.bars[3].low,
|
||||
close=None,
|
||||
volume=None,
|
||||
)
|
||||
history = StockHistory(
|
||||
ts_code=history.ts_code,
|
||||
name=history.name,
|
||||
bars=history.bars[:3] + (incomplete,) + history.bars[4:],
|
||||
)
|
||||
|
||||
chart = GetSelectionChart(FakeReader(history)).execute(history.ts_code, target)
|
||||
|
||||
assert chart.points[-1].trade_date == target
|
||||
assert all(point.trade_date <= target for point in chart.points)
|
||||
assert chart.points[3].high is None
|
||||
assert chart.points[3].volume is None
|
||||
assert chart.points[3].k is None
|
||||
assert chart.points[3].d is None
|
||||
assert chart.points[3].j is None
|
||||
|
||||
|
||||
def test_chart_rejects_empty_history() -> None:
|
||||
reader = FakeReader(StockHistory(ts_code="000001.SZ", name="平安银行"))
|
||||
|
||||
with pytest.raises(SelectionChartNotFound, match="chart data not found"):
|
||||
GetSelectionChart(reader).execute("000001.SZ", date(2026, 8, 8))
|
||||
Reference in New Issue
Block a user