from __future__ import annotations
|
|
import math
|
import time
|
from datetime import date, datetime, time as wall_time, timedelta, timezone
|
from decimal import Decimal
|
from typing import Any, Mapping, Sequence
|
|
import mysql.connector
|
|
from .core import LedgerError, PriceRecord
|
from .database import MySQLSettings, connect_market_readonly, safe_mysql_error
|
|
|
SHANGHAI = timezone(timedelta(hours=8))
|
FINAL_LOAD_TIME = wall_time(15, 5)
|
MARKET_SOURCE_ID = "trading_xuntou.cn_stock_kline_1d_front:xtquant:front"
|
REQUIRED_COLUMNS = {
|
"cn_stock_kline_1d_front": {
|
"id", "symbol", "trade_date", "close", "source", "updated_at"
|
},
|
"formal_trading_calendar_days": {
|
"market", "trade_date", "is_open", "source_kind", "source_fetch_time",
|
"source_version", "asset_version",
|
},
|
"formal_trading_calendar_assets": {
|
"asset_version", "market", "window_end", "generated_at", "status", "is_current",
|
},
|
}
|
|
|
def _market_schema_contract(connection, database: str) -> dict[str, Any]:
|
cursor = connection.cursor(dictionary=True)
|
try:
|
cursor.execute(
|
"SELECT TABLE_NAME, COLUMN_NAME, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_COMMENT "
|
"FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=%s AND TABLE_NAME IN "
|
"('cn_stock_kline_1d_front','formal_trading_calendar_days',"
|
"'formal_trading_calendar_assets')",
|
(database,),
|
)
|
rows = cursor.fetchall()
|
finally:
|
cursor.close()
|
actual = {name: set() for name in REQUIRED_COLUMNS}
|
for row in rows:
|
table = row["TABLE_NAME"]
|
if table in actual:
|
actual[table].add(row["COLUMN_NAME"])
|
missing = {
|
table: sorted(columns - actual[table])
|
for table, columns in REQUIRED_COLUMNS.items()
|
if columns - actual[table]
|
}
|
if missing:
|
raise LedgerError("E_MARKET_SCHEMA", f"行情 schema 缺少必需字段:{missing}")
|
return {
|
"price_table": "cn_stock_kline_1d_front",
|
"adjustment_semantics": "front_adjusted_dedicated_table",
|
"source_id": MARKET_SOURCE_ID,
|
"calendar_market": "SH",
|
"final_load_time": FINAL_LOAD_TIME.isoformat(timespec="minutes"),
|
}
|
|
|
def _as_datetime(value: Any, field: str) -> datetime:
|
if isinstance(value, datetime):
|
return value
|
if isinstance(value, str):
|
try:
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
except ValueError as exc:
|
raise LedgerError("E_MARKET_ROW", f"{field} 不是有效时间") from exc
|
raise LedgerError("E_MARKET_ROW", f"{field} 缺失")
|
|
|
def _as_date(value: Any, field: str) -> date:
|
if isinstance(value, datetime):
|
return value.date()
|
if isinstance(value, date):
|
return value
|
if isinstance(value, str):
|
try:
|
return date.fromisoformat(value[:10])
|
except ValueError as exc:
|
raise LedgerError("E_MARKET_ROW", f"{field} 不是有效日期") from exc
|
raise LedgerError("E_MARKET_ROW", f"{field} 缺失")
|
|
|
def _record_from_row(
|
row: Mapping[str, Any],
|
security: Mapping[str, str],
|
as_of: date,
|
process_start: datetime,
|
) -> PriceRecord:
|
ticker = str(security["ticker"])
|
symbol = str(row.get("symbol") or "")
|
if symbol != ticker:
|
raise LedgerError("E_MARKET_IDENTITY", "行情代码与证券代码不一致")
|
suffix = ticker.rsplit(".", 1)[-1]
|
if suffix not in {"SH", "SZ", "BJ"}:
|
raise LedgerError("E_MARKET_UNSUPPORTED", "trading_xuntou 首版只覆盖 A 股")
|
if security.get("currency") != "CNY":
|
raise LedgerError("E_CURRENCY", "A 股行情必须使用 CNY")
|
trade_date = _as_date(row.get("trade_date"), "trade_date")
|
if trade_date > as_of:
|
raise LedgerError("E_ASOF", "行情日期晚于请求 as-of")
|
try:
|
close = float(Decimal(str(row.get("close"))))
|
except (ValueError, TypeError):
|
close = math.nan
|
if not math.isfinite(close) or close <= 0:
|
raise LedgerError("E_PRICE", "收盘价必须是有限正数")
|
if row.get("is_open") not in (1, True):
|
raise LedgerError("E_INCOMPLETE_TRADING_DAY", "正式交易日历未证明该日开市")
|
if row.get("asset_status") != "current" or row.get("asset_is_current") not in (1, True):
|
raise LedgerError("E_INCOMPLETE_TRADING_DAY", "正式交易日历资产不是 current")
|
window_end = _as_date(row.get("window_end"), "calendar.window_end")
|
if window_end < as_of:
|
raise LedgerError("E_INCOMPLETE_TRADING_DAY", "正式交易日历水位未覆盖请求 as-of")
|
source_time = _as_datetime(row.get("updated_at"), "updated_at")
|
if source_time.tzinfo is None:
|
source_time = source_time.replace(tzinfo=SHANGHAI)
|
else:
|
source_time = source_time.astimezone(SHANGHAI)
|
completed_after = datetime.combine(trade_date, FINAL_LOAD_TIME, SHANGHAI)
|
process_local = process_start.astimezone(SHANGHAI)
|
if source_time < completed_after or (
|
trade_date == process_local.date() and process_local < completed_after
|
):
|
raise LedgerError("E_INCOMPLETE_TRADING_DAY", "日 K 尚无收盘后最终装载水位")
|
source = str(row.get("source") or "")
|
calendar_source = str(row.get("calendar_source_kind") or "").strip()
|
if source != "xtquant" or not calendar_source:
|
raise LedgerError("E_SOURCE", "前复权行情来源必须为 xtquant 且交易日历来源不得缺失")
|
return PriceRecord(
|
ticker=ticker,
|
trade_date=trade_date.isoformat(),
|
close=close,
|
currency="CNY",
|
source_id=MARKET_SOURCE_ID,
|
source_timestamp=source_time.isoformat(),
|
is_complete_trading_day=True,
|
)
|
|
|
def fetch_trading_closes(
|
settings: MySQLSettings,
|
securities: Sequence[dict[str, str]],
|
as_of: str,
|
*,
|
process_start: datetime | None = None,
|
) -> tuple[list[PriceRecord], list[dict[str, str]], dict[str, Any]]:
|
cutoff = date.fromisoformat(as_of)
|
process_start = process_start or datetime.now().astimezone()
|
if process_start.tzinfo is None:
|
raise ValueError("process_start 必须带时区")
|
started = time.monotonic()
|
failures: list[dict[str, str]] = []
|
supported: list[dict[str, str]] = []
|
for item in securities:
|
if str(item["ticker"]).endswith((".SH", ".SZ", ".BJ")):
|
supported.append(item)
|
else:
|
failures.append(
|
{"ticker": item["ticker"], "code": "E_MARKET_UNSUPPORTED", "message": "trading_xuntou 首版只覆盖 A 股"}
|
)
|
connection = connect_market_readonly(settings)
|
cursor = connection.cursor(dictionary=True)
|
try:
|
contract = _market_schema_contract(connection, settings.market_database)
|
rows: list[dict[str, Any]] = []
|
if supported:
|
placeholders = ",".join(["%s"] * len(supported))
|
query = f"""
|
WITH expected AS (
|
SELECT MAX(c.trade_date) AS trade_date
|
FROM formal_trading_calendar_assets a
|
JOIN formal_trading_calendar_days c
|
ON c.asset_version=a.asset_version AND c.market=a.market
|
WHERE a.market='SH' AND a.status='current' AND a.is_current=1
|
AND a.window_end >= %s
|
AND c.is_open=1 AND c.trade_date<=%s
|
), ranked AS (
|
SELECT k.symbol, k.trade_date, k.close, k.source,
|
k.updated_at,
|
ROW_NUMBER() OVER (
|
PARTITION BY k.symbol ORDER BY k.trade_date DESC, k.updated_at DESC, k.id DESC
|
) AS rn
|
FROM cn_stock_kline_1d_front k
|
WHERE k.symbol IN ({placeholders})
|
AND k.trade_date <= %s
|
)
|
SELECT r.symbol, r.trade_date, r.close, r.source, r.updated_at,
|
c.is_open, c.source_kind AS calendar_source_kind,
|
a.window_end, a.status AS asset_status, a.is_current AS asset_is_current
|
FROM ranked r
|
JOIN formal_trading_calendar_assets a
|
ON a.market='SH' AND a.status='current' AND a.is_current=1
|
AND a.window_end >= %s
|
JOIN formal_trading_calendar_days c
|
ON c.asset_version=a.asset_version AND c.market=a.market AND c.trade_date=r.trade_date
|
CROSS JOIN expected e
|
WHERE r.rn=1 AND r.trade_date=e.trade_date
|
"""
|
cursor.execute(
|
query,
|
[cutoff, cutoff]
|
+ [item["ticker"] for item in supported]
|
+ [cutoff, cutoff],
|
)
|
rows = cursor.fetchall()
|
by_ticker = {str(row["symbol"]): row for row in rows}
|
prices: list[PriceRecord] = []
|
for security in supported:
|
ticker = str(security["ticker"])
|
row = by_ticker.get(ticker)
|
if row is None:
|
failures.append(
|
{"ticker": ticker, "code": "E_MARKET_NO_PROVEN_ROW", "message": "没有匹配请求日历水位且证据完整的前复权日 K"}
|
)
|
continue
|
try:
|
prices.append(_record_from_row(row, security, cutoff, process_start))
|
except LedgerError as exc:
|
failures.append({"ticker": ticker, "code": exc.code, "message": str(exc)})
|
connection.rollback()
|
except mysql.connector.Error as exc:
|
connection.rollback()
|
raise LedgerError("E_MARKET_DATABASE", safe_mysql_error(exc)) from exc
|
finally:
|
cursor.close()
|
connection.close()
|
return (
|
sorted(prices, key=lambda item: item.ticker),
|
sorted(failures, key=lambda item: item["ticker"]),
|
{
|
"provider": MARKET_SOURCE_ID,
|
"requested": len(securities),
|
"succeeded": len(prices),
|
"failed": len(failures),
|
"elapsed_seconds": time.monotonic() - started,
|
"contract": contract,
|
"select_only": True,
|
},
|
)
|