rebuild(runtime): govern market operations and job truth
This commit is contained in:
@@ -115,6 +115,19 @@ class DataGateway:
|
||||
self._policy.assert_allowed(provider.source, DataUsage.CALCULATION)
|
||||
return provider.snapshot_inputs(trade_date, previous_trade_date)
|
||||
|
||||
def realtime_snapshot_inputs(
|
||||
self, trade_date: str, previous_trade_date: str
|
||||
) -> dict[str, Any]:
|
||||
provider = self._provider(DataSource.TUSHARE)
|
||||
self._policy.assert_allowed(provider.source, DataUsage.CALCULATION)
|
||||
directory = self.stock_directory()
|
||||
identifiers = tuple(sorted(directory))
|
||||
if not identifiers:
|
||||
raise MarketDataUnavailable("股票目录为空,无法读取盘中行情")
|
||||
return provider.realtime_market_inputs(
|
||||
trade_date, previous_trade_date, identifiers
|
||||
)
|
||||
|
||||
def trading_dates(self, through: str, limit: int = 2) -> tuple[str, ...]:
|
||||
requested = _date(through)
|
||||
with self._database.read() as connection:
|
||||
@@ -163,6 +176,16 @@ class DataGateway:
|
||||
raise MarketDataUnavailable("当前动态竞价快照暂不可用")
|
||||
return result
|
||||
|
||||
def event_reasons(self, trade_date: str) -> ProviderResult:
|
||||
provider = self._provider(DataSource.IFIND)
|
||||
self._policy.assert_allowed(provider.source, DataUsage.CALCULATION)
|
||||
result = provider.event_reasons(trade_date)
|
||||
if result.metadata.usage != DataUsage.CALCULATION:
|
||||
raise MarketDataUnavailable("事件补充来源未获准参与正式数据治理")
|
||||
if result.metadata.coverage <= 0:
|
||||
raise MarketDataUnavailable("事件原因补充服务暂不可用")
|
||||
return result
|
||||
|
||||
def sector_members(
|
||||
self, trade_date: str, sector_name: str, representative: str
|
||||
) -> dict[str, Any]:
|
||||
|
||||
@@ -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]: ...
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -163,6 +163,21 @@ class MarketRepository:
|
||||
)
|
||||
)
|
||||
|
||||
def open_dates_between(
|
||||
self, connection: sqlite3.Connection, start_date: str, end_date: str
|
||||
) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
str(row["trade_date"])
|
||||
for row in connection.execute(
|
||||
"""
|
||||
SELECT trade_date FROM trading_days
|
||||
WHERE is_open = 1 AND trade_date BETWEEN ? AND ?
|
||||
ORDER BY trade_date
|
||||
""",
|
||||
(start_date, end_date),
|
||||
).fetchall()
|
||||
)
|
||||
|
||||
def active_stock_count(self, connection: sqlite3.Connection) -> int:
|
||||
row = connection.execute(
|
||||
"""
|
||||
@@ -235,6 +250,81 @@ class MarketRepository:
|
||||
(through,),
|
||||
).fetchone()
|
||||
|
||||
def save_event_revision(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
trade_date: str,
|
||||
identifier: str,
|
||||
event_type: str,
|
||||
reason: str,
|
||||
first_time: str,
|
||||
last_time: str,
|
||||
open_times: int | None,
|
||||
source: str,
|
||||
priority: int,
|
||||
created_by: int | None,
|
||||
created_at: str,
|
||||
) -> int:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO market_event_revisions (
|
||||
trade_date, identifier, event_type, reason, first_time, last_time,
|
||||
open_times, source, priority, created_by, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
trade_date,
|
||||
identifier,
|
||||
event_type,
|
||||
reason,
|
||||
first_time,
|
||||
last_time,
|
||||
open_times,
|
||||
source,
|
||||
priority,
|
||||
created_by,
|
||||
created_at,
|
||||
),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
def event_revisions(
|
||||
self, connection: sqlite3.Connection, trade_date: str
|
||||
) -> tuple[sqlite3.Row, ...]:
|
||||
return tuple(
|
||||
connection.execute(
|
||||
"""
|
||||
SELECT * FROM (
|
||||
SELECT revisions.*,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY identifier, event_type
|
||||
ORDER BY priority DESC, id DESC
|
||||
) AS rank
|
||||
FROM market_event_revisions AS revisions
|
||||
WHERE trade_date = ?
|
||||
) WHERE rank = 1
|
||||
""",
|
||||
(trade_date,),
|
||||
).fetchall()
|
||||
)
|
||||
|
||||
def event_revision_history(
|
||||
self, connection: sqlite3.Connection, trade_date: str, identifier: str
|
||||
) -> tuple[sqlite3.Row, ...]:
|
||||
return tuple(
|
||||
connection.execute(
|
||||
"""
|
||||
SELECT revisions.*, users.username AS created_by_name
|
||||
FROM market_event_revisions AS revisions
|
||||
LEFT JOIN users ON users.id = revisions.created_by
|
||||
WHERE revisions.trade_date = ? AND revisions.identifier = ?
|
||||
ORDER BY revisions.id DESC
|
||||
""",
|
||||
(trade_date, identifier),
|
||||
).fetchall()
|
||||
)
|
||||
|
||||
def sector_members(
|
||||
self, connection: sqlite3.Connection, trade_date: str, sector_name: str
|
||||
) -> sqlite3.Row | None:
|
||||
|
||||
Reference in New Issue
Block a user