MB-X Bilibili Pipeline
7 days ago 5c75bc6b3fe4eda9b1210574230d22249ecf949f
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
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,
        },
    )