fix(sector-radar): 支持当前上市股票资金流补拉
This commit is contained in:
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user