fix(sector-radar): 支持当前上市股票资金流补拉

This commit is contained in:
yuxuanhui
2026-08-31 14:26:28 +08:00
parent 1cd7b5cb38
commit 2ffd0163f2
20 changed files with 1077 additions and 93 deletions
@@ -33,7 +33,11 @@ from ..domain.models import (
StockDailyFact,
StockFactStatus,
)
from ..domain.normalize import normalize_memberships, normalize_stock_facts
from ..domain.normalize import (
is_current_listed_stock,
normalize_memberships,
normalize_stock_facts,
)
from ..domain.persistence import (
DailyAggregateRecord,
MembershipRecord,
@@ -461,18 +465,20 @@ class BuildSectorRadar:
reusable: Mapping[PublicationSourceGroup, tuple[SourceSnapshot, ...]],
fetch: Callable[[], SourceResult[T]],
parser: Callable[[Mapping[str, SourceScalar]], T],
reuse_if: Callable[[SourceResult[T]], bool] | None = None,
) -> SourceResult[T]:
"""Replay a completed group or fetch and checkpoint it immediately."""
"""Replay a compatible completed group or fetch and checkpoint it immediately."""
try:
snapshots = reusable.get(source_group)
if snapshots is None:
result = fetch()
else:
result = SourceResult(
replayed = SourceResult(
snapshots=snapshots,
rows=tuple(parser(row) for snapshot in snapshots for row in snapshot.rows),
)
result = replayed if reuse_if is None or reuse_if(replayed) else fetch()
except SourceContractError as exc:
if exc.claim_diagnostic():
logger.error(
@@ -540,6 +546,22 @@ class BuildSectorRadar:
self.source.fetch_stock_basics,
StockBasicRow.from_mapping,
)
memberships = normalize_memberships(indices, members)
member_codes = tuple(
sorted(
{
item.stock_code
for item in memberships
if item.status is MembershipStatus.AVAILABLE and item.stock_code is not None
}
)
)
current_listed_codes = {
row.ts_code for row in stock_basics.rows if is_current_listed_stock(row, target)
}
moneyflow_candidate_codes = tuple(
code for code in member_codes if code in current_listed_codes
)
suspensions = self._fetch_group(
publication_id,
PublicationSourceGroup.SUSPENSIONS,
@@ -558,23 +580,16 @@ class BuildSectorRadar:
publication_id,
PublicationSourceGroup.MONEYFLOW_DC,
reusable,
lambda: self.source.fetch_moneyflow_dc(target),
lambda: self.source.fetch_moneyflow_dc(target, moneyflow_candidate_codes),
MoneyflowDcRow.from_mapping,
reuse_if=lambda result: set(moneyflow_candidate_codes).issubset(
{row.ts_code for row in result.rows}
),
)
memberships = normalize_memberships(indices, members)
candidate_codes = tuple(
sorted(
{
item.stock_code
for item in memberships
if item.status is MembershipStatus.AVAILABLE and item.stock_code is not None
}
)
)
stock_facts = normalize_stock_facts(
target_trade_date=target,
candidate_codes=candidate_codes,
candidate_codes=member_codes,
stock_basics=stock_basics,
suspensions=suspensions,
daily=daily,
@@ -117,7 +117,7 @@ def normalize_stock_facts(
Args:
target_trade_date: Date whose point-in-time lifecycle is evaluated.
candidate_codes: Union of stocks in that date's sector memberships.
stock_basics: All explicit Tushare listing-status partitions.
stock_basics: Current ``list_status=L`` Tushare listings.
suspensions: Same-date suspend/resume events.
daily: Same-date stock turnover rows in source units.
moneyflow: Same-date DC main-moneyflow rows in source units.
@@ -165,7 +165,7 @@ def normalize_stock_facts(
turnover_yuan = None
net_amount_yuan = None
if basic is None or not _is_lifecycle_candidate(basic, target_trade_date):
if basic is None or not is_current_listed_stock(basic, target_trade_date):
status = StockFactStatus.LIFECYCLE_INVALID
elif ts_code in suspended_codes and daily_row is None:
status = StockFactStatus.SUSPENDED
@@ -197,7 +197,23 @@ def normalize_stock_facts(
return tuple(records)
def _is_lifecycle_candidate(stock: StockBasicRow, target: date) -> bool:
def is_current_listed_stock(stock: StockBasicRow, target: date) -> bool:
"""Return whether one current ``L`` row is an eligible radar security.
The radar intentionally uses the listings observed at build time rather than
reconstructing historical delistings. Code, market, and list-date checks keep
the existing Shanghai/Shenzhen A-share boundary intact.
Args:
stock: One validated ``stock_basic`` row.
target: Radar date whose list date must already have arrived.
Returns:
Whether the security belongs to the build-time radar universe.
"""
if stock.list_status != "L":
return False
if not stock.ts_code.endswith((".SH", ".SZ")):
return False
if stock.symbol.startswith(("200", "900")):
@@ -205,9 +221,7 @@ def _is_lifecycle_candidate(stock: StockBasicRow, target: date) -> bool:
market = stock.market or ""
if "北交" in market or "B股" in market.upper():
return False
if stock.list_date is None or stock.list_date > target:
return False
return stock.delist_date is None or target <= stock.delist_date
return stock.list_date is not None and stock.list_date <= target
def _is_suspend_event(value: str) -> bool:
@@ -42,7 +42,11 @@ class SectorRadarSource(Protocol):
def fetch_daily(self, trade_date: date) -> SourceResult[DailyRow]: ...
def fetch_moneyflow_dc(self, trade_date: date) -> SourceResult[MoneyflowDcRow]: ...
def fetch_moneyflow_dc(
self,
trade_date: date,
candidate_codes: Sequence[str],
) -> SourceResult[MoneyflowDcRow]: ...
def probe(self, trade_date: date) -> CapabilityProbeResult: ...
@@ -5,12 +5,14 @@ from __future__ import annotations
import logging
import time
from collections.abc import Callable, Iterable, Mapping, Sequence
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, date, datetime
from typing import TypeVar, cast
from zhixing_server.shared.request_coordinator import (
DEFAULT_RATE_LIMIT_COOLDOWNS,
RequestCoordinator,
TushareSourceError,
)
from ..domain.models import SectorType
@@ -84,6 +86,7 @@ _SECTOR_TYPE_PARAM = {
SectorType.CONCEPT: "概念板块",
SectorType.INDUSTRY: "行业板块",
}
_MONEYFLOW_WORKERS = 2
class TushareSectorRadarAdapter:
@@ -104,12 +107,11 @@ class TushareSectorRadarAdapter:
"""Create an adapter around one already-authenticated SDK client."""
self._client = client
self._sleep_fn = sleep_fn
self._request_interval_seconds = max(0.0, request_interval_seconds)
self._now_fn = now_fn
self._coordinator = request_coordinator or RequestCoordinator(
max_retries=max_retries,
backoff_seconds=backoff_seconds,
request_interval_seconds=request_interval_seconds,
cooldown_seconds=cooldown_seconds,
wait_fn=sleep_fn,
sleep_fn=sleep_fn,
@@ -265,24 +267,19 @@ class TushareSectorRadarAdapter:
)
def fetch_stock_basics(self) -> SourceResult[StockBasicRow]:
"""Fetch every documented listing status instead of relying on the L default."""
"""Fetch the build-time current ``L`` listings in one explicit partition."""
snapshots: list[SourceSnapshot] = []
rows: list[StockBasicRow] = []
for status in ("L", "D", "P", "G", "UN"):
snapshot = self._fetch_snapshot(
"stock_basic",
{"exchange": "", "list_status": status},
target_trade_date=None,
partition_key=status,
)
snapshots.append(snapshot)
parsed = tuple(StockBasicRow.from_mapping(row) for row in snapshot.rows)
if any(row.list_status != status for row in parsed):
raise SourceContractError("stock_basic returned an unexpected list_status")
rows.extend(parsed)
snapshot = self._fetch_snapshot(
"stock_basic",
{"exchange": "", "list_status": "L"},
target_trade_date=None,
partition_key="L",
)
rows = tuple(StockBasicRow.from_mapping(row) for row in snapshot.rows)
if any(row.list_status != "L" for row in rows):
raise SourceContractError("stock_basic returned an unexpected list_status")
self._require_unique(rows, key=lambda row: row.ts_code, api_name="stock_basic")
return SourceResult(tuple(snapshots), tuple(sorted(rows, key=lambda row: row.ts_code)))
return SourceResult((snapshot,), tuple(sorted(rows, key=lambda row: row.ts_code)))
def fetch_suspensions(self, trade_date: date) -> SourceResult[SuspendRow]:
"""Fetch explicit suspend/resume events for one date."""
@@ -315,19 +312,121 @@ class TushareSectorRadarAdapter:
self._require_unique(rows, key=lambda row: row.ts_code, api_name="daily")
return SourceResult((snapshot,), tuple(sorted(rows, key=lambda row: row.ts_code)))
def fetch_moneyflow_dc(self, trade_date: date) -> SourceResult[MoneyflowDcRow]:
"""Fetch a full-market DC moneyflow snapshot in its documented source unit."""
def fetch_moneyflow_dc(
self,
trade_date: date,
candidate_codes: Sequence[str],
) -> SourceResult[MoneyflowDcRow]:
"""Fetch full-market moneyflow and refill uncovered current candidates."""
snapshot = self._fetch_snapshot(
expected_codes = tuple(sorted(set(candidate_codes)))
if tuple(candidate_codes) != expected_codes or any(
not code.strip() for code in expected_codes
):
raise ValueError("candidate_codes must be sorted unique non-empty values")
initial = self._fetch_snapshot(
"moneyflow_dc",
{"trade_date": trade_date.strftime("%Y%m%d")},
target_trade_date=trade_date,
partition_key="all",
)
self._reject_limit(snapshot)
rows = tuple(MoneyflowDcRow.from_mapping(row) for row in snapshot.rows)
self._require_target_date(rows, trade_date, "moneyflow_dc")
self._require_unique(rows, key=lambda row: row.ts_code, api_name="moneyflow_dc")
return SourceResult((snapshot,), tuple(sorted(rows, key=lambda row: row.ts_code)))
try:
initial_rows = tuple(MoneyflowDcRow.from_mapping(row) for row in initial.rows)
self._require_target_date(initial_rows, trade_date, "moneyflow_dc")
self._require_unique(
initial_rows,
key=lambda row: (row.trade_date, row.ts_code),
api_name="moneyflow_dc",
)
except SourceContractError as exc:
self._log_contract_failure("moneyflow_dc", "all", exc)
raise
returned_codes = {row.ts_code for row in initial_rows}
missing_codes = tuple(code for code in expected_codes if code not in returned_codes)
if not missing_codes:
return SourceResult(
(initial,),
tuple(sorted(initial_rows, key=lambda row: row.ts_code)),
)
with ThreadPoolExecutor(
max_workers=_MONEYFLOW_WORKERS,
thread_name_prefix="sector-radar-moneyflow",
) as executor:
futures = {
code: executor.submit(self._fetch_moneyflow_partition, trade_date, code)
for code in missing_codes
}
partition_results = tuple(futures[code].result() for code in missing_codes)
snapshots = [initial]
merged_rows = list(initial_rows)
for result in partition_results:
if result is None:
continue
snapshot, rows = result
snapshots.append(snapshot)
merged_rows.extend(rows)
try:
self._require_unique(
merged_rows,
key=lambda row: (row.trade_date, row.ts_code),
api_name="moneyflow_dc",
)
except SourceContractError as exc:
self._log_contract_failure("moneyflow_dc", "merged", exc)
raise
return SourceResult(
tuple(snapshots),
tuple(sorted(merged_rows, key=lambda row: row.ts_code)),
)
def _fetch_moneyflow_partition(
self,
trade_date: date,
ts_code: str,
) -> tuple[SourceSnapshot, tuple[MoneyflowDcRow, ...]] | None:
"""Return one validated refill partition or preserve an ordinary gap."""
try:
snapshot = self._fetch_snapshot(
"moneyflow_dc",
{
"trade_date": trade_date.strftime("%Y%m%d"),
"ts_code": ts_code,
},
target_trade_date=trade_date,
partition_key=ts_code,
)
except TushareSourceError:
logger.warning(
"sector_radar_moneyflow_partition_failed partition_key=%s error_type=%s",
self._safe_partition_key(ts_code),
TushareSourceError.__name__,
)
return None
if not snapshot.rows:
logger.warning(
"sector_radar_moneyflow_partition_empty partition_key=%s",
self._safe_partition_key(ts_code),
)
return None
try:
self._reject_limit(snapshot)
rows = tuple(MoneyflowDcRow.from_mapping(row) for row in snapshot.rows)
self._require_target_date(rows, trade_date, "moneyflow_dc")
self._require_unique(
rows,
key=lambda row: (row.trade_date, row.ts_code),
api_name="moneyflow_dc",
)
if any(row.ts_code != ts_code for row in rows):
raise SourceContractError("moneyflow_dc partition returned a different ts_code")
except SourceContractError as exc:
self._log_contract_failure("moneyflow_dc", ts_code, exc)
raise
return snapshot, rows
def probe(self, trade_date: date) -> CapabilityProbeResult:
"""Probe required interfaces while returning only safe classifications."""
@@ -360,7 +459,7 @@ class TushareSectorRadarAdapter:
("stock_basic", self.fetch_stock_basics),
("suspend_d", lambda: self.fetch_suspensions(trade_date)),
("daily", lambda: self.fetch_daily(trade_date)),
("moneyflow_dc", lambda: self.fetch_moneyflow_dc(trade_date)),
("moneyflow_dc", lambda: self.fetch_moneyflow_dc(trade_date, ())),
):
results.append(self._probe_call(api_name, operation)[0])
return CapabilityProbeResult(observed_at=self._now_fn(), interfaces=tuple(results))
@@ -385,7 +484,6 @@ class TushareSectorRadarAdapter:
return method(fields=fields, **params)
result = self._coordinator.call(api_name, request)
self._sleep_fn(self._request_interval_seconds)
try:
columns = getattr(result, "columns", None)
returned_fields = (