2026-08-08 22:41:45 +08:00
|
|
|
"""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
|
2026-08-09 09:34:46 +08:00
|
|
|
from ..domain.runs import SelectionExecutionSource, SelectionStock
|
2026-08-08 22:41:45 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class SelectionReaderError(MarketDataReaderError):
|
|
|
|
|
"""Database read failure with stock and target-date context."""
|
|
|
|
|
|
|
|
|
|
|
2026-08-09 09:34:46 +08:00
|
|
|
class SelectionMarketDataNotReady(MarketDataReaderError):
|
|
|
|
|
"""The requested date has no market-data batch eligible for selection."""
|
|
|
|
|
|
|
|
|
|
|
2026-08-08 22:41:45 +08:00
|
|
|
_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
|
|
|
|
|
"""
|
|
|
|
|
|
2026-08-09 09:34:46 +08:00
|
|
|
_SOURCE_QUERY = """
|
|
|
|
|
SELECT id, target_count, valid_count, coverage
|
|
|
|
|
FROM market_sync_batch
|
|
|
|
|
WHERE target_trade_date = %s
|
|
|
|
|
AND strategy_eligible = true
|
|
|
|
|
AND status IN ('success', 'partial_success')
|
|
|
|
|
ORDER BY finished_at DESC NULLS LAST, created_at DESC, id DESC
|
|
|
|
|
LIMIT 1
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
_ELIGIBLE_STOCKS_QUERY = """
|
|
|
|
|
SELECT stock.ts_code, stock.name
|
|
|
|
|
FROM market_stock AS stock
|
|
|
|
|
WHERE stock.is_active = true
|
|
|
|
|
AND EXISTS (
|
|
|
|
|
SELECT 1
|
|
|
|
|
FROM market_daily_bar AS bar
|
|
|
|
|
WHERE bar.ts_code = stock.ts_code
|
|
|
|
|
AND bar.trade_date = %s
|
|
|
|
|
AND bar.source_adj = 'qfq'
|
|
|
|
|
AND bar.open IS NOT NULL
|
|
|
|
|
AND bar.high IS NOT NULL
|
|
|
|
|
AND bar.low IS NOT NULL
|
|
|
|
|
AND bar.close IS NOT NULL
|
|
|
|
|
AND bar.vol IS NOT NULL
|
|
|
|
|
)
|
|
|
|
|
AND EXISTS (
|
|
|
|
|
SELECT 1
|
|
|
|
|
FROM market_daily_basic AS basic
|
|
|
|
|
WHERE basic.ts_code = stock.ts_code
|
|
|
|
|
AND basic.trade_date = %s
|
|
|
|
|
)
|
|
|
|
|
ORDER BY stock.ts_code
|
|
|
|
|
"""
|
|
|
|
|
|
2026-08-08 22:41:45 +08:00
|
|
|
|
|
|
|
|
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)},
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-09 09:34:46 +08:00
|
|
|
def load_execution_source(
|
|
|
|
|
self,
|
|
|
|
|
strategy: str,
|
|
|
|
|
target_trade_date: date,
|
|
|
|
|
) -> SelectionExecutionSource:
|
|
|
|
|
"""Load the qualified market-data snapshot for a strategy run.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
strategy: Supported strategy identity. The current reader accepts
|
|
|
|
|
``zhixing_b1`` and keeps the parameter explicit for future
|
|
|
|
|
strategy-specific eligibility rules.
|
|
|
|
|
target_trade_date: Historical trading date to evaluate.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
The eligible stock snapshot and its source synchronization facts.
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
SelectionMarketDataNotReady: If no eligible synchronization batch
|
|
|
|
|
or complete active stock exists for the requested date.
|
|
|
|
|
SelectionReaderError: If PostgreSQL cannot complete the read.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
if strategy != "zhixing_b1":
|
|
|
|
|
raise SelectionMarketDataNotReady(f"unsupported selection strategy: {strategy}")
|
|
|
|
|
try:
|
|
|
|
|
with psycopg.connect(self.database_url) as connection:
|
|
|
|
|
source_row = connection.execute(_SOURCE_QUERY, (target_trade_date,)).fetchone()
|
|
|
|
|
if source_row is None:
|
|
|
|
|
raise SelectionMarketDataNotReady(
|
|
|
|
|
f"market data is not strategy-eligible for {target_trade_date.isoformat()}"
|
|
|
|
|
)
|
|
|
|
|
stock_rows = connection.execute(
|
|
|
|
|
_ELIGIBLE_STOCKS_QUERY,
|
|
|
|
|
(target_trade_date, target_trade_date),
|
|
|
|
|
).fetchall()
|
|
|
|
|
except SelectionMarketDataNotReady:
|
|
|
|
|
raise
|
|
|
|
|
except psycopg.Error as exc:
|
|
|
|
|
raise SelectionReaderError(
|
|
|
|
|
f"failed to load selection source at {target_trade_date.isoformat()}"
|
|
|
|
|
) from exc
|
|
|
|
|
|
|
|
|
|
stocks = tuple(
|
|
|
|
|
SelectionStock(ts_code=str(row[0]), name=str(row[1] or "")) for row in stock_rows
|
|
|
|
|
)
|
|
|
|
|
if not stocks:
|
|
|
|
|
raise SelectionMarketDataNotReady(
|
|
|
|
|
f"no eligible stocks have complete market data for {target_trade_date.isoformat()}"
|
|
|
|
|
)
|
|
|
|
|
return SelectionExecutionSource(
|
|
|
|
|
market_sync_batch_id=str(source_row[0]),
|
|
|
|
|
target_trade_date=target_trade_date,
|
|
|
|
|
target_count=int(source_row[1]),
|
|
|
|
|
valid_count=int(source_row[2]),
|
|
|
|
|
coverage=Decimal(str(source_row[3])),
|
|
|
|
|
stocks=stocks,
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-08 22:41:45 +08:00
|
|
|
@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
|