feat(sector-radar): 建立独立指标与排名领域模型
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from zhixing_server.modules.sector_radar.domain.facts import aggregate_sector_snapshot
|
||||
from zhixing_server.modules.sector_radar.domain.models import (
|
||||
MembershipStatus,
|
||||
PublicationStatus,
|
||||
RadarPublication,
|
||||
SectorMembershipSnapshot,
|
||||
SectorType,
|
||||
StockDailyFact,
|
||||
StockFactStatus,
|
||||
)
|
||||
|
||||
TARGET_DATE = date(2026, 8, 28)
|
||||
|
||||
|
||||
def test_point_in_time_aggregation_distinguishes_suspension_missing_and_zero() -> None:
|
||||
snapshot = SectorMembershipSnapshot(
|
||||
trade_date=TARGET_DATE,
|
||||
sector_type=SectorType.CONCEPT,
|
||||
sector_code="BK0001.DC",
|
||||
sector_name="示例概念",
|
||||
member_codes=("000001.SZ", "000002.SZ", "000003.SZ", "000004.SZ"),
|
||||
status=MembershipStatus.AVAILABLE,
|
||||
source_version="dc-member-20260828-a",
|
||||
)
|
||||
facts = (
|
||||
StockDailyFact(
|
||||
trade_date=TARGET_DATE,
|
||||
ts_code="000001.SZ",
|
||||
status=StockFactStatus.AVAILABLE,
|
||||
turnover_yuan=Decimal("1000"),
|
||||
net_amount_yuan=Decimal("100"),
|
||||
),
|
||||
StockDailyFact(
|
||||
trade_date=TARGET_DATE,
|
||||
ts_code="000002.SZ",
|
||||
status=StockFactStatus.AVAILABLE,
|
||||
turnover_yuan=Decimal("2000"),
|
||||
net_amount_yuan=Decimal("0"),
|
||||
),
|
||||
StockDailyFact(
|
||||
trade_date=TARGET_DATE,
|
||||
ts_code="000003.SZ",
|
||||
status=StockFactStatus.SUSPENDED,
|
||||
),
|
||||
StockDailyFact(
|
||||
trade_date=TARGET_DATE,
|
||||
ts_code="000004.SZ",
|
||||
status=StockFactStatus.MISSING,
|
||||
),
|
||||
)
|
||||
|
||||
aggregate = aggregate_sector_snapshot(snapshot, facts)
|
||||
|
||||
assert aggregate.member_count == 4
|
||||
assert aggregate.valid_sample_count == 2
|
||||
assert aggregate.net_amount_yuan == Decimal("100")
|
||||
assert aggregate.turnover_yuan == Decimal("3000")
|
||||
assert aggregate.membership_coverage == Decimal("1")
|
||||
assert aggregate.moneyflow_coverage == Decimal("2") / Decimal("3")
|
||||
|
||||
|
||||
def test_unknown_membership_never_falls_back_to_available_stock_facts() -> None:
|
||||
snapshot = SectorMembershipSnapshot(
|
||||
trade_date=TARGET_DATE,
|
||||
sector_type=SectorType.INDUSTRY,
|
||||
sector_code="BK1001.DC",
|
||||
sector_name="示例行业",
|
||||
member_codes=(),
|
||||
status=MembershipStatus.UNKNOWN,
|
||||
source_version="dc-member-missing",
|
||||
)
|
||||
fact = StockDailyFact(
|
||||
trade_date=TARGET_DATE,
|
||||
ts_code="000001.SZ",
|
||||
status=StockFactStatus.AVAILABLE,
|
||||
turnover_yuan=Decimal("1000"),
|
||||
net_amount_yuan=Decimal("100"),
|
||||
)
|
||||
|
||||
aggregate = aggregate_sector_snapshot(snapshot, (fact,))
|
||||
|
||||
assert aggregate.member_count == 0
|
||||
assert aggregate.net_amount_yuan is None
|
||||
assert aggregate.turnover_yuan is None
|
||||
assert aggregate.membership_coverage == Decimal("0")
|
||||
|
||||
|
||||
def test_stock_fact_rejects_non_finite_values_and_invalid_status_payloads() -> None:
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
StockDailyFact(
|
||||
trade_date=TARGET_DATE,
|
||||
ts_code="000001.SZ",
|
||||
status=StockFactStatus.AVAILABLE,
|
||||
turnover_yuan=Decimal("Infinity"),
|
||||
net_amount_yuan=Decimal("1"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="must not expose amounts"):
|
||||
StockDailyFact(
|
||||
trade_date=TARGET_DATE,
|
||||
ts_code="000001.SZ",
|
||||
status=StockFactStatus.SUSPENDED,
|
||||
turnover_yuan=Decimal("0"),
|
||||
)
|
||||
|
||||
|
||||
def test_publication_requires_terminal_completion_and_replay_identity() -> None:
|
||||
started_at = datetime(2026, 8, 28, 17, 30, tzinfo=UTC)
|
||||
|
||||
publication = RadarPublication(
|
||||
publication_id="radar-20260828-a",
|
||||
target_trade_date=TARGET_DATE,
|
||||
status=PublicationStatus.SUCCESS,
|
||||
source_version="tushare-pro-v1",
|
||||
universe_version="eastmoney-dc-20260828-a",
|
||||
metric_versions=(
|
||||
"zhixing_amount_net_bn_v1",
|
||||
"zhixing_ratio_turnover_v1",
|
||||
"zhixing_swing_equal_3_10_v1",
|
||||
),
|
||||
input_hash="a" * 64,
|
||||
coverage=Decimal("0.995"),
|
||||
started_at=started_at,
|
||||
finished_at=datetime(2026, 8, 28, 17, 35, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert publication.status is PublicationStatus.SUCCESS
|
||||
|
||||
with pytest.raises(ValueError, match="finished_at"):
|
||||
RadarPublication(
|
||||
publication_id="radar-20260828-running",
|
||||
target_trade_date=TARGET_DATE,
|
||||
status=PublicationStatus.RUNNING,
|
||||
source_version="tushare-pro-v1",
|
||||
universe_version="eastmoney-dc-20260828-a",
|
||||
metric_versions=("zhixing_amount_net_bn_v1",),
|
||||
input_hash=None,
|
||||
coverage=Decimal("0"),
|
||||
started_at=started_at,
|
||||
finished_at=started_at,
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from zhixing_server.modules.sector_radar.domain.metrics import (
|
||||
AmountNetStrategy,
|
||||
RatioTurnoverStrategy,
|
||||
SwingEqualThreeToTenStrategy,
|
||||
)
|
||||
from zhixing_server.modules.sector_radar.domain.models import (
|
||||
MetricQuality,
|
||||
SectorDailyAggregate,
|
||||
SectorType,
|
||||
)
|
||||
|
||||
TARGET_DATE = date(2026, 8, 28)
|
||||
|
||||
|
||||
def make_aggregate(
|
||||
*,
|
||||
net_amount_yuan: Decimal | None = Decimal("125000000"),
|
||||
turnover_yuan: Decimal | None = Decimal("5000000000"),
|
||||
) -> SectorDailyAggregate:
|
||||
return SectorDailyAggregate(
|
||||
trade_date=TARGET_DATE,
|
||||
sector_type=SectorType.CONCEPT,
|
||||
sector_code="BK0001.DC",
|
||||
sector_name="示例概念",
|
||||
member_count=10,
|
||||
valid_sample_count=10,
|
||||
net_amount_yuan=net_amount_yuan,
|
||||
turnover_yuan=turnover_yuan,
|
||||
membership_coverage=Decimal("1"),
|
||||
moneyflow_coverage=Decimal("1"),
|
||||
)
|
||||
|
||||
|
||||
def test_amount_and_ratio_strategies_expose_independent_versioned_values() -> None:
|
||||
aggregate = make_aggregate()
|
||||
|
||||
amount = AmountNetStrategy().evaluate((aggregate,), TARGET_DATE)
|
||||
ratio = RatioTurnoverStrategy().evaluate((aggregate,), TARGET_DATE)
|
||||
|
||||
assert amount.value == Decimal("1.25")
|
||||
assert amount.metric_version == "zhixing_amount_net_bn_v1"
|
||||
assert amount.implementation_kind == "independent"
|
||||
assert amount.unit == "CNY_100M"
|
||||
assert amount.quality is MetricQuality.AVAILABLE
|
||||
|
||||
assert ratio.value == Decimal("0.025")
|
||||
assert ratio.metric_version == "zhixing_ratio_turnover_v1"
|
||||
assert ratio.implementation_kind == "independent"
|
||||
assert ratio.unit == "ratio"
|
||||
|
||||
|
||||
def test_missing_moneyflow_is_unavailable_but_zero_remains_a_real_value() -> None:
|
||||
missing = AmountNetStrategy().evaluate((make_aggregate(net_amount_yuan=None),), TARGET_DATE)
|
||||
zero = AmountNetStrategy().evaluate(
|
||||
(make_aggregate(net_amount_yuan=Decimal("0")),), TARGET_DATE
|
||||
)
|
||||
|
||||
assert missing.value is None
|
||||
assert missing.quality is MetricQuality.UNAVAILABLE
|
||||
assert zero.value == Decimal("0")
|
||||
assert zero.quality is MetricQuality.AVAILABLE
|
||||
|
||||
|
||||
def test_swing_strategy_uses_each_days_point_in_time_aggregate() -> None:
|
||||
history = tuple(
|
||||
SectorDailyAggregate(
|
||||
trade_date=date(2026, 8, 18 + offset),
|
||||
sector_type=SectorType.CONCEPT,
|
||||
sector_code="BK0001.DC",
|
||||
sector_name="示例概念",
|
||||
member_count=6 + offset,
|
||||
valid_sample_count=6 + offset,
|
||||
net_amount_yuan=Decimal(str(offset + 1)),
|
||||
turnover_yuan=Decimal("100"),
|
||||
membership_coverage=Decimal("1"),
|
||||
moneyflow_coverage=Decimal("1"),
|
||||
)
|
||||
for offset in range(10)
|
||||
)
|
||||
|
||||
result = SwingEqualThreeToTenStrategy().evaluate(history, date(2026, 8, 27))
|
||||
|
||||
# The worked 3..10-day window ratios average to exactly 0.0725.
|
||||
assert result.value == Decimal("0.0725")
|
||||
assert result.metric_version == "zhixing_swing_equal_3_10_v1"
|
||||
assert result.member_count == 15
|
||||
|
||||
|
||||
def test_swing_strategy_carries_forward_limited_historical_sample_quality() -> None:
|
||||
history = tuple(
|
||||
SectorDailyAggregate(
|
||||
trade_date=date(2026, 8, 18 + offset),
|
||||
sector_type=SectorType.INDUSTRY,
|
||||
sector_code="BK1001.DC",
|
||||
sector_name="示例行业",
|
||||
member_count=10,
|
||||
valid_sample_count=4 if offset == 0 else 10,
|
||||
net_amount_yuan=Decimal("10"),
|
||||
turnover_yuan=Decimal("100"),
|
||||
membership_coverage=Decimal("1"),
|
||||
moneyflow_coverage=Decimal("1"),
|
||||
)
|
||||
for offset in range(10)
|
||||
)
|
||||
|
||||
result = SwingEqualThreeToTenStrategy().evaluate(history, date(2026, 8, 27))
|
||||
|
||||
assert result.value == Decimal("0.1")
|
||||
assert result.quality is MetricQuality.AVAILABLE_LIMITED_SAMPLE
|
||||
@@ -0,0 +1,147 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from zhixing_server.modules.sector_radar.domain.models import (
|
||||
MetricKind,
|
||||
MetricObservation,
|
||||
MetricQuality,
|
||||
MetricUnit,
|
||||
RankSide,
|
||||
SectorType,
|
||||
)
|
||||
from zhixing_server.modules.sector_radar.domain.ranking import (
|
||||
rank_metric_observations,
|
||||
select_percentile_side,
|
||||
select_rank_change_side,
|
||||
with_rank_changes,
|
||||
)
|
||||
|
||||
TARGET_DATE = date(2026, 8, 28)
|
||||
|
||||
|
||||
def make_observation(
|
||||
sector_code: str,
|
||||
sector_type: SectorType,
|
||||
value: str | None,
|
||||
*,
|
||||
trade_date: date = TARGET_DATE,
|
||||
) -> MetricObservation:
|
||||
metric_value = Decimal(value) if value is not None else None
|
||||
return MetricObservation(
|
||||
trade_date=trade_date,
|
||||
sector_type=sector_type,
|
||||
sector_code=sector_code,
|
||||
sector_name=sector_code,
|
||||
metric_kind=MetricKind.AMOUNT,
|
||||
metric_version="zhixing_amount_net_bn_v1",
|
||||
implementation_kind="independent",
|
||||
unit=MetricUnit.CNY_100M,
|
||||
value=metric_value,
|
||||
quality=(
|
||||
MetricQuality.AVAILABLE if metric_value is not None else MetricQuality.UNAVAILABLE
|
||||
),
|
||||
member_count=10,
|
||||
valid_sample_count=10 if metric_value is not None else 0,
|
||||
membership_coverage=Decimal("1"),
|
||||
moneyflow_coverage=Decimal("1"),
|
||||
)
|
||||
|
||||
|
||||
def test_ranking_separates_types_and_uses_code_as_stable_tie_breaker() -> None:
|
||||
observations = (
|
||||
make_observation("BK2002.DC", SectorType.INDUSTRY, "20"),
|
||||
make_observation("BK1002.DC", SectorType.CONCEPT, "30"),
|
||||
make_observation("BK2001.DC", SectorType.INDUSTRY, "20"),
|
||||
make_observation("BK1001.DC", SectorType.CONCEPT, "10"),
|
||||
)
|
||||
|
||||
ranked = rank_metric_observations(tuple(reversed(observations)))
|
||||
by_code = {row.observation.sector_code: row for row in ranked}
|
||||
|
||||
assert by_code["BK1002.DC"].rank_position == 1
|
||||
assert by_code["BK1002.DC"].rank_percentile == Decimal("100")
|
||||
assert by_code["BK1001.DC"].rank_position == 2
|
||||
assert by_code["BK1001.DC"].rank_percentile == Decimal("50")
|
||||
|
||||
assert by_code["BK2001.DC"].rank_position == 1
|
||||
assert by_code["BK2002.DC"].rank_position == 2
|
||||
|
||||
|
||||
def test_ranking_handles_empty_and_single_element_pools() -> None:
|
||||
assert rank_metric_observations(()) == ()
|
||||
|
||||
[single] = rank_metric_observations((make_observation("BK0001.DC", SectorType.CONCEPT, "0"),))
|
||||
|
||||
assert single.rank_position == 1
|
||||
assert single.rank_percentile == Decimal("100")
|
||||
|
||||
|
||||
def test_percentile_sides_use_confirmed_inclusive_thresholds() -> None:
|
||||
ranked = rank_metric_observations(
|
||||
make_observation(f"BK{position:04d}.DC", SectorType.CONCEPT, str(11 - position))
|
||||
for position in range(1, 11)
|
||||
)
|
||||
|
||||
top = select_percentile_side(ranked, RankSide.TOP)
|
||||
bottom = select_percentile_side(ranked, RankSide.BOTTOM)
|
||||
|
||||
assert [row.observation.sector_code for row in top] == ["BK0001.DC", "BK0002.DC"]
|
||||
assert [row.observation.sector_code for row in bottom] == ["BK0010.DC"]
|
||||
|
||||
|
||||
def test_rank_change_is_past_rank_minus_current_and_preserves_missing_history() -> None:
|
||||
current = rank_metric_observations(
|
||||
(
|
||||
make_observation("BK0001.DC", SectorType.CONCEPT, "30"),
|
||||
make_observation("BK0002.DC", SectorType.CONCEPT, "20"),
|
||||
)
|
||||
)
|
||||
previous = rank_metric_observations(
|
||||
(
|
||||
make_observation(
|
||||
"BK0001.DC",
|
||||
SectorType.CONCEPT,
|
||||
"10",
|
||||
trade_date=date(2026, 8, 27),
|
||||
),
|
||||
make_observation(
|
||||
"BK0002.DC",
|
||||
SectorType.CONCEPT,
|
||||
"40",
|
||||
trade_date=date(2026, 8, 27),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
changed = with_rank_changes(current, {1: previous, 5: ()})
|
||||
by_code = {row.observation.sector_code: row for row in changed}
|
||||
|
||||
assert by_code["BK0001.DC"].rank_change(1) == 1
|
||||
assert by_code["BK0002.DC"].rank_change(1) == -1
|
||||
assert by_code["BK0001.DC"].rank_change(5) is None
|
||||
|
||||
|
||||
def test_rank_change_sides_take_ceiling_ten_percent_per_pool() -> None:
|
||||
current = rank_metric_observations(
|
||||
make_observation(f"BK{position:04d}.DC", SectorType.CONCEPT, str(12 - position))
|
||||
for position in range(1, 12)
|
||||
)
|
||||
previous = rank_metric_observations(
|
||||
make_observation(
|
||||
f"BK{position:04d}.DC",
|
||||
SectorType.CONCEPT,
|
||||
str(position),
|
||||
trade_date=date(2026, 8, 27),
|
||||
)
|
||||
for position in range(1, 12)
|
||||
)
|
||||
changed = with_rank_changes(current, {1: previous})
|
||||
|
||||
top = select_rank_change_side(changed, days=1, side=RankSide.TOP)
|
||||
bottom = select_rank_change_side(changed, days=1, side=RankSide.BOTTOM)
|
||||
|
||||
assert [row.observation.sector_code for row in top] == ["BK0001.DC", "BK0002.DC"]
|
||||
assert [row.observation.sector_code for row in bottom] == [
|
||||
"BK0011.DC",
|
||||
"BK0010.DC",
|
||||
]
|
||||
Reference in New Issue
Block a user