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:
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hash": "be29b65c",
|
||||
"configHash": "93a0ae7b",
|
||||
"lockfileHash": "e3b0c442",
|
||||
"browserHash": "4df63514",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user