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:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user