fix(sector-radar): 支持当前上市股票资金流补拉
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import threading
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
@@ -87,6 +88,109 @@ def test_rate_limit_cooldown_is_shared_by_following_requests() -> None:
|
||||
assert waits == [60]
|
||||
|
||||
|
||||
def test_request_start_interval_allows_overlapping_provider_calls() -> None:
|
||||
current = [0.0]
|
||||
state_lock = threading.Lock()
|
||||
first_started = threading.Event()
|
||||
release_first = threading.Event()
|
||||
waits: list[float] = []
|
||||
starts: list[tuple[str, float]] = []
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def clock() -> float:
|
||||
with state_lock:
|
||||
return current[0]
|
||||
|
||||
def wait(seconds: float) -> None:
|
||||
with state_lock:
|
||||
waits.append(seconds)
|
||||
current[0] += seconds
|
||||
|
||||
coordinator = RequestCoordinator(
|
||||
max_retries=0,
|
||||
request_interval_seconds=0.2,
|
||||
clock=clock,
|
||||
wait_fn=wait,
|
||||
sleep_fn=wait,
|
||||
)
|
||||
|
||||
def first_request() -> object:
|
||||
starts.append(("first", clock()))
|
||||
first_started.set()
|
||||
if not release_first.wait(timeout=2):
|
||||
raise AssertionError("first provider call was not released")
|
||||
return "first"
|
||||
|
||||
def run_first() -> None:
|
||||
try:
|
||||
coordinator.call("first", first_request)
|
||||
except BaseException as exc: # pragma: no cover - surfaced by the assertion below
|
||||
errors.append(exc)
|
||||
|
||||
first_thread = threading.Thread(target=run_first)
|
||||
first_thread.start()
|
||||
assert first_started.wait(timeout=2)
|
||||
|
||||
second = coordinator.call(
|
||||
"second",
|
||||
lambda: starts.append(("second", clock())) or "second",
|
||||
)
|
||||
|
||||
assert second == "second"
|
||||
assert first_thread.is_alive()
|
||||
release_first.set()
|
||||
first_thread.join(timeout=2)
|
||||
assert not first_thread.is_alive()
|
||||
assert errors == []
|
||||
assert starts == [("first", 0.0), ("second", 0.2)]
|
||||
assert waits == [0.2]
|
||||
|
||||
|
||||
def test_request_start_interval_is_disabled_by_default() -> None:
|
||||
waits: list[float] = []
|
||||
starts: list[str] = []
|
||||
coordinator = RequestCoordinator(
|
||||
max_retries=0,
|
||||
clock=lambda: 0.0,
|
||||
wait_fn=waits.append,
|
||||
)
|
||||
|
||||
coordinator.call("first", lambda: starts.append("first"))
|
||||
coordinator.call("second", lambda: starts.append("second"))
|
||||
|
||||
assert starts == ["first", "second"]
|
||||
assert waits == []
|
||||
|
||||
|
||||
def test_request_start_interval_applies_to_retry_attempts() -> None:
|
||||
current = [0.0]
|
||||
waits: list[float] = []
|
||||
starts: list[float] = []
|
||||
|
||||
def wait(seconds: float) -> None:
|
||||
waits.append(seconds)
|
||||
current[0] += seconds
|
||||
|
||||
coordinator = RequestCoordinator(
|
||||
max_retries=1,
|
||||
backoff_seconds=0,
|
||||
request_interval_seconds=0.2,
|
||||
clock=lambda: current[0],
|
||||
wait_fn=wait,
|
||||
sleep_fn=wait,
|
||||
)
|
||||
|
||||
def request() -> object:
|
||||
starts.append(current[0])
|
||||
if len(starts) == 1:
|
||||
raise RuntimeError("transient provider failure")
|
||||
return "ok"
|
||||
|
||||
assert coordinator.call("daily", request) == "ok"
|
||||
assert starts == [0.0, 0.2]
|
||||
assert waits == [0.0, 0.2]
|
||||
|
||||
|
||||
def test_pro_bar_qfq_calls_are_bound_to_the_shared_coordinator(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -51,6 +52,7 @@ class FakeRadarSource:
|
||||
self.net_scale = net_scale
|
||||
self.fail_daily = False
|
||||
self.calls: list[str] = []
|
||||
self.moneyflow_candidate_codes: list[tuple[str, ...]] = []
|
||||
|
||||
def _result[T](
|
||||
self, api_name: str, target: date | None, rows: tuple[T, ...]
|
||||
@@ -239,8 +241,13 @@ class FakeRadarSource:
|
||||
)
|
||||
return self._result("daily", trade_date, rows)
|
||||
|
||||
def fetch_moneyflow_dc(self, trade_date: date) -> SourceResult[MoneyflowDcRow]:
|
||||
def fetch_moneyflow_dc(
|
||||
self,
|
||||
trade_date: date,
|
||||
candidate_codes: Sequence[str],
|
||||
) -> SourceResult[MoneyflowDcRow]:
|
||||
self.calls.append("moneyflow_dc")
|
||||
self.moneyflow_candidate_codes.append(tuple(candidate_codes))
|
||||
count = 4 if self.missing_moneyflow else 5
|
||||
rows = tuple(
|
||||
MoneyflowDcRow(
|
||||
@@ -288,6 +295,28 @@ def test_successful_build_is_idempotent_and_failed_retry_preserves_last_good() -
|
||||
assert any(item.status is PublicationStatus.FAILED for item in repository.publications.values())
|
||||
|
||||
|
||||
def test_build_passes_stable_current_listing_member_intersection_to_moneyflow() -> None:
|
||||
class FutureListingSource(FakeRadarSource):
|
||||
def fetch_stock_basics(self) -> SourceResult[StockBasicRow]:
|
||||
result = super().fetch_stock_basics()
|
||||
rows = result.rows[:-1] + (replace(result.rows[-1], list_date=date(2027, 1, 1)),)
|
||||
return self._result("stock_basic", None, rows)
|
||||
|
||||
source = FutureListingSource()
|
||||
|
||||
summary = BuildSectorRadar(
|
||||
source,
|
||||
InMemorySectorRadarRepository(),
|
||||
today=TARGET_DATE,
|
||||
now_fn=lambda: NOW,
|
||||
).execute(BuildSectorRadarCommand(trade_date=TARGET_DATE))
|
||||
|
||||
assert summary.status == "success"
|
||||
assert source.moneyflow_candidate_codes == [
|
||||
("000001.SZ", "000002.SZ", "000003.SZ", "000004.SZ")
|
||||
]
|
||||
|
||||
|
||||
def test_source_contract_failure_is_logged_with_safe_build_context(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
@@ -386,6 +415,85 @@ def test_unknown_membership_is_persisted_as_partial_and_retried_independently()
|
||||
assert source.calls == ["members"]
|
||||
|
||||
|
||||
def test_membership_retry_refreshes_moneyflow_when_replay_misses_new_candidates() -> None:
|
||||
class ExpandingMembershipSource(FakeRadarSource):
|
||||
def fetch_sector_members(
|
||||
self,
|
||||
trade_date: date,
|
||||
sector_codes: Sequence[str],
|
||||
) -> SourceResult[SectorMemberRow]:
|
||||
self.calls.append("members")
|
||||
rows: list[SectorMemberRow] = []
|
||||
snapshots: list[SourceSnapshot] = []
|
||||
for index, sector_code in enumerate(sector_codes, start=1):
|
||||
sector_rows = (
|
||||
()
|
||||
if self.missing_membership and index == len(sector_codes)
|
||||
else (
|
||||
SectorMemberRow(
|
||||
trade_date,
|
||||
sector_code,
|
||||
f"00000{index}.SZ",
|
||||
f"股票{index}",
|
||||
),
|
||||
)
|
||||
)
|
||||
snapshots.append(
|
||||
build_source_snapshot(
|
||||
api_name="dc_member",
|
||||
params={
|
||||
"trade_date": trade_date.isoformat(),
|
||||
"ts_code": sector_code,
|
||||
},
|
||||
rows=tuple(self._raw_row(row) for row in sector_rows),
|
||||
target_trade_date=trade_date,
|
||||
partition_key=sector_code,
|
||||
observed_at=NOW,
|
||||
)
|
||||
)
|
||||
rows.extend(sector_rows)
|
||||
return SourceResult(tuple(snapshots), tuple(rows))
|
||||
|
||||
def fetch_moneyflow_dc(
|
||||
self,
|
||||
trade_date: date,
|
||||
candidate_codes: Sequence[str],
|
||||
) -> SourceResult[MoneyflowDcRow]:
|
||||
self.calls.append("moneyflow_dc")
|
||||
self.moneyflow_candidate_codes.append(tuple(candidate_codes))
|
||||
rows = tuple(
|
||||
MoneyflowDcRow(
|
||||
trade_date,
|
||||
code,
|
||||
code,
|
||||
Decimal(1),
|
||||
Decimal(0),
|
||||
Decimal(0),
|
||||
Decimal(10),
|
||||
)
|
||||
for code in candidate_codes
|
||||
)
|
||||
return self._result("moneyflow_dc", trade_date, rows)
|
||||
|
||||
repository = InMemorySectorRadarRepository()
|
||||
source = ExpandingMembershipSource(missing_membership=True)
|
||||
use_case = BuildSectorRadar(source, repository, today=TARGET_DATE, now_fn=lambda: NOW)
|
||||
|
||||
partial = use_case.execute(BuildSectorRadarCommand(trade_date=TARGET_DATE))
|
||||
partial_id = partial.outcomes[0].publication_id
|
||||
assert partial.status == "partial"
|
||||
assert partial_id is not None
|
||||
assert source.moneyflow_candidate_codes == [("000001.SZ",)]
|
||||
|
||||
source.missing_membership = False
|
||||
source.calls.clear()
|
||||
retried = use_case.execute(BuildSectorRadarCommand(retry_publication_id=partial_id))
|
||||
|
||||
assert retried.status == "success"
|
||||
assert source.calls == ["members", "moneyflow_dc"]
|
||||
assert source.moneyflow_candidate_codes[-1] == ("000001.SZ", "000002.SZ")
|
||||
|
||||
|
||||
def test_range_builds_dates_in_order_and_retry_uses_old_target() -> None:
|
||||
repository = InMemorySectorRadarRepository()
|
||||
source = FakeRadarSource(missing_moneyflow=True)
|
||||
|
||||
@@ -76,7 +76,9 @@ def test_cli_main_returns_summary_exit_code_and_json(
|
||||
@staticmethod
|
||||
def from_token(token: str, **kwargs: object) -> object:
|
||||
assert token == "secret-token"
|
||||
assert kwargs
|
||||
assert kwargs["max_retries"] == 3
|
||||
assert kwargs["backoff_seconds"] == 1.0
|
||||
assert kwargs["request_interval_seconds"] == 0.2
|
||||
return object()
|
||||
|
||||
class FakeBuild:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
@@ -24,11 +25,13 @@ class QueryClient:
|
||||
def __init__(self, responses: Mapping[tuple[str, str], object]) -> None:
|
||||
self.responses = dict(responses)
|
||||
self.calls: list[tuple[str, dict[str, object]]] = []
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def query(self, api_name: str, **kwargs: object) -> object:
|
||||
self.calls.append((api_name, kwargs))
|
||||
partition = str(kwargs.get("ts_code") or kwargs.get("list_status") or "")
|
||||
response = self.responses.get((api_name, partition), ())
|
||||
with self._lock:
|
||||
self.calls.append((api_name, kwargs))
|
||||
response = self.responses.get((api_name, partition), ())
|
||||
if isinstance(response, BaseException):
|
||||
raise response
|
||||
return response
|
||||
@@ -44,6 +47,22 @@ def make_adapter(client: object) -> TushareSectorRadarAdapter:
|
||||
)
|
||||
|
||||
|
||||
def moneyflow_record(
|
||||
ts_code: str,
|
||||
*,
|
||||
trade_date: str = "20260828",
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"trade_date": trade_date,
|
||||
"ts_code": ts_code,
|
||||
"name": ts_code,
|
||||
"net_amount": "1",
|
||||
"net_amount_rate": "0.1",
|
||||
"pct_change": "1",
|
||||
"close": "10",
|
||||
}
|
||||
|
||||
|
||||
def test_daily_and_moneyflow_keep_source_units_and_distinguish_missing_from_zero() -> None:
|
||||
client = QueryClient(
|
||||
{
|
||||
@@ -98,7 +117,7 @@ def test_daily_and_moneyflow_keep_source_units_and_distinguish_missing_from_zero
|
||||
adapter = make_adapter(client)
|
||||
|
||||
daily = adapter.fetch_daily(TARGET_DATE)
|
||||
moneyflow = adapter.fetch_moneyflow_dc(TARGET_DATE)
|
||||
moneyflow = adapter.fetch_moneyflow_dc(TARGET_DATE, ("000001.SZ", "000002.SZ"))
|
||||
|
||||
assert daily.rows[0].amount_thousand_yuan == Decimal("12.5")
|
||||
assert daily.rows[0].turnover_yuan == Decimal("12500.0")
|
||||
@@ -109,6 +128,168 @@ def test_daily_and_moneyflow_keep_source_units_and_distinguish_missing_from_zero
|
||||
assert client.calls[0][1]["fields"] == ",".join(source_module.FIELDS["daily"])
|
||||
|
||||
|
||||
def test_moneyflow_accepts_a_full_initial_snapshot_at_the_provider_limit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setitem(source_module.ROW_LIMITS, "moneyflow_dc", 2)
|
||||
client = QueryClient(
|
||||
{
|
||||
("moneyflow_dc", ""): (
|
||||
moneyflow_record("000001.SZ"),
|
||||
moneyflow_record("000002.SZ"),
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
result = make_adapter(client).fetch_moneyflow_dc(
|
||||
TARGET_DATE,
|
||||
("000001.SZ", "000002.SZ"),
|
||||
)
|
||||
|
||||
assert result.snapshots[0].limit_reached is True
|
||||
assert [row.ts_code for row in result.rows] == ["000001.SZ", "000002.SZ"]
|
||||
assert len(client.calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("initial_rows", "message"),
|
||||
(
|
||||
((moneyflow_record("000001.SZ", trade_date="20260827"),), "trade_date"),
|
||||
(
|
||||
(moneyflow_record("000001.SZ"), moneyflow_record("000001.SZ")),
|
||||
"duplicate business keys",
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_moneyflow_initial_contract_errors_fail_closed(
|
||||
initial_rows: tuple[dict[str, object], ...],
|
||||
message: str,
|
||||
) -> None:
|
||||
client = QueryClient({("moneyflow_dc", ""): initial_rows})
|
||||
|
||||
with pytest.raises(SourceContractError, match=message):
|
||||
make_adapter(client).fetch_moneyflow_dc(TARGET_DATE, ())
|
||||
|
||||
|
||||
def test_moneyflow_refills_only_missing_codes_in_stable_snapshot_order(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setitem(source_module.ROW_LIMITS, "moneyflow_dc", 3)
|
||||
third_finished = threading.Event()
|
||||
completion_order: list[str] = []
|
||||
completion_lock = threading.Lock()
|
||||
|
||||
class ReverseCompletionClient(QueryClient):
|
||||
def query(self, api_name: str, **kwargs: object) -> object:
|
||||
response = super().query(api_name, **kwargs)
|
||||
ts_code = str(kwargs.get("ts_code") or "")
|
||||
if ts_code == "000004.SZ":
|
||||
if not third_finished.wait(timeout=2):
|
||||
raise AssertionError("second moneyflow worker did not start")
|
||||
elif ts_code == "000005.SZ":
|
||||
third_finished.set()
|
||||
if ts_code:
|
||||
with completion_lock:
|
||||
completion_order.append(ts_code)
|
||||
return response
|
||||
|
||||
client = ReverseCompletionClient(
|
||||
{
|
||||
("moneyflow_dc", ""): tuple(
|
||||
moneyflow_record(f"00000{index}.SZ") for index in range(1, 4)
|
||||
),
|
||||
("moneyflow_dc", "000004.SZ"): (moneyflow_record("000004.SZ"),),
|
||||
("moneyflow_dc", "000005.SZ"): (moneyflow_record("000005.SZ"),),
|
||||
}
|
||||
)
|
||||
|
||||
result = make_adapter(client).fetch_moneyflow_dc(
|
||||
TARGET_DATE,
|
||||
tuple(f"00000{index}.SZ" for index in range(1, 6)),
|
||||
)
|
||||
|
||||
assert completion_order == ["000005.SZ", "000004.SZ"]
|
||||
assert [snapshot.partition_key for snapshot in result.snapshots] == [
|
||||
"all",
|
||||
"000004.SZ",
|
||||
"000005.SZ",
|
||||
]
|
||||
assert [row.ts_code for row in result.rows] == [
|
||||
"000001.SZ",
|
||||
"000002.SZ",
|
||||
"000003.SZ",
|
||||
"000004.SZ",
|
||||
"000005.SZ",
|
||||
]
|
||||
assert len(client.calls) == 3
|
||||
|
||||
|
||||
def test_moneyflow_empty_and_exhausted_refills_remain_real_gaps(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
client = QueryClient(
|
||||
{
|
||||
("moneyflow_dc", ""): (moneyflow_record("000001.SZ"),),
|
||||
("moneyflow_dc", "000002.SZ"): (),
|
||||
("moneyflow_dc", "000003.SZ"): RuntimeError("private provider payload"),
|
||||
}
|
||||
)
|
||||
caplog.set_level(
|
||||
logging.WARNING,
|
||||
logger="zhixing_server.modules.sector_radar.infrastructure.tushare",
|
||||
)
|
||||
|
||||
result = make_adapter(client).fetch_moneyflow_dc(
|
||||
TARGET_DATE,
|
||||
("000001.SZ", "000002.SZ", "000003.SZ"),
|
||||
)
|
||||
|
||||
assert [row.ts_code for row in result.rows] == ["000001.SZ"]
|
||||
assert [snapshot.partition_key for snapshot in result.snapshots] == ["all"]
|
||||
messages = "\n".join(record.getMessage() for record in caplog.records)
|
||||
assert "partition_empty partition_key=000002.SZ" in messages
|
||||
assert "partition_failed partition_key=000003.SZ" in messages
|
||||
assert "private provider payload" not in messages
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("partition_rows", "row_limit", "message"),
|
||||
(
|
||||
((moneyflow_record("000002.SZ", trade_date="20260827"),), 6_000, "trade_date"),
|
||||
((moneyflow_record("000099.SZ"),), 6_000, "different ts_code"),
|
||||
(
|
||||
(moneyflow_record("000002.SZ"), moneyflow_record("000002.SZ")),
|
||||
6_000,
|
||||
"duplicate business keys",
|
||||
),
|
||||
(
|
||||
(moneyflow_record("000002.SZ"), moneyflow_record("000002.SZ")),
|
||||
2,
|
||||
"provider row limit",
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_moneyflow_partition_contract_errors_fail_closed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
partition_rows: tuple[dict[str, object], ...],
|
||||
row_limit: int,
|
||||
message: str,
|
||||
) -> None:
|
||||
monkeypatch.setitem(source_module.ROW_LIMITS, "moneyflow_dc", row_limit)
|
||||
client = QueryClient(
|
||||
{
|
||||
("moneyflow_dc", ""): (moneyflow_record("000001.SZ"),),
|
||||
("moneyflow_dc", "000002.SZ"): partition_rows,
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(SourceContractError, match=message):
|
||||
make_adapter(client).fetch_moneyflow_dc(
|
||||
TARGET_DATE,
|
||||
("000001.SZ", "000002.SZ"),
|
||||
)
|
||||
|
||||
|
||||
def test_non_finite_source_values_are_rejected() -> None:
|
||||
client = QueryClient(
|
||||
{
|
||||
@@ -305,32 +486,54 @@ def test_dc_member_preserves_an_explicit_empty_partition() -> None:
|
||||
assert result.snapshots[1].row_count == 0
|
||||
|
||||
|
||||
def test_stock_basic_explicitly_requests_all_lifecycle_statuses() -> None:
|
||||
responses = {
|
||||
(
|
||||
"stock_basic",
|
||||
status,
|
||||
): (
|
||||
{
|
||||
"ts_code": f"00000{index}.SZ",
|
||||
"symbol": f"00000{index}",
|
||||
"name": status,
|
||||
"market": None if status == "D" else "主板",
|
||||
"exchange": "SZSE",
|
||||
"list_status": status,
|
||||
"list_date": "20200101",
|
||||
"delist_date": None,
|
||||
},
|
||||
)
|
||||
for index, status in enumerate(("L", "D", "P", "G", "UN"), start=1)
|
||||
}
|
||||
client = QueryClient(responses)
|
||||
def test_stock_basic_requests_only_current_listings() -> None:
|
||||
client = QueryClient(
|
||||
{
|
||||
(
|
||||
"stock_basic",
|
||||
"L",
|
||||
): (
|
||||
{
|
||||
"ts_code": "000001.SZ",
|
||||
"symbol": "000001",
|
||||
"name": "L",
|
||||
"market": "主板",
|
||||
"exchange": "SZSE",
|
||||
"list_status": "L",
|
||||
"list_date": "20200101",
|
||||
"delist_date": None,
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
result = make_adapter(client).fetch_stock_basics()
|
||||
|
||||
assert {row.list_status for row in result.rows} == {"L", "D", "P", "G", "UN"}
|
||||
assert next(row for row in result.rows if row.list_status == "D").market is None
|
||||
assert [call[1]["list_status"] for call in client.calls] == ["L", "D", "P", "G", "UN"]
|
||||
assert {row.list_status for row in result.rows} == {"L"}
|
||||
assert [snapshot.partition_key for snapshot in result.snapshots] == ["L"]
|
||||
assert [call[1]["list_status"] for call in client.calls] == ["L"]
|
||||
|
||||
|
||||
def test_stock_basic_rejects_a_non_listed_row_from_the_l_partition() -> None:
|
||||
client = QueryClient(
|
||||
{
|
||||
("stock_basic", "L"): (
|
||||
{
|
||||
"ts_code": "000001.SZ",
|
||||
"symbol": "000001",
|
||||
"name": "unexpected",
|
||||
"market": "主板",
|
||||
"exchange": "SZSE",
|
||||
"list_status": "D",
|
||||
"list_date": "20200101",
|
||||
"delist_date": "20260828",
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(SourceContractError, match="unexpected list_status"):
|
||||
make_adapter(client).fetch_stock_basics()
|
||||
|
||||
|
||||
def test_suspend_timing_may_be_missing_while_suspend_type_remains_required() -> None:
|
||||
|
||||
Reference in New Issue
Block a user