feat(selection): 迁移知行B1选股策略
This commit is contained in:
+158
@@ -0,0 +1,158 @@
|
||||
"""Read-only PostgreSQL adapter for selection history."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import cast
|
||||
|
||||
import psycopg
|
||||
|
||||
from ....bootstrap.config import Settings
|
||||
from ..domain.models import SelectionBar, SelectionDailyBasic, StockHistory
|
||||
from ..domain.ports import MarketDataReaderError
|
||||
|
||||
|
||||
class SelectionReaderError(MarketDataReaderError):
|
||||
"""Database read failure with stock and target-date context."""
|
||||
|
||||
|
||||
_HISTORY_QUERY = """
|
||||
SELECT
|
||||
bar.ts_code,
|
||||
stock.name,
|
||||
bar.trade_date,
|
||||
bar.open,
|
||||
bar.high,
|
||||
bar.low,
|
||||
bar.close,
|
||||
bar.vol,
|
||||
basic.turnover_rate,
|
||||
basic.total_mv
|
||||
FROM market_daily_bar AS bar
|
||||
LEFT JOIN market_stock AS stock
|
||||
ON stock.ts_code = bar.ts_code
|
||||
LEFT JOIN market_daily_basic AS basic
|
||||
ON basic.ts_code = bar.ts_code
|
||||
AND basic.trade_date = bar.trade_date
|
||||
WHERE bar.ts_code = %s
|
||||
AND bar.source_adj = 'qfq'
|
||||
AND bar.trade_date <= %s
|
||||
ORDER BY bar.trade_date ASC
|
||||
"""
|
||||
|
||||
|
||||
def _as_date(value: object) -> date:
|
||||
"""Convert a PostgreSQL date-like scalar to a date."""
|
||||
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
return date.fromisoformat(str(value)[:10])
|
||||
|
||||
|
||||
def _as_float(value: object) -> float | None:
|
||||
"""Convert nullable PostgreSQL numerics to finite floats."""
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
number = Decimal(str(value))
|
||||
except (InvalidOperation, ValueError) as exc:
|
||||
raise ValueError(f"invalid market-data numeric value: {value!r}") from exc
|
||||
if number.is_nan():
|
||||
return None
|
||||
if not number.is_finite():
|
||||
raise ValueError(f"market-data numeric value must be finite: {value!r}")
|
||||
return float(number)
|
||||
|
||||
|
||||
class PostgresMarketDataReader:
|
||||
"""Load qfq bars and same-day basic facts without writing market data."""
|
||||
|
||||
def __init__(self, settings: Settings | str) -> None:
|
||||
"""Create a reader from injected settings or a compatible URL string."""
|
||||
|
||||
self.database_url = settings.database_url if isinstance(settings, Settings) else settings
|
||||
|
||||
def load_history(self, ts_code: str, target_trade_date: date) -> StockHistory:
|
||||
"""Read all retained qfq rows through the explicit target date.
|
||||
|
||||
Args:
|
||||
ts_code: Tushare stock identifier.
|
||||
target_trade_date: Historical date to which rows are truncated.
|
||||
|
||||
Returns:
|
||||
A sorted ``StockHistory``. An empty history is a normal missing
|
||||
target-data result and is interpreted by the application layer.
|
||||
|
||||
Raises:
|
||||
SelectionReaderError: If PostgreSQL cannot complete the read.
|
||||
ValueError: If a returned date or numeric field is malformed.
|
||||
"""
|
||||
|
||||
try:
|
||||
with psycopg.connect(self.database_url) as connection:
|
||||
rows = connection.execute(
|
||||
_HISTORY_QUERY,
|
||||
(ts_code, target_trade_date),
|
||||
).fetchall()
|
||||
except psycopg.Error as exc:
|
||||
raise SelectionReaderError(
|
||||
f"failed to load market history for {ts_code} at {target_trade_date.isoformat()}"
|
||||
) from exc
|
||||
|
||||
bars: dict[date, SelectionBar] = {}
|
||||
daily_basic: dict[date, SelectionDailyBasic] = {}
|
||||
name = ""
|
||||
for raw_row in rows:
|
||||
row = cast(tuple[object, ...], raw_row)
|
||||
row_code, row_name, bar, basic = self._map_row(row, ts_code)
|
||||
if row_code != ts_code:
|
||||
raise ValueError(f"reader returned unexpected stock code: {row_code}")
|
||||
name = row_name or name
|
||||
if bar.trade_date <= target_trade_date:
|
||||
bars[bar.trade_date] = bar
|
||||
daily_basic[bar.trade_date] = basic
|
||||
return StockHistory(
|
||||
ts_code=ts_code,
|
||||
name=name,
|
||||
bars=tuple(bars[trade_date] for trade_date in sorted(bars)),
|
||||
daily_basic={trade_date: daily_basic[trade_date] for trade_date in sorted(daily_basic)},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _map_row(
|
||||
row: tuple[object, ...],
|
||||
expected_code: str,
|
||||
) -> tuple[str, str, SelectionBar, SelectionDailyBasic]:
|
||||
"""Map the current query row, tolerating a legacy test row without name."""
|
||||
|
||||
if len(row) >= 10:
|
||||
code, raw_name, raw_date = row[0], row[1], row[2]
|
||||
values = row[3:]
|
||||
elif len(row) >= 9:
|
||||
code, raw_name, raw_date = row[0], "", row[1]
|
||||
values = row[2:]
|
||||
else:
|
||||
raise ValueError("market history row has too few columns")
|
||||
row_code = str(code or expected_code)
|
||||
name = str(raw_name or "")
|
||||
trade_date = _as_date(raw_date)
|
||||
if len(values) < 7:
|
||||
raise ValueError("market history row is missing OHLCV/basic columns")
|
||||
bar = SelectionBar(
|
||||
trade_date=trade_date,
|
||||
open=_as_float(values[0]),
|
||||
high=_as_float(values[1]),
|
||||
low=_as_float(values[2]),
|
||||
close=_as_float(values[3]),
|
||||
volume=_as_float(values[4]),
|
||||
)
|
||||
basic = SelectionDailyBasic(
|
||||
trade_date=trade_date,
|
||||
turnover_rate=_as_float(values[5]),
|
||||
total_mv=_as_float(values[6]),
|
||||
)
|
||||
return row_code, name, bar, basic
|
||||
Reference in New Issue
Block a user