rebuild(runtime): govern market operations and job truth

This commit is contained in:
leefer
2026-07-30 10:14:29 +08:00
parent 4fc8691eee
commit d8f0dd930c
39 changed files with 2224 additions and 79 deletions
+9
View File
@@ -27,6 +27,13 @@ class MarketDataProvider(Protocol):
self, trade_date: str, previous_trade_date: str
) -> dict[str, ProviderResult | dict[str, Any]]: ...
def realtime_market_inputs(
self,
trade_date: str,
previous_trade_date: str,
identifiers: tuple[str, ...],
) -> dict[str, ProviderResult | dict[str, Any]]: ...
def sector_members(self, representative: str, trade_date: str) -> ProviderResult: ...
def heaven_inputs(
@@ -49,4 +56,6 @@ class MarketDataProvider(Protocol):
self, identifiers: tuple[str, ...], start_time: str, end_time: str
) -> ProviderResult: ...
def event_reasons(self, trade_date: str) -> ProviderResult: ...
def screener_inputs(self, trade_dates: tuple[str, ...]) -> dict[str, ProviderResult | None]: ...
+11
View File
@@ -99,6 +99,14 @@ class EastmoneyProvider:
) -> dict[str, ProviderResult | dict[str, object]]:
raise ProviderError("The display provider cannot build market snapshots")
def realtime_market_inputs(
self,
trade_date: str,
previous_trade_date: str,
identifiers: tuple[str, ...],
) -> dict[str, ProviderResult | dict[str, object]]:
raise ProviderError("The display provider cannot build realtime market snapshots")
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
raise ProviderError("The display provider is not the constituent authority")
@@ -126,6 +134,9 @@ class EastmoneyProvider:
) -> ProviderResult:
raise ProviderError("The display provider cannot supply calculation snapshots")
def event_reasons(self, trade_date: str) -> ProviderResult:
raise ProviderError("The display provider cannot supply event reasons")
def screener_inputs(self, trade_dates: tuple[str, ...]) -> dict[str, ProviderResult | None]:
raise ProviderError("The display provider cannot supply screener factors")
+116
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import re
import threading
import urllib.error
import urllib.request
@@ -89,6 +90,14 @@ class IfindProvider:
) -> dict[str, ProviderResult | dict[str, Any]]:
raise ProviderError("iFinD is not the post-close snapshot authority")
def realtime_market_inputs(
self,
trade_date: str,
previous_trade_date: str,
identifiers: tuple[str, ...],
) -> dict[str, ProviderResult | dict[str, Any]]:
raise ProviderError("iFinD不承担全市场盘中快照计算")
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
raise ProviderError("iFinD is not the Shenwan constituent authority")
@@ -145,6 +154,76 @@ class IfindProvider:
),
)
def event_reasons(self, trade_date: str) -> ProviderResult:
date = datetime.fromisoformat(_display(trade_date)).date()
display = f"{date.year}{date.month}{date.day}"
queries = {
"limit_up": (
f"{display}涨停股票,股票代码、股票简称、涨停原因、"
"首次涨停时间、最终涨停时间、开板次数"
),
"broken": (
f"{display}曾涨停但收盘未涨停的股票,股票代码、股票简称、"
"涨停原因、首次涨停时间、开板次数"
),
"limit_down": f"{display}跌停股票,股票代码、股票简称、跌停原因",
}
rows: list[dict[str, Any]] = []
completed = 0
for event_type, query in queries.items():
try:
payload = self._request(
"smart_stock_picking",
{"searchstring": query, "searchtype": "stock"},
)
except ProviderError:
continue
completed += 1
for raw in _result(
payload, "event", "not_applicable", SnapshotState.FINAL
).rows:
identifier = _event_identifier(raw)
if not identifier:
continue
reason_tokens = (
("跌停原因", "风险线索", "原因")
if event_type == "limit_down"
else ("涨停原因类别", "涨停原因", "触板逻辑", "原因")
)
rows.append(
{
"event_type": event_type,
"identifier": identifier,
"reason": str(_event_field(raw, reason_tokens) or "").strip(),
"first_time": _event_time(
_event_field(
raw, ("首次涨停时间", "首次触板时间", "首次封板时间")
)
),
"last_time": _event_time(
_event_field(
raw, ("最终涨停时间", "最后涨停时间", "最后封板时间")
)
),
"open_times": _event_integer(
_event_field(raw, ("开板次数", "打开涨停次数"))
),
}
)
return ProviderResult(
tuple(rows),
ObservationMetadata(
source=self.source,
observed_at=datetime.now(SHANGHAI),
unit="event",
adjustment="not_applicable",
freshness_seconds=0,
coverage=completed / len(queries),
state=SnapshotState.FINAL,
usage=DataUsage.CALCULATION,
),
)
def screener_inputs(self, trade_dates: tuple[str, ...]) -> dict[str, ProviderResult | None]:
raise ProviderError("iFinD尚未批准用于盘后因子批量计算")
@@ -295,3 +374,40 @@ def _error_code(payload: dict[str, Any]) -> int:
return int(payload.get("errorcode", payload.get("code", 0)) or 0)
except (TypeError, ValueError):
return -1
def _event_field(row: dict[str, Any], tokens: tuple[str, ...]) -> Any:
for token in tokens:
for key, value in row.items():
if token in str(key) and value not in (None, ""):
return value
return None
def _event_identifier(row: dict[str, Any]) -> str:
value = _event_field(row, ("股票代码", "证券代码", "代码", "thscode"))
text = str(value or "").strip().upper()
match = re.search(r"(?<!\d)(\d{6})(?!\d)", text)
if not match:
return ""
code = match.group(1)
if re.fullmatch(r"\d{6}\.(?:SH|SZ|BJ)", text):
return text
suffix = "BJ" if code.startswith(("4", "8", "9")) else "SH" if code.startswith("6") else "SZ"
return f"{code}.{suffix}"
def _event_time(value: Any) -> str:
text = str(value or "").strip()
match = re.search(r"(?<!\d)(\d{1,2}):(\d{2})(?::\d{2})?(?!\d)", text)
if match:
return f"{int(match.group(1)):02d}:{match.group(2)}"
compact = re.search(r"(?<!\d)(\d{2})(\d{2})(\d{2})(?!\d)", text)
return f"{compact.group(1)}:{compact.group(2)}" if compact else ""
def _event_integer(value: Any) -> int | None:
try:
return max(0, int(float(value))) if value not in (None, "") else None
except (TypeError, ValueError):
return None
+62
View File
@@ -130,6 +130,59 @@ class TushareProvider:
)
return datasets
def realtime_market_inputs(
self,
trade_date: str,
previous_trade_date: str,
identifiers: tuple[str, ...],
) -> dict[str, ProviderResult | dict[str, Any]]:
current = _compact(trade_date)
previous = _compact(previous_trade_date)
quotes = self._query(
"rt_k",
{"ts_code": ",".join(identifiers)},
"",
unit="mixed",
)
normalized = tuple(
{
**row,
"trade_date": current,
"pct_chg": _change(row.get("close"), row.get("pre_close")),
"amount_unit": "yuan",
}
for row in quotes.rows
if _number(row.get("close")) > 0 and _number(row.get("pre_close")) > 0
)
daily = ProviderResult(
normalized,
replace(
quotes.metadata,
coverage=min(len(normalized) / max(len(identifiers), 1), 1),
state=SnapshotState.REALTIME,
),
)
return {
"daily": daily,
"price_limits": self._query(
"stk_limit",
{"trade_date": current},
"ts_code,trade_date,up_limit,down_limit",
unit="yuan/share",
),
"previous_limit_up": self._query(
"limit_list_d",
{"trade_date": previous, "limit_type": "U"},
(
"trade_date,ts_code,industry,name,close,pct_chg,amount,limit_amount,"
"float_mv,total_mv,turnover_ratio,fd_amount,first_time,last_time,"
"open_times,up_stat,limit_times"
),
unit="mixed",
empty_is_complete=True,
),
}
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
target = _compact(trade_date)
industry, members = self._sector_memberships(representative, target)
@@ -354,6 +407,9 @@ class TushareProvider:
) -> ProviderResult:
raise ProviderError("Tushare不提供动态竞价快照")
def event_reasons(self, trade_date: str) -> ProviderResult:
raise ProviderError("Tushare涨跌停榜单不提供可用的事件原因字段")
def screener_inputs(self, trade_dates: tuple[str, ...]) -> dict[str, ProviderResult | None]:
if len(trade_dates) < 21:
raise ProviderError("选股因子至少需要21个交易日")
@@ -628,6 +684,12 @@ def _display(value: str) -> str:
return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}"
def _change(close: Any, previous: Any) -> float:
current = _number(close)
prior = _number(previous)
return round((current / prior - 1) * 100, 4) if current > 0 and prior > 0 else 0.0
def _quarter_periods(through: str, count: int) -> tuple[str, ...]:
year = int(through[:4])
quarter = (int(through[4:6]) - 1) // 3