feat(selection): 新增个股技术图表接口
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
"""Read-only application use case for one selected stock's chart series."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from math import isfinite
|
||||
from typing import Literal, cast
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from ..domain.indicators import compute_kdj
|
||||
from ..domain.ports import MarketDataReader
|
||||
|
||||
SELECTION_CHART_LIMIT = 250
|
||||
|
||||
|
||||
class SelectionChartNotFound(LookupError):
|
||||
"""No qfq daily history exists through the requested target date."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SelectionChartPoint:
|
||||
"""One date-aligned OHLCV and KDJ point in the public chart vocabulary."""
|
||||
|
||||
trade_date: date
|
||||
open: float | None
|
||||
high: float | None
|
||||
low: float | None
|
||||
close: float | None
|
||||
volume: float | None
|
||||
k: float | None
|
||||
d: float | None
|
||||
j: float | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SelectionChart:
|
||||
"""A bounded ascending qfq chart series for one stock and target date."""
|
||||
|
||||
ts_code: str
|
||||
name: str
|
||||
target_trade_date: date
|
||||
source_adj: Literal["qfq"]
|
||||
points: tuple[SelectionChartPoint, ...]
|
||||
|
||||
|
||||
class GetSelectionChart:
|
||||
"""Load one qfq history, compute KDJ, then bound the response series."""
|
||||
|
||||
def __init__(self, reader: MarketDataReader) -> None:
|
||||
"""Inject the market-data reader owned by the selection context."""
|
||||
|
||||
self.reader = reader
|
||||
|
||||
def execute(self, ts_code: str, target_trade_date: date) -> SelectionChart:
|
||||
"""Return at most 250 points without changing KDJ's full-history state.
|
||||
|
||||
Args:
|
||||
ts_code: Tushare stock identifier selected by the user.
|
||||
target_trade_date: Inclusive historical boundary for the chart.
|
||||
|
||||
Returns:
|
||||
An ascending qfq chart series aligned by trade date.
|
||||
|
||||
Raises:
|
||||
SelectionChartNotFound: If no qfq bars exist through the target date.
|
||||
MarketDataReaderError: If the injected reader cannot complete the read.
|
||||
"""
|
||||
|
||||
history = self.reader.load_history(ts_code, target_trade_date)
|
||||
bars = tuple(bar for bar in history.bars if bar.trade_date <= target_trade_date)
|
||||
if not bars:
|
||||
raise SelectionChartNotFound(
|
||||
f"chart data not found for {ts_code} at {target_trade_date.isoformat()}"
|
||||
)
|
||||
|
||||
frame = pd.DataFrame(
|
||||
{
|
||||
"low": [bar.low for bar in bars],
|
||||
"high": [bar.high for bar in bars],
|
||||
"close": [bar.close for bar in bars],
|
||||
}
|
||||
)
|
||||
kdj = compute_kdj(frame)
|
||||
start = max(0, len(bars) - SELECTION_CHART_LIMIT)
|
||||
points = tuple(
|
||||
SelectionChartPoint(
|
||||
trade_date=bar.trade_date,
|
||||
open=bar.open,
|
||||
high=bar.high,
|
||||
low=bar.low,
|
||||
close=bar.close,
|
||||
volume=bar.volume,
|
||||
k=_finite_or_none(kdj.iloc[index]["K"]),
|
||||
d=_finite_or_none(kdj.iloc[index]["D"]),
|
||||
j=_finite_or_none(kdj.iloc[index]["J"]),
|
||||
)
|
||||
for index, bar in enumerate(bars)
|
||||
if index >= start
|
||||
)
|
||||
return SelectionChart(
|
||||
ts_code=history.ts_code,
|
||||
name=history.name,
|
||||
target_trade_date=target_trade_date,
|
||||
source_adj="qfq",
|
||||
points=points,
|
||||
)
|
||||
|
||||
|
||||
def _finite_or_none(value: object) -> float | None:
|
||||
"""Convert one Pandas scalar to a finite JSON-safe float or ``None``."""
|
||||
|
||||
try:
|
||||
number = float(cast(float, value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return number if isfinite(number) else None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"GetSelectionChart",
|
||||
"SELECTION_CHART_LIMIT",
|
||||
"SelectionChart",
|
||||
"SelectionChartNotFound",
|
||||
"SelectionChartPoint",
|
||||
]
|
||||
@@ -9,6 +9,11 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, s
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from zhixing_server.bootstrap.config import Settings, get_settings
|
||||
from zhixing_server.modules.selection.application.chart import (
|
||||
GetSelectionChart,
|
||||
SelectionChart,
|
||||
SelectionChartNotFound,
|
||||
)
|
||||
from zhixing_server.modules.selection.application.run import (
|
||||
RunZhixingB1,
|
||||
)
|
||||
@@ -17,6 +22,7 @@ from zhixing_server.modules.selection.domain.pattern_scoring import (
|
||||
PatternScore,
|
||||
ZhixingB1PatternScorer,
|
||||
)
|
||||
from zhixing_server.modules.selection.domain.ports import MarketDataReaderError
|
||||
from zhixing_server.modules.selection.domain.runs import (
|
||||
SelectionRerunRequired,
|
||||
SelectionResultQuery,
|
||||
@@ -140,6 +146,30 @@ class SelectionStockResponse(BaseModel):
|
||||
signals: list[SelectionSignalResponse] = Field(default_factory=_empty_signals)
|
||||
|
||||
|
||||
class SelectionChartPointResponse(BaseModel):
|
||||
"""One date-aligned qfq OHLCV and KDJ point."""
|
||||
|
||||
trade_date: date
|
||||
open: float | None
|
||||
high: float | None
|
||||
low: float | None
|
||||
close: float | None
|
||||
volume: float | None
|
||||
k: float | None
|
||||
d: float | None
|
||||
j: float | None
|
||||
|
||||
|
||||
class SelectionChartResponse(BaseModel):
|
||||
"""A bounded ascending chart series for one selected stock."""
|
||||
|
||||
ts_code: str
|
||||
name: str
|
||||
target_trade_date: date
|
||||
source_adj: Literal["qfq"]
|
||||
points: list[SelectionChartPointResponse]
|
||||
|
||||
|
||||
def _empty_stocks() -> list[SelectionStockResponse]:
|
||||
"""Create a typed default stock-result list."""
|
||||
|
||||
@@ -192,6 +222,15 @@ def get_selection_service(
|
||||
)
|
||||
|
||||
|
||||
def get_selection_chart_service(
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> GetSelectionChart:
|
||||
"""Build the read-only chart use case on the shared selection pool."""
|
||||
|
||||
pool = get_selection_postgres_pool(settings)
|
||||
return GetSelectionChart(PostgresMarketDataReader(settings, pool=pool))
|
||||
|
||||
|
||||
def get_selection_postgres_pool(settings: Settings) -> SelectionPostgresPool:
|
||||
"""Return the cached bounded pool shared by selection adapters."""
|
||||
|
||||
@@ -220,6 +259,26 @@ def _close_cached_selection_pools() -> None:
|
||||
atexit.register(_close_cached_selection_pools)
|
||||
|
||||
|
||||
@selection_router.get(
|
||||
"/stocks/{ts_code}/chart",
|
||||
response_model=SelectionChartResponse,
|
||||
)
|
||||
def get_selection_chart(
|
||||
ts_code: str,
|
||||
target_trade_date: date,
|
||||
service: Annotated[GetSelectionChart, Depends(get_selection_chart_service)],
|
||||
) -> SelectionChartResponse:
|
||||
"""Return one stock's bounded qfq OHLCV and KDJ history."""
|
||||
|
||||
try:
|
||||
chart = service.execute(ts_code, target_trade_date)
|
||||
except SelectionChartNotFound as exc:
|
||||
raise _http_error(404, "chart_data_not_found", str(exc)) from exc
|
||||
except MarketDataReaderError as exc:
|
||||
raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc
|
||||
return _chart_response(chart)
|
||||
|
||||
|
||||
@selection_router.post(
|
||||
"/runs",
|
||||
response_model=SelectionRunAcceptedResponse,
|
||||
@@ -387,13 +446,6 @@ def _pattern_score_response(score: PatternScore) -> SelectionPatternScoreRespons
|
||||
return None
|
||||
if score.status == "failed":
|
||||
return SelectionPatternScoreResponse(status="failed", reason=score.reason)
|
||||
if score.status == "below_threshold":
|
||||
return SelectionPatternScoreResponse(
|
||||
status="below_threshold",
|
||||
threshold=score.threshold,
|
||||
version=score.version,
|
||||
reason="未匹配到评分阈值以上案例",
|
||||
)
|
||||
if score.case is None or score.breakdown is None:
|
||||
return SelectionPatternScoreResponse(status="failed", reason="评分结果不完整")
|
||||
return SelectionPatternScoreResponse(
|
||||
@@ -411,6 +463,31 @@ def _pattern_score_response(score: PatternScore) -> SelectionPatternScoreRespons
|
||||
)
|
||||
|
||||
|
||||
def _chart_response(chart: SelectionChart) -> SelectionChartResponse:
|
||||
"""Translate an application chart without leaking domain dataclasses."""
|
||||
|
||||
return SelectionChartResponse(
|
||||
ts_code=chart.ts_code,
|
||||
name=chart.name,
|
||||
target_trade_date=chart.target_trade_date,
|
||||
source_adj=chart.source_adj,
|
||||
points=[
|
||||
SelectionChartPointResponse(
|
||||
trade_date=point.trade_date,
|
||||
open=point.open,
|
||||
high=point.high,
|
||||
low=point.low,
|
||||
close=point.close,
|
||||
volume=point.volume,
|
||||
k=point.k,
|
||||
d=point.d,
|
||||
j=point.j,
|
||||
)
|
||||
for point in chart.points
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _result_query(
|
||||
page: int,
|
||||
page_size: int,
|
||||
@@ -440,10 +517,12 @@ def _http_error(code: int, error_type: str, message: str) -> HTTPException:
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SelectionChartResponse",
|
||||
"SelectionResultsResponse",
|
||||
"SelectionRunAcceptedResponse",
|
||||
"SelectionRunRequest",
|
||||
"SelectionStockResponse",
|
||||
"get_selection_chart_service",
|
||||
"get_selection_service",
|
||||
"get_selection_postgres_pool",
|
||||
"selection_router",
|
||||
|
||||
Reference in New Issue
Block a user