feat(sector_radar): enhance sector radar functionality with active moneyflow and detailed metrics

- Introduced ActiveMoneyflowSource to fetch optional active-order flow, enhancing the sector radar's data capabilities.
- Updated StockFactRecord and DailyAggregateRecord to include pct_change and active_buy_net_amount_yuan for improved financial insights.
- Modified the build process to incorporate active moneyflow data without invalidating main rankings on failure.
- Enhanced the HTTP API to return detailed sector history and metrics, including pct_change and active buy metrics for members.
- Updated tests to validate the new functionality and ensure data integrity across various scenarios.
This commit is contained in:
yuxuanhui
2026-09-06 16:06:17 +08:00
parent 68282f5d46
commit 7f93d6b0f5
48 changed files with 4883 additions and 95 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"hash": "be29b65c",
"configHash": "93a0ae7b",
"lockfileHash": "e3b0c442",
"browserHash": "4df63514",
"optimized": {},
"chunks": {}
}
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
@@ -0,0 +1,38 @@
"""Preserve independent stock detail facts without invalidating old publications."""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0008_radar_detail"
down_revision: str | None = "0007_selection_pattern_scoring"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Add nullable facts and allow the optional raw moneyflow checkpoint."""
for name in ("pct_change", "active_buy_net_amount_yuan"):
op.add_column("sector_radar_stock_fact", sa.Column(name, sa.Numeric(), nullable=True))
op.create_check_constraint(
f"ck_radar_stock_{name}_finite",
"sector_radar_stock_fact",
f"{name} IS NULL OR {name} NOT IN "
"('NaN'::numeric, 'Infinity'::numeric, '-Infinity'::numeric)",
)
op.drop_constraint(
"ck_sector_radar_publication_source_group", "sector_radar_publication_source"
)
op.create_check_constraint(
"ck_sector_radar_publication_source_group",
"sector_radar_publication_source",
"source_group IN ('calendar', 'concept_indices', 'industry_indices', 'members', "
"'stock_basics', 'suspensions', 'daily', 'moneyflow_dc', 'moneyflow')",
)
def downgrade() -> None:
"""Drop added fact columns; retain optional checkpoint audit rows for older readers."""
for name in ("pct_change", "active_buy_net_amount_yuan"):
op.drop_column("sector_radar_stock_fact", name)
@@ -0,0 +1,33 @@
"""Store source sector detail alongside immutable publication daily inputs."""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0009_radar_sector_detail"
down_revision: str | None = "0008_radar_detail"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Keep all previous rows readable with nullable provider detail."""
op.add_column(
"sector_radar_daily_aggregate", sa.Column("pct_change", sa.Numeric(), nullable=True)
)
op.add_column(
"sector_radar_daily_aggregate", sa.Column("leading_code", sa.Text(), nullable=True)
)
op.create_check_constraint(
"ck_radar_sector_pct_change_finite",
"sector_radar_daily_aggregate",
"pct_change IS NULL OR pct_change NOT IN "
"('NaN'::numeric, 'Infinity'::numeric, '-Infinity'::numeric)",
)
def downgrade() -> None:
"""Remove only the nullable projection; original provider snapshots remain intact."""
op.drop_column("sector_radar_daily_aggregate", "leading_code")
op.drop_column("sector_radar_daily_aggregate", "pct_change")
@@ -14,6 +14,8 @@ from typing import Literal
from uuid import uuid4
from zoneinfo import ZoneInfo
from zhixing_server.shared.request_coordinator import TushareSourceError
from ..domain.facts import aggregate_sector_snapshot
from ..domain.metrics import (
AmountNetStrategy,
@@ -47,11 +49,12 @@ from ..domain.persistence import (
SectorRadarRepository,
StockFactRecord,
)
from ..domain.ports import SectorRadarSource
from ..domain.ports import ActiveMoneyflowSource, SectorRadarSource
from ..domain.ranking import rank_metric_observations, with_rank_changes
from ..domain.source import (
DailyRow,
MoneyflowDcRow,
MoneyflowRow,
SectorIndexRow,
SectorMemberRow,
SourceContractError,
@@ -367,12 +370,21 @@ class BuildSectorRadar:
)
),
)
index_details = {
(index.sector_type, index.sector_code): index for index in collected.indices
}
self.repository.finalize_publication(
finished,
memberships=collected.memberships,
stock_facts=collected.stock_facts,
daily_aggregates=(
DailyAggregateRecord(publication_id, aggregate) for aggregate in aggregates
DailyAggregateRecord(
publication_id,
aggregate,
index_details[(aggregate.sector_type, aggregate.sector_code)].pct_change,
index_details[(aggregate.sector_type, aggregate.sector_code)].leading_code,
)
for aggregate in aggregates
),
rankings=(RankingRecord(publication_id, ranking) for ranking in rankings),
retry_source_groups=(
@@ -511,7 +523,7 @@ class BuildSectorRadar:
publication_id,
PublicationSourceGroup.CALENDAR,
reusable,
lambda: self.source.fetch_trade_calendar(target, target),
lambda: self.source.fetch_trade_calendar(target - timedelta(days=70), target),
TradeCalendarRow.from_mapping,
)
if target not in {row.cal_date for row in calendar.rows if row.is_open}:
@@ -587,6 +599,23 @@ class BuildSectorRadar:
),
)
active_moneyflow: SourceResult[MoneyflowRow] | None = None
if isinstance(self.source, ActiveMoneyflowSource):
fetch_active = self.source.fetch_moneyflow
try:
active_moneyflow = self._fetch_group(
publication_id,
PublicationSourceGroup.MONEYFLOW,
reusable,
lambda: fetch_active(target),
MoneyflowRow.from_mapping,
)
except (TushareSourceError, SourceContractError):
# Optional detail failure must not invalidate otherwise complete rankings.
logger.warning(
"sector_radar_optional_moneyflow_unavailable publication_id=%s", publication_id
)
stock_facts = normalize_stock_facts(
target_trade_date=target,
candidate_codes=member_codes,
@@ -594,6 +623,7 @@ class BuildSectorRadar:
suspensions=suspensions,
daily=daily,
moneyflow=moneyflow,
active_moneyflow=active_moneyflow,
)
snapshots = (
calendar.snapshots
@@ -604,9 +634,11 @@ class BuildSectorRadar:
+ suspensions.snapshots
+ daily.snapshots
+ moneyflow.snapshots
+ (active_moneyflow.snapshots if active_moneyflow else ())
)
return _CollectedInputs(
target_trade_date=target,
indices=concepts.rows + industries.rows,
snapshots=snapshots,
membership_snapshots=members.snapshots,
memberships=memberships,
@@ -723,7 +755,7 @@ class BuildSectorRadar:
{
"snapshot_ids": sorted(snapshot.snapshot_id for snapshot in snapshots),
"metric_versions": sorted(strategy.metric_version for strategy in self.strategies),
"normalizer": "zhixing_stock_fact_v1",
"normalizer": "zhixing_stock_fact_v2",
},
sort_keys=True,
separators=(",", ":"),
@@ -753,6 +785,7 @@ class BuildSectorRadar:
@dataclass(frozen=True, slots=True)
class _CollectedInputs:
target_trade_date: date
indices: tuple[SectorIndexRow, ...]
snapshots: tuple[SourceSnapshot, ...]
membership_snapshots: tuple[SourceSnapshot, ...]
memberships: tuple[MembershipRecord, ...]
@@ -0,0 +1,445 @@
"""Publication-scoped detail projections built exclusively from persisted inputs."""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from ..domain.metrics import AmountNetStrategy, RatioTurnoverStrategy, SwingEqualThreeToTenStrategy
from ..domain.models import MetricKind, RadarPublication, RankedMetric, RankSide, SectorType
from ..domain.normalize import is_current_listed_stock
from ..domain.persistence import PublicationSourceGroup, SectorRadarRepository
from ..domain.source import (
DailyRow,
MoneyflowDcRow,
MoneyflowRow,
SectorIndexRow,
SectorMemberRow,
SourceSnapshot,
StockBasicRow,
TradeCalendarRow,
)
_METRIC_VERSIONS = {
MetricKind.AMOUNT: AmountNetStrategy.metric_version,
MetricKind.RATIO: RatioTurnoverStrategy.metric_version,
MetricKind.SWING: SwingEqualThreeToTenStrategy.metric_version,
}
@dataclass(frozen=True, slots=True)
class RankingExtras:
"""Same-publication daily values and the selected side's historical appearances."""
pct_change: Decimal | None = None
daily_net_amount_yuan: Decimal | None = None
daily_ratio: Decimal | None = None
on_list_count: int | None = None
history_available_days: int = 0
@dataclass(frozen=True, slots=True)
class HistoryMetric:
"""One dated rank retaining its pool and explicit missing state."""
rank_position: int | None = None
rank_percentile: Decimal | None = None
pool_size: int = 0
metric_value: Decimal | None = None
missing: bool = True
in_top: bool = False
in_bottom: bool = False
@dataclass(frozen=True, slots=True)
class HistoryPoint:
"""Three comparable metric ranks on one observed trading day."""
trade_date: date
publication_id: str | None
amount: HistoryMetric
ratio: HistoryMetric
swing: HistoryMetric
@dataclass(frozen=True, slots=True)
class SectorHistory:
"""A thirty-session ceiling with no invented pre-launch history."""
status: str
requested_trade_date: date
trade_date: date | None
publication: RadarPublication | None
sector_type: SectorType
sector_code: str
sector_name: str | None
points: tuple[HistoryPoint, ...]
available_days: int
window_size: int = 30
@dataclass(frozen=True, slots=True)
class DetailMember:
"""One confirmed current-listed member with independently nullable metrics."""
ts_code: str
name: str
pct_change: Decimal | None = None
net_amount_yuan: Decimal | None = None
active_buy_net_amount_yuan: Decimal | None = None
@dataclass(frozen=True, slots=True)
class LeadingStock:
"""Provider-designated leading stock identity."""
ts_code: str
name: str | None
@dataclass(frozen=True, slots=True)
class MemberLeaders:
"""Up to five finite observations per side, stably ordered by code on ties."""
top: tuple[DetailMember, ...]
bottom: tuple[DetailMember, ...]
@dataclass(frozen=True, slots=True)
class SimilarSector:
"""Jaccard overlap of confirmed same-publication listed member sets."""
sector_type: SectorType
sector_code: str
sector_name: str
overlap_ratio: Decimal
intersection_count: int
union_count: int
@dataclass(frozen=True, slots=True)
class SectorDetail:
"""Complete read-only detail for one immutable publication."""
history: SectorHistory
pct_change: Decimal | None
leading_stock: LeadingStock | None
summary: dict[str, HistoryMetric]
members: tuple[DetailMember, ...]
leaders: dict[str, MemberLeaders]
similar_sectors: tuple[SimilarSector, ...]
class ReadRadarDetails:
"""Reuse batched history and exact publication raw snapshots across read views."""
def __init__(self, repository: SectorRadarRepository) -> None:
self.repository = repository
def history_data(
self, publication: RadarPublication
) -> tuple[dict[date, RadarPublication], dict[date, Sequence[RankedMetric]], tuple[date, ...]]:
"""Load history in bounded batches; calendar holes remain explicit missing points."""
publications = {
item.target_trade_date: item
for item in self.repository.load_history_publications(publication.target_trade_date)
}
# Pin the current date to the response's chosen publication if a rebuild finishes
# during this request. All history rows are then fetched by these exact IDs.
publications[publication.target_trade_date] = publication
by_id = dict(
self.repository.load_publication_rankings(
tuple(item.publication_id for item in publications.values())
)
)
rows = {
day: by_id.get(item.publication_id, ())
if item.source_version == publication.source_version
else ()
for day, item in publications.items()
}
snapshots = self.snapshots(publication)
calendar = [
TradeCalendarRow.from_mapping(row)
for snapshot in snapshots.get(PublicationSourceGroup.CALENDAR, ())
for row in snapshot.rows
]
dates = tuple(
sorted(
{
row.cal_date
for row in calendar
if row.is_open and row.cal_date <= publication.target_trade_date
}
| set(publications)
)[-30:]
)
return publications, rows, dates
def ranking_extras(
self, publication: RadarPublication, rankings: Sequence[RankedMetric], side: RankSide
) -> dict[str, RankingExtras]:
"""Enrich one page from one batched thirty-session history, with no per-sector IO."""
if not rankings:
return {}
_, history, dates = self.history_data(publication)
snapshots = self.snapshots(publication)
indices = {
(item.sector_type, item.sector_code): item
for group, kind in (
(PublicationSourceGroup.CONCEPT_INDICES, SectorType.CONCEPT),
(PublicationSourceGroup.INDUSTRY_INDICES, SectorType.INDUSTRY),
)
for snapshot in snapshots.get(group, ())
for row in snapshot.rows
for item in (SectorIndexRow.from_mapping(row, kind),)
if item.trade_date == publication.target_trade_date
}
result: dict[str, RankingExtras] = {}
for ranking in rankings:
observation = ranking.observation
key = (observation.sector_type, observation.sector_code)
index = indices.get(key)
current = history.get(publication.target_trade_date, ())
amount = metric_at(current, *key, MetricKind.AMOUNT).metric_value
ratio = metric_at(current, *key, MetricKind.RATIO).metric_value
points = [
metric_at(history.get(day, ()), *key, observation.metric_kind) for day in dates
]
available_days = sum(not point.missing for point in points)
on_list_count = None
if available_days and side is not RankSide.ALL:
on_list_count = sum(
point.in_top if side is RankSide.TOP else point.in_bottom for point in points
)
result[observation.sector_code] = RankingExtras(
index.pct_change if index else None,
amount * Decimal(100_000_000) if amount is not None else None,
ratio,
on_list_count,
available_days,
)
return result
def snapshots(
self, publication: RadarPublication
) -> dict[PublicationSourceGroup, list[SourceSnapshot]]:
"""Load exact source revisions, never global latest membership or provider data."""
grouped: dict[PublicationSourceGroup, list[SourceSnapshot]] = {}
for record in self.repository.load_publication_sources(publication.publication_id):
grouped.setdefault(record.source_group, []).append(record.snapshot)
return grouped
def history(self, target: date, sector_type: SectorType, sector_code: str) -> SectorHistory:
"""Return an exact-date publication and its compatible, past-only rank trajectory."""
publication = self.repository.get_successful_publication(target)
if publication is None:
return SectorHistory(
"no_data", target, None, None, sector_type, sector_code, None, (), 0
)
publications, rows, dates = self.history_data(publication)
current_rows = rows.get(target, ())
name = next(
(
row.observation.sector_name
for row in current_rows
if row.observation.sector_type is sector_type
and row.observation.sector_code == sector_code
),
None,
)
points = tuple(
HistoryPoint(
day,
publications[day].publication_id if day in publications else None,
metric_at(rows.get(day, ()), sector_type, sector_code, MetricKind.AMOUNT),
metric_at(rows.get(day, ()), sector_type, sector_code, MetricKind.RATIO),
metric_at(rows.get(day, ()), sector_type, sector_code, MetricKind.SWING),
)
for day in dates
)
return SectorHistory(
"success" if name is not None else "no_data",
target,
target,
publication,
sector_type,
sector_code,
name,
points,
sum(
not point.amount.missing or not point.ratio.missing or not point.swing.missing
for point in points
),
)
def detail(self, target: date, sector_type: SectorType, sector_code: str) -> SectorDetail:
"""Project independently sourced metrics and same-day overlap for confirmed members."""
history = self.history(target, sector_type, sector_code)
keys = ("pct_change", "net_amount_yuan", "active_buy_net_amount_yuan")
empty = {key: MemberLeaders((), ()) for key in keys}
summary = {kind.value: HistoryMetric() for kind in MetricKind}
if history.publication is None or history.status == "no_data":
return SectorDetail(history, None, None, summary, (), empty, ())
latest = next((point for point in history.points if point.trade_date == target), None)
if latest:
summary = {kind.value: getattr(latest, kind.value) for kind in MetricKind}
snapshots = self.snapshots(history.publication)
indices = [
SectorIndexRow.from_mapping(row, kind)
for group, kind in (
(PublicationSourceGroup.CONCEPT_INDICES, SectorType.CONCEPT),
(PublicationSourceGroup.INDUSTRY_INDICES, SectorType.INDUSTRY),
)
for snapshot in snapshots.get(group, ())
for row in snapshot.rows
]
index = next(
(
row
for row in indices
if row.sector_type is sector_type
and row.sector_code == sector_code
and row.trade_date == target
),
None,
)
basics = {
item.ts_code: item
for snapshot in snapshots.get(PublicationSourceGroup.STOCK_BASICS, ())
for row in snapshot.rows
for item in (StockBasicRow.from_mapping(row),)
if is_current_listed_stock(item, target)
}
memberships: dict[str, dict[str, str]] = {}
for snapshot in snapshots.get(PublicationSourceGroup.MEMBERS, ()):
for row in snapshot.rows:
member = SectorMemberRow.from_mapping(row)
if member.trade_date == target and member.stock_code in basics:
memberships.setdefault(member.sector_code, {})[member.stock_code] = (
member.stock_name
)
daily = {
item.ts_code: item
for snapshot in snapshots.get(PublicationSourceGroup.DAILY, ())
for row in snapshot.rows
for item in (DailyRow.from_mapping(row),)
if item.trade_date == target
}
main = {
item.ts_code: item
for snapshot in snapshots.get(PublicationSourceGroup.MONEYFLOW_DC, ())
for row in snapshot.rows
for item in (MoneyflowDcRow.from_mapping(row),)
if item.trade_date == target
}
active = {
item.ts_code: item
for snapshot in snapshots.get(PublicationSourceGroup.MONEYFLOW, ())
for row in snapshot.rows
for item in (MoneyflowRow.from_mapping(row),)
if item.trade_date == target
}
members = tuple(
DetailMember(
code,
name,
daily[code].pct_chg if code in daily else None,
main[code].net_amount_yuan if code in main else None,
active[code].active_buy_net_amount_yuan if code in active else None,
)
for code, name in sorted(memberships.get(sector_code, {}).items())
)
leaders = {key: member_leaders(members, key) for key in keys}
current_set = set(memberships.get(sector_code, {}))
similar: list[SimilarSector] = []
if current_set:
for candidate in indices:
if (candidate.sector_type, candidate.sector_code) == (
sector_type,
sector_code,
) or candidate.trade_date != target:
continue
other = set(memberships.get(candidate.sector_code, {}))
if not other:
continue
intersection, union = len(current_set & other), len(current_set | other)
if intersection:
similar.append(
SimilarSector(
candidate.sector_type,
candidate.sector_code,
candidate.name,
Decimal(intersection) / Decimal(union),
intersection,
union,
)
)
leading = None
if index is not None and index.leading_code:
basic = basics.get(index.leading_code)
leading = LeadingStock(index.leading_code, basic.name if basic else None)
return SectorDetail(
history,
index.pct_change if index else None,
leading,
summary,
members,
leaders,
tuple(
sorted(
similar,
key=lambda item: (
-item.overlap_ratio,
item.sector_code,
item.sector_type.value,
),
)[:4]
),
)
def metric_at(
rows: Sequence[RankedMetric], sector_type: SectorType, sector_code: str, kind: MetricKind
) -> HistoryMetric:
"""Select compatible rank values; pool thresholds use each day's actual percentile."""
pool = [
row
for row in rows
if row.observation.sector_type is sector_type
and row.observation.metric_kind is kind
and row.observation.metric_version == _METRIC_VERSIONS[kind]
]
size = sum(row.rank_position is not None for row in pool)
row = next((row for row in pool if row.observation.sector_code == sector_code), None)
if row is None:
return HistoryMetric(pool_size=size)
percentile = row.rank_percentile
return HistoryMetric(
row.rank_position,
percentile,
size,
row.observation.value,
row.rank_position is None,
percentile is not None and percentile >= 90,
percentile is not None and percentile <= 10,
)
def member_leaders(members: Sequence[DetailMember], key: str) -> MemberLeaders:
"""Exclude missing values and break metric ties by stock code on both sides."""
values: list[tuple[DetailMember, Decimal]] = []
for member in members:
value = {
"pct_change": member.pct_change,
"net_amount_yuan": member.net_amount_yuan,
"active_buy_net_amount_yuan": member.active_buy_net_amount_yuan,
}[key]
if value is not None:
values.append((member, value))
return MemberLeaders(
tuple(item[0] for item in sorted(values, key=lambda item: (-item[1], item[0].ts_code))[:5]),
tuple(item[0] for item in sorted(values, key=lambda item: (item[1], item[0].ts_code))[:5]),
)
@@ -3,7 +3,7 @@
from __future__ import annotations
from collections.abc import Iterator, Sequence
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import date
from enum import StrEnum
from typing import Literal
@@ -27,6 +27,7 @@ from ..domain.persistence import (
StockMembershipEntry,
)
from ..domain.ranking import select_percentile_side, select_rank_change_side
from .details import RankingExtras, ReadRadarDetails
ReadStatus = Literal["success", "no_data"]
@@ -106,6 +107,7 @@ class RankingPage:
definition: RadarMetricDefinition
rows: tuple[RankedMetric, ...]
total: int
extras: dict[str, RankingExtras] = field(default_factory=lambda: dict[str, RankingExtras]())
@dataclass(frozen=True, slots=True)
@@ -281,6 +283,11 @@ class ReadSectorRadar:
definition=definition,
rows=searched[start : start + query.page_size],
total=len(searched),
extras=ReadRadarDetails(self.repository).ranking_extras(
publication, searched[start : start + query.page_size], query.side
)
if query.view in {RadarView.AMOUNT, RadarView.RATIO}
else {},
)
def stock_membership(self, query: StockSectorQuery) -> StockSectorMembership:
@@ -12,6 +12,7 @@ from .persistence import MembershipRecord, StockFactRecord
from .source import (
DailyRow,
MoneyflowDcRow,
MoneyflowRow,
SectorIndexRow,
SectorMemberRow,
SourceContractError,
@@ -111,6 +112,7 @@ def normalize_stock_facts(
suspensions: SourceResult[SuspendRow],
daily: SourceResult[DailyRow],
moneyflow: SourceResult[MoneyflowDcRow],
active_moneyflow: SourceResult[MoneyflowRow] | None = None,
) -> tuple[StockFactRecord, ...]:
"""Build normalized yuan facts without collapsing missing states into zero.
@@ -121,6 +123,7 @@ def normalize_stock_facts(
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.
active_moneyflow: Optional independently sourced active-order flow in ten-thousand yuan.
Returns:
One deterministic fact per candidate code under a content-derived revision.
@@ -145,11 +148,25 @@ def normalize_stock_facts(
}
)
)
if active_moneyflow is not None:
source_snapshot_ids = tuple(
sorted(
set(source_snapshot_ids)
| {snapshot.snapshot_id for snapshot in active_moneyflow.snapshots}
)
)
if active_moneyflow is not None and any(
row.trade_date != target_trade_date for row in active_moneyflow.rows
):
raise SourceContractError("moneyflow rows must match target trade date")
active_by_code = _unique_index(
active_moneyflow.rows if active_moneyflow else (), lambda row: row.ts_code, "moneyflow"
)
revision_payload = json.dumps(
{
"target_trade_date": target_trade_date.isoformat(),
"source_snapshot_ids": source_snapshot_ids,
"normalizer": "zhixing_stock_fact_v1",
"normalizer": "zhixing_stock_fact_v2",
},
sort_keys=True,
separators=(",", ":"),
@@ -192,6 +209,16 @@ def normalize_stock_facts(
status=status,
turnover_yuan=turnover_yuan,
net_amount_yuan=net_amount_yuan,
pct_change=(
daily_row.pct_chg
if daily_row is not None and status is not StockFactStatus.LIFECYCLE_INVALID
else None
),
active_buy_net_amount_yuan=(
active_by_code[ts_code].active_buy_net_amount_yuan
if ts_code in active_by_code and status is not StockFactStatus.LIFECYCLE_INVALID
else None
),
)
)
return tuple(records)
@@ -109,6 +109,8 @@ class StockFactRecord:
status: StockFactStatus
turnover_yuan: Decimal | None = None
net_amount_yuan: Decimal | None = None
pct_change: Decimal | None = None
active_buy_net_amount_yuan: Decimal | None = None
def __post_init__(self) -> None:
"""Preserve source traceability and stock fact null semantics."""
@@ -122,6 +124,8 @@ class StockFactRecord:
_validate_digest(value, "source_snapshot_id")
if not self.ts_code.strip():
raise ValueError("ts_code must not be empty")
_validate_optional_decimal(self.pct_change, "pct_change")
_validate_optional_decimal(self.active_buy_net_amount_yuan, "active_buy_net_amount_yuan")
_validate_optional_decimal(self.turnover_yuan, "turnover_yuan")
_validate_optional_decimal(self.net_amount_yuan, "net_amount_yuan")
if self.status is StockFactStatus.AVAILABLE:
@@ -149,14 +153,17 @@ class RankingRecord:
@dataclass(frozen=True, slots=True)
class DailyAggregateRecord:
"""One exact daily strategy input owned by a publication revision."""
"""One exact daily strategy input and optional source detail owned by a publication."""
publication_id: str
aggregate: SectorDailyAggregate
pct_change: Decimal | None = None
leading_code: str | None = None
def __post_init__(self) -> None:
"""Validate the publication foreign identity."""
_validate_optional_decimal(self.pct_change, "pct_change")
if not self.publication_id.strip():
raise ValueError("publication_id must not be empty")
@@ -172,6 +179,7 @@ class PublicationSourceGroup(StrEnum):
SUSPENSIONS = "suspensions"
DAILY = "daily"
MONEYFLOW_DC = "moneyflow_dc"
MONEYFLOW = "moneyflow"
@dataclass(frozen=True, slots=True)
@@ -291,8 +299,14 @@ class SectorRadarRepository(Protocol):
def list_successful_dates(self) -> Sequence[date]: ...
def load_history_publications(self, target: date) -> Sequence[RadarPublication]: ...
def load_rankings(self, publication_id: str) -> Sequence[RankedMetric]: ...
def load_publication_rankings(
self, publication_ids: Sequence[str]
) -> Sequence[tuple[str, Sequence[RankedMetric]]]: ...
def load_daily_aggregate_history(
self, target_trade_date: date, *, limit_dates: int
) -> Sequence[SectorDailyAggregate]: ...
@@ -5,13 +5,14 @@ from __future__ import annotations
from collections.abc import Sequence
from contextlib import AbstractContextManager
from datetime import date
from typing import Protocol
from typing import Protocol, runtime_checkable
from .models import SectorType
from .source import (
CapabilityProbeResult,
DailyRow,
MoneyflowDcRow,
MoneyflowRow,
SectorIndexRow,
SectorMemberRow,
SourceResult,
@@ -51,6 +52,13 @@ class SectorRadarSource(Protocol):
def probe(self, trade_date: date) -> CapabilityProbeResult: ...
@runtime_checkable
class ActiveMoneyflowSource(Protocol):
"""Optional stock-detail capability; ranking-only sources remain valid."""
def fetch_moneyflow(self, trade_date: date) -> SourceResult[MoneyflowRow]: ...
class SectorRadarLock(Protocol):
"""Repository seam for a target-date advisory lock."""
@@ -463,6 +463,27 @@ class MoneyflowDcRow:
)
@dataclass(frozen=True, slots=True)
class MoneyflowRow:
"""Active buy/sell net flow; Tushare documents net_mf_amount in ten-thousand yuan."""
trade_date: date
ts_code: str
net_mf_amount: Decimal | None
@property
def active_buy_net_amount_yuan(self) -> Decimal | None:
"""Return yuan while retaining missing observations."""
return None if self.net_mf_amount is None else self.net_mf_amount * Decimal(10_000)
@classmethod
def from_mapping(cls, row: Mapping[str, SourceScalar]) -> MoneyflowRow:
"""Parse one dated active-flow row, rejecting non-finite amounts."""
trade_date = _source_date(row, "trade_date")
assert trade_date is not None
return cls(trade_date, _required_text(row, "ts_code"), _decimal(row, "net_mf_amount"))
class CapabilityStatus(StrEnum):
"""Safe capability outcomes that never expose provider error text."""
@@ -435,6 +435,14 @@ class InMemorySectorRadarRepository:
)
)
def load_history_publications(self, target: date) -> Sequence[RadarPublication]:
"""Return the latest successful revision per past date, newest first."""
return tuple(
self._latest_success_for_date(day)
for day in self.list_successful_dates()
if day <= target
)[:30]
def load_rankings(self, publication_id: str) -> Sequence[RankedMetric]:
"""Load every ranking projection owned by one publication."""
@@ -455,6 +463,12 @@ class InMemorySectorRadarRepository:
)
)
def load_publication_rankings(
self, publication_ids: Sequence[str]
) -> Sequence[tuple[str, Sequence[RankedMetric]]]:
"""Read exact immutable revisions selected by the history reader."""
return tuple((key, self.load_rankings(key)) for key in publication_ids)
def load_daily_aggregate_history(
self, target_trade_date: date, *, limit_dates: int
) -> Sequence[SectorDailyAggregate]:
@@ -379,6 +379,8 @@ class PostgresSectorRadarRepository:
item.status.value,
item.turnover_yuan,
item.net_amount_yuan,
item.pct_change,
item.active_buy_net_amount_yuan,
)
for item in items
)
@@ -392,6 +394,8 @@ class PostgresSectorRadarRepository:
"status",
"turnover_yuan",
"net_amount_yuan",
"pct_change",
"active_buy_net_amount_yuan",
),
("fact_revision", "ts_code"),
rows,
@@ -422,6 +426,8 @@ class PostgresSectorRadarRepository:
item.aggregate.turnover_yuan,
item.aggregate.membership_coverage,
item.aggregate.moneyflow_coverage,
item.pct_change,
item.leading_code,
)
for item in items
)
@@ -439,6 +445,8 @@ class PostgresSectorRadarRepository:
"turnover_yuan",
"membership_coverage",
"moneyflow_coverage",
"pct_change",
"leading_code",
),
("publication_id", "sector_type", "sector_code"),
rows,
@@ -566,6 +574,8 @@ class PostgresSectorRadarRepository:
"status",
"turnover_yuan",
"net_amount_yuan",
"pct_change",
"active_buy_net_amount_yuan",
),
("fact_revision", "ts_code"),
tuple(
@@ -577,6 +587,8 @@ class PostgresSectorRadarRepository:
item.status.value,
item.turnover_yuan,
item.net_amount_yuan,
item.pct_change,
item.active_buy_net_amount_yuan,
)
for item in stock_items
),
@@ -596,6 +608,8 @@ class PostgresSectorRadarRepository:
"turnover_yuan",
"membership_coverage",
"moneyflow_coverage",
"pct_change",
"leading_code",
),
("publication_id", "sector_type", "sector_code"),
tuple(
@@ -611,6 +625,8 @@ class PostgresSectorRadarRepository:
item.aggregate.turnover_yuan,
item.aggregate.membership_coverage,
item.aggregate.moneyflow_coverage,
item.pct_change,
item.leading_code,
)
for item in aggregate_items
),
@@ -798,6 +814,19 @@ class PostgresSectorRadarRepository:
).fetchall()
return tuple(row[0] for row in rows)
def load_history_publications(self, target: date) -> Sequence[RadarPublication]:
"""Batch-load at most thirty latest successful date revisions, excluding future data."""
with self._connection() as connection:
rows = connection.execute(
self._publication_select().replace(
"SELECT ", "SELECT DISTINCT ON (target_trade_date) ", 1
)
+ " WHERE status = 'success' AND target_trade_date <= %s "
+ "ORDER BY target_trade_date DESC, finished_at DESC, id DESC LIMIT 30",
(target,),
).fetchall()
return tuple(self._publication_from_row(row) for row in rows)
def load_rankings(self, publication_id: str) -> Sequence[RankedMetric]:
"""Load all ranking projections for one publication in deterministic order."""
@@ -816,6 +845,31 @@ class PostgresSectorRadarRepository:
).fetchall()
return tuple(self._ranking_from_row(row) for row in rows)
def load_publication_rankings(
self, publication_ids: Sequence[str]
) -> Sequence[tuple[str, Sequence[RankedMetric]]]:
"""Read all requested immutable revisions in one query, avoiding date races."""
if not publication_ids:
return ()
with self._connection() as connection:
rows = connection.execute(
"""
SELECT publication_id, trade_date, sector_type, sector_code, sector_name,
metric_kind, metric_version, implementation_kind, unit, metric_value,
quality, member_count, valid_sample_count, membership_coverage,
moneyflow_coverage, rank_position, rank_percentile, rank_changes
FROM sector_radar_ranking
WHERE publication_id = ANY(%s)
ORDER BY publication_id, sector_type, metric_kind, rank_position NULLS LAST,
sector_code
""",
(list(publication_ids),),
).fetchall()
grouped: dict[str, list[RankedMetric]] = {}
for row in rows:
grouped.setdefault(str(row[0]), []).append(self._ranking_from_row(row[1:]))
return tuple((key, tuple(grouped.get(key, ()))) for key in publication_ids)
def load_daily_aggregate_history(
self, target_trade_date: date, *, limit_dates: int
) -> Sequence[SectorDailyAggregate]:
@@ -22,6 +22,7 @@ from ..domain.source import (
CapabilityStatus,
DailyRow,
MoneyflowDcRow,
MoneyflowRow,
SectorIndexRow,
SectorMemberRow,
SourceContractError,
@@ -61,6 +62,7 @@ FIELDS: dict[str, tuple[str, ...]] = {
),
"suspend_d": ("ts_code", "trade_date", "suspend_timing", "suspend_type"),
"daily": ("ts_code", "trade_date", "close", "pre_close", "pct_chg", "vol", "amount"),
"moneyflow": ("trade_date", "ts_code", "net_mf_amount"),
"moneyflow_dc": (
"trade_date",
"ts_code",
@@ -80,6 +82,7 @@ ROW_LIMITS: dict[str, int | None] = {
"suspend_d": None,
"daily": 6_000,
"moneyflow_dc": 6_000,
"moneyflow": 6_000,
}
_SECTOR_TYPE_PARAM = {
@@ -312,6 +315,19 @@ 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(self, trade_date: date) -> SourceResult[MoneyflowRow]:
"""Fetch optional active-order flow, fail closed on truncation or wrong dates."""
snapshot = self._fetch_snapshot(
"moneyflow",
{"trade_date": trade_date.strftime("%Y%m%d")},
target_trade_date=trade_date,
)
self._reject_limit(snapshot)
rows = tuple(MoneyflowRow.from_mapping(row) for row in snapshot.rows)
self._require_target_date(rows, trade_date, "moneyflow")
self._require_unique(rows, key=lambda row: row.ts_code, api_name="moneyflow")
return SourceResult((snapshot,), tuple(sorted(rows, key=lambda row: row.ts_code)))
def fetch_moneyflow_dc(
self,
trade_date: date,
@@ -9,9 +9,15 @@ from decimal import Decimal
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from ....bootstrap.config import Settings, get_settings
from ..application.details import (
RankingExtras,
ReadRadarDetails,
SectorDetail,
SectorHistory,
)
from ..application.read import (
STOCK_SECTOR_CONCEPT_LIMIT,
RadarDateIndex,
@@ -101,6 +107,106 @@ class RadarRankingRowResponse(BaseModel):
rank_percentile: Decimal | None = Field(default=None, gt=0, le=100)
rank_change_days: int = Field(ge=1, le=5)
rank_change: int | None
pct_change: Decimal | None = None
daily_net_amount_yuan: Decimal | None = None
daily_ratio: Decimal | None = None
on_list_count: int | None = Field(default=None, ge=0, le=30)
history_available_days: int = Field(default=0, ge=0, le=30)
class RadarDetailModel(BaseModel):
"""Validate typed application projections without coupling them to Pydantic."""
model_config = ConfigDict(from_attributes=True)
class RadarHistoryMetricResponse(RadarDetailModel):
"""One comparable rank with the actual daily pool and missing state."""
rank_position: int | None
rank_percentile: Decimal | None
pool_size: int
metric_value: Decimal | None
missing: bool
in_top: bool
in_bottom: bool
class RadarHistoryPointResponse(RadarDetailModel):
"""Three independent ranks for one trading date."""
trade_date: date
publication_id: str | None
amount: RadarHistoryMetricResponse
ratio: RadarHistoryMetricResponse
swing: RadarHistoryMetricResponse
class RadarSectorIdentityResponse(RadarDetailModel):
"""Exact-date identity; a missing date never borrows a nearby publication."""
status: Literal["success", "no_data"]
requested_trade_date: date
trade_date: date | None
publication: RadarPublicationResponse | None
sector_type: SectorType
sector_code: str
sector_name: str | None
class RadarHistoryResponse(RadarSectorIdentityResponse):
"""At most thirty trading dates, with explicit unavailable points."""
points: list[RadarHistoryPointResponse]
window_size: int
available_days: int
class RadarMemberResponse(RadarDetailModel):
"""A listed member's independently nullable stock metrics, in yuan."""
ts_code: str
name: str
pct_change: Decimal | None
net_amount_yuan: Decimal | None
active_buy_net_amount_yuan: Decimal | None
class RadarLeadingStockResponse(RadarDetailModel):
"""Provider-designated leader; its name may be absent from the listed universe."""
ts_code: str
name: str | None
class RadarMemberLeadersResponse(RadarDetailModel):
"""Up to five observations per side, excluding missing values."""
top: list[RadarMemberResponse]
bottom: list[RadarMemberResponse]
class RadarSimilarSectorResponse(RadarDetailModel):
"""Same-publication Jaccard overlap, including cross-type candidates."""
sector_type: SectorType
sector_code: str
sector_name: str
overlap_ratio: Decimal
intersection_count: int
union_count: int
class RadarDetailResponse(RadarSectorIdentityResponse):
"""Persisted sector detail with exact-version ranks, members and overlap."""
pct_change: Decimal | None
leading_stock: RadarLeadingStockResponse | None
summary: dict[str, RadarHistoryMetricResponse]
history: RadarHistoryResponse
members: list[RadarMemberResponse]
leaders: dict[str, RadarMemberLeadersResponse]
similar_sectors: list[RadarSimilarSectorResponse]
def _empty_ranking_rows() -> list[RadarRankingRowResponse]:
@@ -143,8 +249,8 @@ class StockSectorMembershipResponse(BaseModel):
ts_code: str
requested_trade_date: date
trade_date: date | None
industries: list[SectorRefResponse] = Field(default_factory=list)
concepts: list[SectorRefResponse] = Field(default_factory=list)
industries: list[SectorRefResponse] = Field(default_factory=lambda: list[SectorRefResponse]())
concepts: list[SectorRefResponse] = Field(default_factory=lambda: list[SectorRefResponse]())
concept_total: int = Field(ge=0)
concept_limit: int = Field(ge=1, le=100)
@@ -223,6 +329,91 @@ def get_sector_radar_rankings(
raise _storage_error() from exc
@sector_radar_router.get(
"/sectors/{sector_type}/{sector_code}/history", response_model=RadarHistoryResponse
)
def get_sector_history(
reader: Annotated[ReadSectorRadar, Depends(get_sector_radar_reader)],
sector_type: SectorType,
sector_code: Annotated[str, Path(min_length=1, max_length=32, pattern=r"\S")],
trade_date: date,
) -> RadarHistoryResponse:
"""Read an exact-date sector history without provider IO or future fallback."""
try:
return _history_response(
ReadRadarDetails(reader.repository).history(
trade_date, sector_type, sector_code.strip()
)
)
except SectorRadarRepositoryError as exc:
raise _storage_error() from exc
@sector_radar_router.get(
"/sectors/{sector_type}/{sector_code}/detail", response_model=RadarDetailResponse
)
def get_sector_detail(
reader: Annotated[ReadSectorRadar, Depends(get_sector_radar_reader)],
sector_type: SectorType,
sector_code: Annotated[str, Path(min_length=1, max_length=32, pattern=r"\S")],
trade_date: date,
) -> RadarDetailResponse:
"""Read independently sourced stock metrics and confirmed member-set overlap."""
try:
return _detail_response(
ReadRadarDetails(reader.repository).detail(trade_date, sector_type, sector_code.strip())
)
except SectorRadarRepositoryError as exc:
raise _storage_error() from exc
def _history_response(history: SectorHistory) -> RadarHistoryResponse:
"""Convert publication metadata explicitly at the HTTP boundary."""
return RadarHistoryResponse(
status="success" if history.status == "success" else "no_data",
requested_trade_date=history.requested_trade_date,
trade_date=history.trade_date,
publication=_publication_response(history.publication) if history.publication else None,
sector_type=history.sector_type,
sector_code=history.sector_code,
sector_name=history.sector_name,
points=[RadarHistoryPointResponse.model_validate(point) for point in history.points],
window_size=history.window_size,
available_days=history.available_days,
)
def _detail_response(detail: SectorDetail) -> RadarDetailResponse:
"""Keep the same resolved publication identity in detail and nested history."""
history = _history_response(detail.history)
return RadarDetailResponse(
status=history.status,
requested_trade_date=history.requested_trade_date,
trade_date=history.trade_date,
publication=history.publication,
sector_type=history.sector_type,
sector_code=history.sector_code,
sector_name=history.sector_name,
pct_change=detail.pct_change,
leading_stock=RadarLeadingStockResponse.model_validate(detail.leading_stock)
if detail.leading_stock
else None,
summary={
key: RadarHistoryMetricResponse.model_validate(value)
for key, value in detail.summary.items()
},
history=history,
members=[RadarMemberResponse.model_validate(member) for member in detail.members],
leaders={
key: RadarMemberLeadersResponse.model_validate(value)
for key, value in detail.leaders.items()
},
similar_sectors=[
RadarSimilarSectorResponse.model_validate(value) for value in detail.similar_sectors
],
)
@sector_radar_router.get(
"/stocks/{ts_code}/membership", response_model=StockSectorMembershipResponse
)
@@ -276,7 +467,12 @@ def _rankings_response(page: RankingPage) -> RadarRankingsResponse:
page=query.page,
page_size=query.page_size,
total=page.total,
rows=[_ranking_response(row, query.rank_change_days) for row in page.rows],
rows=[
_ranking_response(
row, query.rank_change_days, page.extras.get(row.observation.sector_code)
)
for row in page.rows
],
)
@@ -328,8 +524,11 @@ def _definition_response(
)
def _ranking_response(row: RankedMetric, rank_change_days: int) -> RadarRankingRowResponse:
def _ranking_response(
row: RankedMetric, rank_change_days: int, extras: RankingExtras | None = None
) -> RadarRankingRowResponse:
observation = row.observation
extras = extras or RankingExtras()
return RadarRankingRowResponse(
trade_date=observation.trade_date,
sector_type=observation.sector_type,
@@ -349,6 +548,11 @@ def _ranking_response(row: RankedMetric, rank_change_days: int) -> RadarRankingR
rank_percentile=row.rank_percentile,
rank_change_days=rank_change_days,
rank_change=row.rank_change(rank_change_days),
pct_change=extras.pct_change,
daily_net_amount_yuan=extras.daily_net_amount_yuan,
daily_ratio=extras.daily_ratio,
on_list_count=extras.on_list_count,
history_available_days=extras.history_available_days,
)
@@ -3,13 +3,14 @@ from dataclasses import replace
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from pathlib import Path
from unittest.mock import patch
import psycopg
import pytest
from alembic import command
from alembic.config import Config
from zhixing_server.bootstrap.config import sqlalchemy_database_url
from zhixing_server.bootstrap.config import Settings, sqlalchemy_database_url
from zhixing_server.modules.sector_radar.domain.models import (
MembershipStatus,
PublicationStatus,
@@ -35,7 +36,12 @@ def prepare_database(database_url: str) -> None:
config = Config(str(server_root / "alembic.ini"))
sqlalchemy_url = sqlalchemy_database_url(database_url)
config.set_main_option("sqlalchemy.url", sqlalchemy_url.replace("%", "%%"))
command.upgrade(config, "head")
config.config_file_name = None
with patch(
"zhixing_server.bootstrap.config.get_settings",
return_value=Settings(database_url=database_url),
):
command.upgrade(config, "head")
@pytest.mark.integration
@@ -111,12 +117,22 @@ def test_postgres_sector_radar_revisions_and_last_good() -> None:
status=StockFactStatus.AVAILABLE,
turnover_yuan=Decimal("1000"),
net_amount_yuan=Decimal("100"),
pct_change=Decimal("1.25"),
active_buy_net_amount_yuan=Decimal("-25000"),
),
)
).inserted
== 1
)
with psycopg.connect(database_url) as connection:
detail_fact = connection.execute(
"SELECT pct_change, active_buy_net_amount_yuan "
"FROM sector_radar_stock_fact WHERE fact_revision = %s",
(fact_revision,),
).fetchone()
assert detail_fact == (Decimal("1.25"), Decimal("-25000"))
running = RadarPublication(
publication_id=publication_ids[0],
target_trade_date=TARGET_DATE,
@@ -639,3 +639,229 @@ def test_history_uses_latest_successful_input_revision_for_a_date() -> None:
assert second.status == "success"
assert len(history) == 2
assert all(item.net_amount_yuan == Decimal(300_000) for item in history)
def test_detail_history_is_thirty_sessions_past_only_and_latest_revision() -> None:
from zhixing_server.modules.sector_radar.application.details import ReadRadarDetails
repository = InMemorySectorRadarRepository()
end = TARGET_DATE + timedelta(days=33)
summary = BuildSectorRadar(FakeRadarSource(), repository, now_fn=lambda: NOW).execute(
BuildSectorRadarCommand(start_date=TARGET_DATE, end_date=end)
)
assert summary.status == "success"
target = end - timedelta(days=1)
replacement = BuildSectorRadar(
FakeRadarSource(net_scale=Decimal(2)),
repository,
now_fn=lambda: NOW + timedelta(hours=1),
).execute(BuildSectorRadarCommand(trade_date=target))
reader = ReadRadarDetails(repository)
history = reader.history(target, SectorType.CONCEPT, "BK0001.DC")
assert len(history.points) == 30
assert history.points[0].trade_date == target - timedelta(days=29)
assert history.points[-1].trade_date == target
assert len({point.trade_date for point in history.points}) == 30
assert history.points[-1].publication_id == replacement.outcomes[0].publication_id
assert history.points[-1].amount.metric_value == Decimal("0.003")
assert history.available_days == 30
assert (
reader.history(TARGET_DATE - timedelta(days=1), SectorType.CONCEPT, "BK0001.DC").status
== "no_data"
)
old = repository.get_successful_publication(target - timedelta(days=1))
assert old is not None
repository.publications[old.publication_id] = replace(old, source_version="incompatible-v2")
isolated = reader.history(target, SectorType.CONCEPT, "BK0001.DC")
assert isolated.points[-2].amount.missing
assert isolated.available_days == 29
def test_optional_moneyflow_failure_keeps_main_rankings_and_nullable_details() -> None:
from zhixing_server.modules.sector_radar.application.details import ReadRadarDetails
from zhixing_server.modules.sector_radar.domain.source import MoneyflowRow
class UnavailableActiveSource(FakeRadarSource):
def fetch_moneyflow(self, trade_date: date) -> SourceResult[MoneyflowRow]:
raise SourceContractError("optional provider unavailable")
repository = InMemorySectorRadarRepository()
summary = BuildSectorRadar(UnavailableActiveSource(), repository, now_fn=lambda: NOW).execute(
BuildSectorRadarCommand(trade_date=TARGET_DATE)
)
assert summary.status == "success"
detail = ReadRadarDetails(repository).detail(TARGET_DATE, SectorType.CONCEPT, "BK0001.DC")
assert detail.pct_change == Decimal(1)
assert len(detail.members) == 5
assert all(member.active_buy_net_amount_yuan is None for member in detail.members)
assert detail.members[0].net_amount_yuan == Decimal(10000)
assert detail.members[0].pct_change == Decimal(0)
assert detail.leaders["active_buy_net_amount_yuan"].top == ()
assert detail.summary["amount"].metric_value == Decimal("0.0015")
def test_detail_jaccard_uses_same_day_current_listed_members_and_independent_values() -> None:
from zhixing_server.modules.sector_radar.application.details import ReadRadarDetails
from zhixing_server.modules.sector_radar.domain.source import MoneyflowRow
class DetailSource(FakeRadarSource):
def fetch_sector_members(
self, trade_date: date, sector_codes: Sequence[str]
) -> SourceResult[SectorMemberRow]:
rows = tuple(
SectorMemberRow(trade_date, code, f"00000{index}.SZ", f"股票{index}")
for code in sector_codes
for index in ((1, 2, 6) if code == "BK0001.DC" else (2, 3))
)
return self._result("dc_member", trade_date, rows)
def fetch_moneyflow(self, trade_date: date) -> SourceResult[MoneyflowRow]:
raw = (
{
"trade_date": trade_date.isoformat(),
"ts_code": "000001.SZ",
"net_mf_amount": "-2.5",
},
)
snapshot = build_source_snapshot(
api_name="moneyflow",
params={},
rows=raw,
target_trade_date=trade_date,
observed_at=NOW,
)
return SourceResult((snapshot,), tuple(MoneyflowRow.from_mapping(row) for row in raw))
repository = InMemorySectorRadarRepository()
result = BuildSectorRadar(DetailSource(), repository, now_fn=lambda: NOW).execute(
BuildSectorRadarCommand(trade_date=TARGET_DATE)
)
assert result.status == "success"
reader = ReadRadarDetails(repository)
detail = reader.detail(TARGET_DATE, SectorType.CONCEPT, "BK0001.DC")
assert [member.ts_code for member in detail.members] == ["000001.SZ", "000002.SZ"]
assert detail.members[0].active_buy_net_amount_yuan == Decimal(-25000)
assert detail.members[0].net_amount_yuan == Decimal(10000)
assert detail.members[1].active_buy_net_amount_yuan is None
assert len(detail.leaders["active_buy_net_amount_yuan"].top) == 1
assert detail.similar_sectors[0].intersection_count == 1
assert detail.similar_sectors[0].union_count == 3
assert detail.similar_sectors[0].overlap_ratio == Decimal(1) / Decimal(3)
assert detail.similar_sectors[0].sector_type is SectorType.INDUSTRY
# A later build with different membership cannot alter the older detail.
BuildSectorRadar(FakeRadarSource(), repository, now_fn=lambda: NOW).execute(
BuildSectorRadarCommand(trade_date=TARGET_DATE + timedelta(days=1))
)
assert reader.detail(TARGET_DATE, SectorType.CONCEPT, "BK0001.DC") == detail
def test_detail_history_and_ranking_extras_http_use_the_same_publication() -> None:
from fastapi.testclient import TestClient
from zhixing_server.bootstrap.app import create_app
from zhixing_server.modules.sector_radar.application.read import ReadSectorRadar
from zhixing_server.modules.sector_radar.presentation.http import get_sector_radar_reader
repository = InMemorySectorRadarRepository()
result = BuildSectorRadar(FakeRadarSource(), repository, now_fn=lambda: NOW).execute(
BuildSectorRadarCommand(trade_date=TARGET_DATE)
)
app = create_app()
app.dependency_overrides[get_sector_radar_reader] = lambda: ReadSectorRadar(repository)
with TestClient(app) as client:
params = {"trade_date": TARGET_DATE.isoformat()}
base = "/api/v1/sector-radar/sectors/concept/BK0001.DC"
history = client.get(base + "/history", params=params)
detail = client.get(base + "/detail", params=params)
ranking = client.get(
"/api/v1/sector-radar/rankings", params={**params, "view": "amount", "side": "top"}
)
assert history.status_code == detail.status_code == ranking.status_code == 200
payload = detail.json()
assert payload["publication"]["publication_id"] == result.outcomes[0].publication_id
assert payload["history"] == history.json()
assert payload["pct_change"] == "1"
assert payload["members"][0]["active_buy_net_amount_yuan"] is None
row = ranking.json()["rows"][0]
assert row["pct_change"] == "1"
assert Decimal(row["daily_net_amount_yuan"]) == 150000
assert Decimal(row["daily_ratio"]) == Decimal("0.03")
assert row["on_list_count"] == row["history_available_days"] == 1
assert client.get(base + "/detail").status_code == 422
absent = client.get(base + "/detail", params={"trade_date": "2020-01-01"})
assert absent.json()["status"] == "no_data"
assert absent.json()["members"] == []
@pytest.mark.integration
def test_postgres_detail_migration_and_build_roundtrip(monkeypatch: pytest.MonkeyPatch) -> None:
import os
from pathlib import Path
import psycopg
from alembic import command
from alembic.config import Config
from zhixing_server.bootstrap.config import sqlalchemy_database_url
from zhixing_server.modules.sector_radar.application.details import ReadRadarDetails
from zhixing_server.modules.sector_radar.infrastructure.postgres import (
PostgresSectorRadarRepository,
)
database_url = os.getenv("ZHIXING_TEST_DATABASE_URL")
if not database_url:
pytest.skip("set ZHIXING_TEST_DATABASE_URL to run PostgreSQL integration tests")
config = Config(str(Path(__file__).parents[3] / "alembic.ini"))
config.set_main_option(
"sqlalchemy.url", sqlalchemy_database_url(database_url).replace("%", "%%")
)
# Alembic intentionally reads Settings; bind the explicit test DSN and avoid
# fileConfig disabling unrelated test loggers in the same pytest process.
config.config_file_name = None
with monkeypatch.context() as context:
context.setenv("ZHIXING_DATABASE_URL", database_url)
from zhixing_server.bootstrap.config import get_settings
get_settings.cache_clear()
try:
command.upgrade(config, "head")
finally:
get_settings.cache_clear()
target = date(2098, 12, 1)
repository = PostgresSectorRadarRepository(database_url, max_connections=2)
try:
result = BuildSectorRadar(FakeRadarSource(), repository, now_fn=lambda: NOW).execute(
BuildSectorRadarCommand(trade_date=target)
)
assert result.status in ("success", "unchanged")
detail = ReadRadarDetails(repository).detail(target, SectorType.CONCEPT, "BK0001.DC")
assert detail.history.status == "success"
assert detail.pct_change == Decimal(1)
assert len(detail.members) == 5
assert detail.members[0].active_buy_net_amount_yuan is None
assert detail.summary["amount"].metric_value == Decimal("0.0015")
with psycopg.connect(database_url) as connection:
assert connection.execute("SELECT version_num FROM alembic_version").fetchone() == (
"0009_radar_sector_detail",
)
row = connection.execute(
"SELECT pct_change, leading_code FROM sector_radar_daily_aggregate "
"WHERE publication_id = %s AND sector_type = 'concept'",
(result.outcomes[0].publication_id,),
).fetchone()
assert row == (Decimal(1), "000001.SZ")
facts = connection.execute(
"SELECT pct_change, active_buy_net_amount_yuan "
"FROM sector_radar_stock_fact WHERE trade_date = %s",
(target,),
).fetchall()
assert facts and all(row == (Decimal(0), None) for row in facts)
with pytest.raises(psycopg.errors.CheckViolation), connection.transaction():
connection.execute(
"UPDATE sector_radar_stock_fact "
"SET active_buy_net_amount_yuan = 'NaN'::numeric WHERE trade_date = %s",
(target,),
)
finally:
repository.close()
@@ -402,3 +402,40 @@ def test_sector_member_codes_rejects_blank_sector_code() -> None:
with pytest.raises(ValueError):
reader.sector_member_codes(TARGET_DATE, " ")
def test_history_appearance_counts_use_each_days_pool_and_metric_version() -> None:
from zhixing_server.modules.sector_radar.application.details import ReadRadarDetails
repository = InMemorySectorRadarRepository()
current_rows = _amount_rankings()
for offset, size in ((-1, 5), (0, 10), (1, 20)):
day = TARGET_DATE + timedelta(days=offset)
running = _running(f"pool-{offset}", day)
repository.create_publication(running)
repository.finish_publication(_finish(running, PublicationStatus.SUCCESS))
observations = tuple(
replace(row.observation, trade_date=day) for row in current_rows[:size]
)
repository.save_rankings(
tuple(
RankingRecord(running.publication_id, row)
for row in rank_metric_observations(observations)
)
)
reader = ReadRadarDetails(repository)
history = reader.history(TARGET_DATE, SectorType.CONCEPT, "BK0002.DC")
assert [point.amount.pool_size for point in history.points] == [5, 10]
assert [point.amount.in_top for point in history.points] == [False, True]
publication = repository.get_successful_publication(TARGET_DATE)
assert publication is not None
extra = reader.ranking_extras(publication, (current_rows[1],), RankSide.TOP)["BK0002.DC"]
assert extra.on_list_count == 1
assert extra.history_available_days == 2
old_version = replace(
current_rows[1],
observation=replace(current_rows[1].observation, metric_version="older-version"),
)
from zhixing_server.modules.sector_radar.application.details import metric_at
assert metric_at((old_version,), SectorType.CONCEPT, "BK0002.DC", MetricKind.AMOUNT).missing