feat(sector-radar): add weighted scores and rank-change views

This commit is contained in:
yuxuanhui
2026-09-21 23:57:55 +08:00
parent 669e89d3c3
commit b9981aa48d
45 changed files with 2468 additions and 320 deletions
@@ -0,0 +1,27 @@
"""Keep weighted ranking scores separate from raw flow values."""
import sqlalchemy as sa
from alembic import op
revision = "0010_radar_weighted_score"
down_revision = "0009_radar_sector_detail"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Preserve existing publication rows with an unknown (NULL) score."""
op.add_column(
"sector_radar_ranking", sa.Column("weighted_score", sa.Numeric(28, 12), nullable=True)
)
op.create_check_constraint(
"ck_radar_weighted_score_finite",
"sector_radar_ranking",
"weighted_score IS NULL OR weighted_score NOT IN "
"('NaN'::numeric, 'Infinity'::numeric, '-Infinity'::numeric)",
)
def downgrade() -> None:
"""Remove the additional score projection without changing raw values."""
op.drop_column("sector_radar_ranking", "weighted_score")
@@ -18,14 +18,10 @@ from zhixing_server.shared.request_coordinator import TushareSourceError
from ..domain.facts import aggregate_sector_snapshot
from ..domain.metrics import (
AmountNetStrategy,
MetricStrategy,
RatioTurnoverStrategy,
SwingEqualThreeToTenStrategy,
)
from ..domain.models import (
MembershipStatus,
MetricObservation,
PublicationStatus,
RadarPublication,
RankedMetric,
@@ -50,7 +46,6 @@ from ..domain.persistence import (
StockFactRecord,
)
from ..domain.ports import ActiveMoneyflowSource, SectorRadarSource
from ..domain.ranking import rank_metric_observations, with_rank_changes
from ..domain.source import (
DailyRow,
MoneyflowDcRow,
@@ -65,6 +60,7 @@ from ..domain.source import (
SuspendRow,
TradeCalendarRow,
)
from .scoring import calculate_rankings, calendar_rank_changes, default_strategies
BuildOutcomeStatus = Literal["success", "partial", "failed", "locked", "unchanged"]
SHANGHAI = ZoneInfo("Asia/Shanghai")
@@ -194,14 +190,7 @@ class BuildSectorRadar:
self.repository = repository
self.coverage_threshold = coverage_threshold
self.today = today or self.now_fn().astimezone(SHANGHAI).date()
self.strategies = tuple(
strategies
or (
AmountNetStrategy(),
RatioTurnoverStrategy(),
SwingEqualThreeToTenStrategy(),
)
)
self.strategies = tuple(strategies) if strategies is not None else default_strategies()
def execute(self, command: BuildSectorRadarCommand | None = None) -> BuildSummary:
"""Build each selected trade date sequentially for deterministic history."""
@@ -314,7 +303,13 @@ class BuildSectorRadar:
publication_created = True
reusable = self._reusable_sources(target.retry_publication_id)
collected = self._collect(target.trade_date, publication_id, reusable)
input_hash = self._input_hash(collected.snapshots)
previous_publications = tuple(
item
for item in self.repository.load_history_publications(target.trade_date)
if item.target_trade_date < target.trade_date
and item.source_version == self.source_version
)[:9]
input_hash = self._input_hash(collected.snapshots, previous_publications)
existing = self.repository.find_reusable_publication(target.trade_date, input_hash)
if existing is not None:
self.repository.discard_running_publication(publication_id)
@@ -339,7 +334,9 @@ class BuildSectorRadar:
input_hash=input_hash,
)
aggregates = self._aggregate(collected)
rankings = self._rank(target.trade_date, aggregates)
rankings = self._rank(
target.trade_date, aggregates, collected.trading_dates, previous_publications
)
coverage = self._coverage(collected.stock_facts)
membership_complete = all(
item.status is MembershipStatus.AVAILABLE for item in collected.memberships
@@ -638,6 +635,7 @@ class BuildSectorRadar:
)
return _CollectedInputs(
target_trade_date=target,
trading_dates=tuple(sorted({row.cal_date for row in calendar.rows if row.is_open})),
indices=concepts.rows + industries.rows,
snapshots=snapshots,
membership_snapshots=members.snapshots,
@@ -692,24 +690,29 @@ class BuildSectorRadar:
return aggregates
def _rank(
self, target: date, aggregates: Sequence[SectorDailyAggregate]
self,
target: date,
aggregates: Sequence[SectorDailyAggregate],
trading_dates: Sequence[date],
previous_publications: Sequence[RadarPublication],
) -> tuple[RankedMetric, ...]:
history = tuple(self.repository.load_daily_aggregate_history(target, limit_dates=9))
observations: list[MetricObservation] = []
for current in aggregates:
sector_history = tuple(
item
for item in history
if (item.sector_type, item.sector_code)
== (current.sector_type, current.sector_code)
) + (current,)
observations.extend(
strategy.evaluate(sector_history, target) for strategy in self.strategies
history = tuple(
record.aggregate
for record in self.repository.load_publication_aggregates(
tuple(item.publication_id for item in previous_publications)
)
current_rankings = rank_metric_observations(observations)
previous = self.repository.load_previous_rankings(target, limit_dates=5)
history_by_days = {days: rankings for days, (_, rankings) in enumerate(previous, start=1)}
return with_rank_changes(current_rankings, history_by_days)
)
current_rankings = calculate_rankings(
target, aggregates, history, trading_dates, self.strategies
)
dates_by_id = {
item.publication_id: item.target_trade_date for item in previous_publications[:5]
}
previous = {
dates_by_id[key]: rows
for key, rows in self.repository.load_publication_rankings(tuple(dates_by_id))
}
return calendar_rank_changes(current_rankings, previous, trading_dates, target)
@staticmethod
def _coverage(stock_facts: Sequence[StockFactRecord]) -> Decimal:
@@ -750,12 +753,15 @@ class BuildSectorRadar:
groups.append(PublicationSourceGroup.MONEYFLOW_DC)
return tuple(groups)
def _input_hash(self, snapshots: Sequence[SourceSnapshot]) -> str:
def _input_hash(
self, snapshots: Sequence[SourceSnapshot], previous: Sequence[RadarPublication] = ()
) -> str:
payload = json.dumps(
{
"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_v2",
"history": [(item.publication_id, item.input_hash) for item in previous],
},
sort_keys=True,
separators=(",", ":"),
@@ -785,6 +791,7 @@ class BuildSectorRadar:
@dataclass(frozen=True, slots=True)
class _CollectedInputs:
target_trade_date: date
trading_dates: tuple[date, ...]
indices: tuple[SectorIndexRow, ...]
snapshots: tuple[SourceSnapshot, ...]
membership_snapshots: tuple[SourceSnapshot, ...]
@@ -7,7 +7,6 @@ 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 HistoricalRanking, PublicationSourceGroup, SectorRadarRepository
@@ -20,12 +19,7 @@ from ..domain.source import (
StockBasicRow,
TradeCalendarRow,
)
_METRIC_VERSIONS = {
MetricKind.AMOUNT: AmountNetStrategy.metric_version,
MetricKind.RATIO: RatioTurnoverStrategy.metric_version,
MetricKind.SWING: SwingEqualThreeToTenStrategy.metric_version,
}
from ..domain.weighted import resolve_metric_version
@dataclass(frozen=True, slots=True)
@@ -50,6 +44,7 @@ class HistoryMetric:
missing: bool = True
in_top: bool = False
in_bottom: bool = False
weighted_score: Decimal | None = None
@dataclass(frozen=True, slots=True)
@@ -134,7 +129,8 @@ class SectorDetail:
class _RankHistory:
"""Index one day's selected ranks once, retaining complete versioned pool counts."""
def __init__(self, records: Sequence[HistoricalRanking]) -> None:
def __init__(self, records: Sequence[HistoricalRanking], versions: Sequence[str] = ()) -> None:
self.versions = tuple(versions) or tuple(record.metric_version for record in records)
self.pools: dict[tuple[SectorType, MetricKind, str], int] = {}
self.rankings: dict[tuple[SectorType, str, MetricKind, str], RankedMetric] = {}
self.names: dict[tuple[SectorType, str], str] = {}
@@ -158,7 +154,9 @@ class _RankHistory:
)
def metric(self, sector_type: SectorType, sector_code: str, kind: MetricKind) -> HistoryMetric:
version = _METRIC_VERSIONS[kind]
version = resolve_metric_version(kind, self.versions)
if version is None:
return HistoryMetric()
size = self.pools.get((sector_type, kind, version), 0)
row = self.rankings.get((sector_type, sector_code, kind, version))
return _metric_from_ranking(row, size)
@@ -187,9 +185,9 @@ class ReadRadarDetails:
):
by_id.setdefault(record.publication_id, []).append(record)
rows = {
day: _RankHistory(by_id.get(item.publication_id, ()))
day: _RankHistory(by_id.get(item.publication_id, ()), publication.metric_versions)
if item.source_version == publication.source_version
else _RankHistory(())
else _RankHistory((), publication.metric_versions)
for day, item in publications.items()
}
sources = self.repository.load_publication_rows(
@@ -209,7 +207,11 @@ class ReadRadarDetails:
| set(publications)
)[-30:]
)
return publications, {day: rows.get(day, _RankHistory(())) for day in dates}, dates
return (
publications,
{day: rows.get(day, _RankHistory((), publication.metric_versions)) for day in dates},
dates,
)
def ranking_extras(
self, publication: RadarPublication, rankings: Sequence[RankedMetric], side: RankSide
@@ -438,12 +440,13 @@ 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."""
version = resolve_metric_version(kind, (row.observation.metric_version for row in rows))
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]
and row.observation.metric_version == version
]
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)
@@ -463,6 +466,7 @@ def _metric_from_ranking(row: RankedMetric | None, size: int) -> HistoryMetric:
row.rank_position is None,
percentile is not None and percentile >= 90,
percentile is not None and percentile <= 10,
row.observation.weighted_score,
)
@@ -27,7 +27,13 @@ from ..domain.persistence import (
StockMembershipEntry,
)
from ..domain.ranking import select_percentile_side, select_rank_change_side
from ..domain.weighted import (
RatioWeightedStrategy,
SwingWeightedStrategy,
resolve_metric_version,
)
from .details import RankingExtras, ReadRadarDetails
from .scoring import calendar_rank_changes, comparison_dates, publication_trading_dates
ReadStatus = Literal["success", "no_data"]
@@ -62,7 +68,7 @@ class RadarQuery:
trade_date: date | None = None
sector_type: SectorType = SectorType.CONCEPT
view: RadarView = RadarView.AMOUNT
rank_change_metric: MetricKind = MetricKind.AMOUNT
rank_change_metric: MetricKind = MetricKind.SWING
rank_change_days: int = 1
side: RankSide = RankSide.ALL
search: str | None = None
@@ -108,6 +114,10 @@ class RankingPage:
rows: tuple[RankedMetric, ...]
total: int
extras: dict[str, RankingExtras] = field(default_factory=lambda: dict[str, RankingExtras]())
rank_change_values: dict[str, dict[MetricKind, int | None]] = field(
default_factory=lambda: dict[str, dict[MetricKind, int | None]]()
)
comparison_trade_date: date | None = None
@dataclass(frozen=True, slots=True)
@@ -175,27 +185,54 @@ class SectorMembersSnapshot:
_METRIC_DEFINITIONS = {
MetricKind.AMOUNT: RadarMetricDefinition(
AmountNetStrategy.metric_version: RadarMetricDefinition(
metric_kind=MetricKind.AMOUNT,
metric_version=AmountNetStrategy.metric_version,
label="主力净流入(知行独立实现)",
unit=MetricUnit.CNY_100M,
),
MetricKind.RATIO: RadarMetricDefinition(
RatioTurnoverStrategy.metric_version: RadarMetricDefinition(
metric_kind=MetricKind.RATIO,
metric_version=RatioTurnoverStrategy.metric_version,
label="主力净流入/成交额(知行独立实现)",
unit=MetricUnit.RATIO,
),
MetricKind.SWING: RadarMetricDefinition(
SwingEqualThreeToTenStrategy.metric_version: RadarMetricDefinition(
metric_kind=MetricKind.SWING,
metric_version=SwingEqualThreeToTenStrategy.metric_version,
label="3—10 日等权资金率(知行独立实现)",
unit=MetricUnit.RATIO,
),
RatioWeightedStrategy.metric_version: RadarMetricDefinition(
metric_kind=MetricKind.RATIO,
metric_version=RatioWeightedStrategy.metric_version,
label="单日流入率",
unit=MetricUnit.RATIO,
disclaimer="基于知行数据的资金排名与成交额加权评分",
),
SwingWeightedStrategy.metric_version: RadarMetricDefinition(
metric_kind=MetricKind.SWING,
metric_version=SwingWeightedStrategy.metric_version,
label="波段流入率",
unit=MetricUnit.RATIO,
disclaimer="3 日与 10 日资金率排名各占 50%,再按成交额加权",
),
}
def metric_definition(
kind: MetricKind, publication: RadarPublication | None
) -> RadarMetricDefinition:
"""Resolve a publication's actual algorithm instead of hiding legacy rows."""
current = {
MetricKind.AMOUNT: AmountNetStrategy.metric_version,
MetricKind.RATIO: RatioWeightedStrategy.metric_version,
MetricKind.SWING: SwingWeightedStrategy.metric_version,
}[kind]
version = resolve_metric_version(kind, publication.metric_versions) if publication else None
return _METRIC_DEFINITIONS[version or current]
class ReadSectorRadar:
"""Hide last-good selection, ranking filters, search, and pagination."""
@@ -219,18 +256,45 @@ class ReadSectorRadar:
if query.view is RadarView.RANK_CHANGE
else MetricKind(query.view.value)
)
definition = _METRIC_DEFINITIONS[metric_kind]
publication = (
self.repository.get_successful_publication(query.trade_date)
if query.trade_date is not None
else self.repository.get_last_good_publication()
)
definition = metric_definition(metric_kind, publication)
if publication is None:
return RankingPage("no_data", query, None, definition, (), 0)
all_rows = tuple(self.repository.load_rankings(publication.publication_id))
compared_date = None
if query.view is RadarView.RANK_CHANGE:
calendar = publication_trading_dates(
self.repository, publication.publication_id, publication.target_trade_date
)
previous_publications = tuple(
item
for item in self.repository.load_history_publications(publication.target_trade_date)
if item.target_trade_date < publication.target_trade_date
and item.source_version == publication.source_version
)[:5]
dates_by_id = {
item.publication_id: item.target_trade_date for item in previous_publications
}
previous = {
dates_by_id[publication_id]: rows
for publication_id, rows in self.repository.load_publication_rankings(
tuple(dates_by_id)
)
}
all_rows = calendar_rank_changes(
all_rows, previous, calendar, publication.target_trade_date
)
compared_date = comparison_dates(calendar, publication.target_trade_date)[
query.rank_change_days
]
metric_rows = tuple(
row
for row in self.repository.load_rankings(publication.publication_id)
for row in all_rows
if row.observation.sector_type is query.sector_type
and row.observation.metric_kind is metric_kind
and row.observation.metric_version == definition.metric_version
@@ -276,18 +340,32 @@ class ReadSectorRadar:
or search in row.observation.sector_name.casefold()
)
start = (query.page - 1) * query.page_size
page_rows = searched[start : start + query.page_size]
selected_codes = {row.observation.sector_code for row in page_rows}
changes: dict[str, dict[MetricKind, int | None]] = {}
for row in all_rows:
observation = row.observation
if (
observation.sector_type is query.sector_type
and observation.sector_code in selected_codes
and observation.metric_version
== resolve_metric_version(observation.metric_kind, publication.metric_versions)
):
changes.setdefault(observation.sector_code, {})[observation.metric_kind] = (
row.rank_change(query.rank_change_days)
)
return RankingPage(
status="success",
query=query,
publication=publication,
definition=definition,
rows=searched[start : start + query.page_size],
rows=page_rows,
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 {},
publication, page_rows, query.side
),
rank_change_values=changes,
comparison_trade_date=compared_date,
)
def stock_membership(self, query: StockSectorQuery) -> StockSectorMembership:
@@ -0,0 +1,268 @@
"""Rescore immutable local inputs without refetching or altering source history."""
from __future__ import annotations
import hashlib
import json
from collections.abc import Callable, Sequence
from dataclasses import replace
from datetime import UTC, date, datetime
from decimal import Decimal
from uuid import uuid4
from ..domain.models import PublicationStatus, RadarPublication, RankedMetric
from ..domain.persistence import (
DailyAggregateRecord,
PublicationSourceRecord,
RankingRecord,
SectorRadarRepository,
)
from .build import BuildDateOutcome, BuildSectorRadarCommand, BuildSummary
from .scoring import (
calculate_rankings,
calendar_rank_changes,
default_strategies,
publication_trading_dates,
)
class RecomputeSectorRadar:
"""Publish a new algorithm revision from pinned, already-normalized facts.
No source adapter is accepted: current stock membership cannot accidentally
replace a historical snapshot. Failed rebuilds leave the old last-good intact.
"""
def __init__(
self,
repository: SectorRadarRepository,
now_fn: Callable[[], datetime] = lambda: datetime.now(UTC),
) -> None:
self.repository = repository
self.now_fn = now_fn
self.versions = tuple(strategy.metric_version for strategy in default_strategies())
def execute(self, command: BuildSectorRadarCommand) -> BuildSummary:
"""Rescore successful dates oldest first, stopping if one date fails.
The requested range is inclusive. A single date or no date selects one
existing publication. All aggregate inputs are pinned before writes.
"""
if command.retry_publication_id is not None:
raise ValueError("offline recompute does not accept a source retry")
available = sorted(self.repository.list_successful_dates())
if command.trade_date is not None:
targets = [command.trade_date] if command.trade_date in available else []
elif command.start_date is not None and command.end_date is not None:
targets = [day for day in available if command.start_date <= day <= command.end_date]
else:
targets = available[-1:]
if not targets:
return BuildSummary(
(
BuildDateOutcome(
command.trade_date or command.start_date or self.now_fn().date(),
"failed",
None,
Decimal(0),
0,
0,
"no_local_publication",
"no successful local input publication",
),
)
)
needed = [day for day in available if day < targets[0]][-9:] + targets
pinned: dict[date, RadarPublication] = {}
for day in needed:
publication = self.repository.get_successful_publication(day)
if publication is None:
raise ValueError("selected local publication disappeared")
pinned[day] = publication
inputs = self.repository.load_publication_aggregates(
tuple(item.publication_id for item in pinned.values())
)
by_publication: dict[str, list[DailyAggregateRecord]] = {}
for record in inputs:
by_publication.setdefault(record.publication_id, []).append(record)
existing_rankings = dict(
self.repository.load_publication_rankings(
tuple(item.publication_id for item in pinned.values())
)
)
ranks_by_day = {
day: existing_rankings.get(item.publication_id, ()) for day, item in pinned.items()
}
outcomes: list[BuildDateOutcome] = []
for target in targets:
base = pinned[target]
current = by_publication.get(base.publication_id, [])
prior_dates = [day for day in needed if day < target][-9:]
history = [
record
for day in prior_dates
if pinned[day].source_version == base.source_version
for record in by_publication.get(pinned[day].publication_id, [])
]
previous = {
day: ranks_by_day[day]
for day in prior_dates
if pinned[day].source_version == base.source_version
}
outcome, rankings = self._rescore(base, current, history, previous)
outcomes.append(outcome)
if outcome.status in {"failed", "locked"}:
break
ranks_by_day[target] = rankings
return BuildSummary(tuple(outcomes))
def _rescore(
self,
base: RadarPublication,
current: Sequence[DailyAggregateRecord],
history: Sequence[DailyAggregateRecord],
previous: dict[date, Sequence[RankedMetric]],
) -> tuple[BuildDateOutcome, Sequence[RankedMetric]]:
target = base.target_trade_date
pending: RadarPublication | None = None
try:
with self.repository.advisory_lock(target) as acquired:
if not acquired:
return BuildDateOutcome(target, "locked", None, Decimal(0), 0, 0), ()
if not current:
raise ValueError("local aggregate input is missing")
calendar = publication_trading_dates(self.repository, base.publication_id, target)
rankings = calendar_rank_changes(
calculate_rankings(
target,
[item.aggregate for item in current],
[item.aggregate for item in history],
calendar,
),
previous,
calendar,
target,
)
sources = tuple(self.repository.load_publication_sources(base.publication_id))
digest = self._fingerprint(base, current, history, sources, rankings)
reusable = self.repository.find_reusable_publication(target, digest)
if reusable is not None and reusable.status is PublicationStatus.SUCCESS:
return BuildDateOutcome(
target,
"unchanged",
reusable.publication_id,
reusable.coverage,
len(current),
len(rankings),
), rankings
started = self.now_fn()
self.repository.recover_running_publications(target, finished_at=started)
pending = replace(
base,
publication_id=f"radar-{target:%Y%m%d}-rescore-{uuid4().hex[:20]}",
status=PublicationStatus.RUNNING,
metric_versions=self.versions,
input_hash=None,
started_at=started,
finished_at=None,
error_summary=None,
)
self.repository.create_publication(pending)
self.repository.save_publication_sources(
replace(item, publication_id=pending.publication_id, refresh_on_retry=False)
for item in sources
)
finished = replace(
pending,
status=PublicationStatus.SUCCESS,
input_hash=digest,
finished_at=self.now_fn(),
)
self.repository.finalize_publication(
finished,
memberships=(),
stock_facts=(),
daily_aggregates=(
replace(item, publication_id=pending.publication_id) for item in current
),
rankings=(RankingRecord(pending.publication_id, row) for row in rankings),
)
return BuildDateOutcome(
target,
"success",
pending.publication_id,
base.coverage,
len(current),
len(rankings),
), rankings
except (ValueError, RuntimeError, ArithmeticError) as exc:
if pending is not None:
saved = self.repository.get_publication(pending.publication_id)
if saved is not None and saved.status is PublicationStatus.RUNNING:
self.repository.finish_publication(
replace(
pending,
status=PublicationStatus.FAILED,
finished_at=self.now_fn(),
error_summary="offline_recompute_failed",
)
)
return BuildDateOutcome(
target,
"failed",
pending.publication_id if pending else None,
Decimal(0),
0,
0,
type(exc).__name__,
"local radar rescoring failed",
), ()
def _fingerprint(
self,
base: RadarPublication,
current: Sequence[DailyAggregateRecord],
history: Sequence[DailyAggregateRecord],
sources: Sequence[PublicationSourceRecord],
rankings: Sequence[RankedMetric],
) -> str:
"""Hash source content, not derived publication IDs, so reruns are idempotent."""
payload = {
"source_version": base.source_version,
"universe_version": base.universe_version,
"metric_versions": self.versions,
"snapshots": sorted(item.snapshot.snapshot_id for item in sources),
"inputs": [
(
item.aggregate.trade_date.isoformat(),
item.aggregate.sector_type.value,
item.aggregate.sector_code,
str(item.aggregate.net_amount_yuan),
str(item.aggregate.turnover_yuan),
item.aggregate.member_count,
item.aggregate.valid_sample_count,
str(item.aggregate.membership_coverage),
str(item.aggregate.moneyflow_coverage),
str(item.pct_change),
item.leading_code,
)
for item in sorted(
(*history, *current),
key=lambda item: (
item.aggregate.trade_date,
item.aggregate.sector_type,
item.aggregate.sector_code,
),
)
],
"rank_changes": [
(
row.observation.sector_type.value,
row.observation.sector_code,
row.observation.metric_version,
[(change.days, change.value) for change in row.rank_changes],
)
for row in rankings
],
}
return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
@@ -0,0 +1,98 @@
"""Shared publication scoring and strict trading-session comparisons."""
from __future__ import annotations
from collections import defaultdict
from collections.abc import Mapping, Sequence
from datetime import date
from ..domain.metrics import AmountNetStrategy, MetricStrategy
from ..domain.models import MetricObservation, RankedMetric, SectorDailyAggregate, SectorType
from ..domain.persistence import PublicationSourceGroup, SectorRadarRepository
from ..domain.ranking import rank_metric_observations, with_rank_changes
from ..domain.source import TradeCalendarRow
from ..domain.weighted import (
FlowFeatures,
RatioWeightedStrategy,
SwingWeightedStrategy,
attach_weighted_scores,
calendar_sector_history,
flow_features,
)
def default_strategies() -> tuple[MetricStrategy, ...]:
"""Return versioned strategies shared by online builds and offline rescoring."""
return (AmountNetStrategy(), RatioWeightedStrategy(), SwingWeightedStrategy())
def publication_trading_dates(
repository: SectorRadarRepository,
publication_id: str,
target: date,
) -> tuple[date, ...]:
"""Read the exact publication's calendar without querying an external provider."""
sources = repository.load_publication_rows(publication_id, (PublicationSourceGroup.CALENDAR,))
return tuple(
sorted(
{
parsed.cal_date
for row in sources.get(PublicationSourceGroup.CALENDAR, ())
for parsed in (TradeCalendarRow.from_mapping(row),)
if parsed.is_open and parsed.cal_date <= target
}
)
)
def calculate_rankings(
target: date,
aggregates: Sequence[SectorDailyAggregate],
history: Sequence[SectorDailyAggregate],
trading_dates: Sequence[date],
strategies: Sequence[MetricStrategy] | None = None,
) -> tuple[RankedMetric, ...]:
"""Evaluate raw features, score full pools, then generate authoritative ranks."""
histories: defaultdict[tuple[SectorType, str], dict[date, SectorDailyAggregate]] = defaultdict(
dict
)
for row in history:
if row.trade_date >= target:
continue
days = histories[(row.sector_type, row.sector_code)]
if row.trade_date in days:
raise ValueError("aggregate history must contain unique sector dates")
days[row.trade_date] = row
observations: list[MetricObservation] = []
features: dict[tuple[SectorType, str], FlowFeatures] = {}
selected = default_strategies() if strategies is None else strategies
for current in aggregates:
if current.trade_date != target:
raise ValueError("current aggregates must match the target date")
key = (current.sector_type, current.sector_code)
sector_history = calendar_sector_history(current, histories[key], trading_dates)
features[key] = flow_features(sector_history, target)
observations.extend(strategy.evaluate(sector_history, target) for strategy in selected)
return rank_metric_observations(attach_weighted_scores(observations, features))
def comparison_dates(trading_dates: Sequence[date], target: date) -> dict[int, date | None]:
"""Resolve actual prior trading sessions; missing calendar history stays unknown."""
previous = sorted({day for day in trading_dates if day < target}, reverse=True)
return {days: previous[days - 1] if len(previous) >= days else None for days in range(1, 6)}
def calendar_rank_changes(
current: Sequence[RankedMetric],
previous: Mapping[date, Sequence[RankedMetric]],
trading_dates: Sequence[date],
target: date,
) -> tuple[RankedMetric, ...]:
"""Compare matching versions on exact calendar dates, without skipping failed days."""
return with_rank_changes(
current,
{
days: previous.get(day, ()) if day is not None else ()
for days, day in comparison_dates(trading_dates, target).items()
},
)
@@ -33,22 +33,24 @@ class MetricStrategy(Protocol):
...
def _target_aggregate(
def target_aggregate(
history: Iterable[SectorDailyAggregate], target_trade_date: date
) -> SectorDailyAggregate:
"""Require one target row; missing or duplicate targets are invalid inputs."""
matches = tuple(row for row in history if row.trade_date == target_trade_date)
if len(matches) != 1:
raise ValueError("history must contain exactly one target-date aggregate")
return matches[0]
def _quality(row: SectorDailyAggregate) -> MetricQuality:
def aggregate_quality(row: SectorDailyAggregate) -> MetricQuality:
"""Retain limited-sample status for incomplete membership or flow coverage."""
if row.valid_sample_count < 5 or row.membership_coverage < 1 or row.moneyflow_coverage < 1:
return MetricQuality.AVAILABLE_LIMITED_SAMPLE
return MetricQuality.AVAILABLE
def _observation(
def make_metric_observation(
row: SectorDailyAggregate,
*,
metric_kind: MetricKind,
@@ -57,6 +59,7 @@ def _observation(
value: Decimal | None,
quality: MetricQuality | None = None,
) -> MetricObservation:
"""Carry source coverage into a metric and mark unknown raw values unavailable."""
return MetricObservation(
trade_date=row.trade_date,
sector_type=row.sector_type,
@@ -72,7 +75,7 @@ def _observation(
if value is None
else quality
if quality is not None
else _quality(row)
else aggregate_quality(row)
),
member_count=row.member_count,
valid_sample_count=row.valid_sample_count,
@@ -95,9 +98,9 @@ class AmountNetStrategy:
) -> MetricObservation:
"""Return the target net amount; missing moneyflow remains unavailable."""
row = _target_aggregate(history, target_trade_date)
row = target_aggregate(history, target_trade_date)
value = None if row.net_amount_yuan is None else row.net_amount_yuan / Decimal("100000000")
return _observation(
return make_metric_observation(
row,
metric_kind=self.metric_kind,
metric_version=self.metric_version,
@@ -120,7 +123,7 @@ class RatioTurnoverStrategy:
) -> MetricObservation:
"""Return a ratio only when numerator and positive denominator exist."""
row = _target_aggregate(history, target_trade_date)
row = target_aggregate(history, target_trade_date)
value = None
if (
row.net_amount_yuan is not None
@@ -128,7 +131,7 @@ class RatioTurnoverStrategy:
and row.turnover_yuan > 0
):
value = row.net_amount_yuan / row.turnover_yuan
return _observation(
return make_metric_observation(
row,
metric_kind=self.metric_kind,
metric_version=self.metric_version,
@@ -156,7 +159,7 @@ class SwingEqualThreeToTenStrategy:
"""Calculate eight complete trading-day windows ending at the target."""
rows = tuple(sorted(history, key=lambda row: row.trade_date))
target = _target_aggregate(rows, target_trade_date)
target = target_aggregate(rows, target_trade_date)
eligible = tuple(row for row in rows if row.trade_date <= target_trade_date)
if any(
(row.sector_type, row.sector_code) != (target.sector_type, target.sector_code)
@@ -190,11 +193,11 @@ class SwingEqualThreeToTenStrategy:
value = sum(window_ratios, start=Decimal(0)) / Decimal(8)
quality = (
MetricQuality.AVAILABLE_LIMITED_SAMPLE
if any(_quality(row) is not MetricQuality.AVAILABLE for row in latest)
if any(aggregate_quality(row) is not MetricQuality.AVAILABLE for row in latest)
else MetricQuality.AVAILABLE
)
return _observation(
return make_metric_observation(
target,
metric_kind=self.metric_kind,
metric_version=self.metric_version,
@@ -260,11 +260,15 @@ class MetricObservation:
valid_sample_count: int
membership_coverage: Decimal
moneyflow_coverage: Decimal
weighted_score: Decimal | None = None
def __post_init__(self) -> None:
"""Keep unavailable and finite-value states internally consistent."""
_validate_finite_decimal(self.value, "value")
_validate_finite_decimal(self.weighted_score, "weighted_score")
if self.weighted_score is not None and self.value is None:
raise ValueError("a weighted score requires an observed raw value")
if self.value is None and self.quality is not MetricQuality.UNAVAILABLE:
raise ValueError("a missing metric value must be unavailable")
if self.value is not None and self.quality is MetricQuality.UNAVAILABLE:
@@ -293,6 +293,12 @@ class SectorRadarRepository(Protocol):
def save_daily_aggregates(self, records: Iterable[DailyAggregateRecord]) -> WriteCounts: ...
def load_publication_aggregates(
self, publication_ids: Sequence[str]
) -> Sequence[DailyAggregateRecord]:
"""Read exact revisions, retaining source details for offline rescoring."""
...
def create_publication(self, publication: RadarPublication) -> WriteCounts: ...
def finish_publication(self, publication: RadarPublication) -> None: ...
@@ -16,6 +16,7 @@ from .models import (
RankSide,
SectorType,
)
from .weighted import ranking_value
PoolKey = tuple[date, SectorType, MetricKind, str]
SectorMetricKey = tuple[SectorType, str, MetricKind, str]
@@ -40,9 +41,10 @@ def _sector_metric_key(observation: MetricObservation) -> SectorMetricKey:
def _available_sort_key(observation: MetricObservation) -> tuple[Decimal, str]:
if observation.value is None:
value = ranking_value(observation)
if value is None:
raise ValueError("unavailable observations cannot use the ranking sort key")
return (-observation.value, observation.sector_code)
return (-value, observation.sector_code)
def rank_metric_observations(
@@ -70,7 +72,7 @@ def rank_metric_observations(
raise ValueError("a ranking pool must not contain duplicate sector codes")
available = sorted(
(observation for observation in pool if observation.value is not None),
(observation for observation in pool if ranking_value(observation) is not None),
key=_available_sort_key,
)
pool_size = len(available)
@@ -93,7 +95,7 @@ def rank_metric_observations(
rank_percentile=None,
)
for observation in sorted(
(observation for observation in pool if observation.value is None),
(observation for observation in pool if ranking_value(observation) is None),
key=lambda observation: observation.sector_code,
)
)
@@ -134,7 +136,7 @@ def select_percentile_side(
sorted(
pools[pool_key],
key=lambda row: (
row.observation.value if row.observation.value is not None else Decimal(0),
ranking_value(row.observation) or Decimal(0),
row.observation.sector_code,
),
)
@@ -0,0 +1,246 @@
"""Versioned flow scores: separate observable ratios from cross-sectional scores."""
from __future__ import annotations
from collections import defaultdict
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass, replace
from datetime import date
from decimal import Decimal
from .metrics import (
AmountNetStrategy,
RatioTurnoverStrategy,
SwingEqualThreeToTenStrategy,
aggregate_quality,
make_metric_observation,
target_aggregate,
)
from .models import (
MetricKind,
MetricObservation,
MetricQuality,
MetricUnit,
SectorDailyAggregate,
SectorType,
)
RATIO_WEIGHTED_VERSION = "zhixing_ratio_weighted_v2"
SWING_WEIGHTED_VERSION = "zhixing_swing_weighted_v2"
WEIGHTED_VERSIONS = frozenset((RATIO_WEIGHTED_VERSION, SWING_WEIGHTED_VERSION))
SectorKey = tuple[SectorType, str]
def resolve_metric_version(kind: MetricKind, versions: Iterable[str]) -> str | None:
"""Select a supported version actually present in an immutable publication."""
available = set(versions)
candidates = {
MetricKind.AMOUNT: (AmountNetStrategy.metric_version,),
MetricKind.RATIO: (RATIO_WEIGHTED_VERSION, RatioTurnoverStrategy.metric_version),
MetricKind.SWING: (SWING_WEIGHTED_VERSION, SwingEqualThreeToTenStrategy.metric_version),
}
return next((version for version in candidates[kind] if version in available), None)
def ranking_value(observation: MetricObservation) -> Decimal | None:
"""Never substitute a raw ratio for a missing score during v2 warm-up."""
return (
observation.weighted_score
if observation.metric_version in WEIGHTED_VERSIONS
else observation.value
)
def daily_flow_ratio(row: SectorDailyAggregate) -> Decimal | None:
"""Use yuan consistently, with the reviewed 100-yuan denominator offset."""
if row.net_amount_yuan is None or row.turnover_yuan is None or row.turnover_yuan <= 0:
return None
return row.net_amount_yuan / (row.turnover_yuan + Decimal(100))
@dataclass(frozen=True, slots=True)
class FlowFeatures:
"""Past-only inputs before any ranking or page filtering is applied."""
daily_ratio: Decimal | None
short_ratio: Decimal | None
long_ratio: Decimal | None
liquidity_weight: Decimal | None
quality: MetricQuality
def flow_features(history: Iterable[SectorDailyAggregate], target: date) -> FlowFeatures:
"""Extract complete 3/5/10-session windows; unknown inputs stay unknown.
The caller supplies calendar-aligned history, including explicit missing
aggregates for calendar holes. Future observations never enter a window.
"""
rows = tuple(
sorted((row for row in history if row.trade_date <= target), key=lambda row: row.trade_date)
)
current = target_aggregate(rows, target)
if len({row.trade_date for row in rows}) != len(rows):
raise ValueError("history must not contain duplicate trade dates")
if any(
(row.sector_type, row.sector_code) != (current.sector_type, current.sector_code)
for row in rows
):
raise ValueError("history must contain exactly one sector identity")
def mean_ratio(window: int) -> Decimal | None:
ratios = tuple(daily_flow_ratio(row) for row in rows[-window:])
if len(ratios) < window or any(value is None for value in ratios):
return None
return sum((value for value in ratios if value is not None), Decimal(0)) / window
turnovers = tuple(row.turnover_yuan for row in rows[-5:])
weight = None
if len(turnovers) == 5 and all(value is not None and value > 0 for value in turnovers):
average = sum((value for value in turnovers if value is not None), Decimal(0)) / 5
weight = (average + 1).log10() / 10
quality = (
MetricQuality.AVAILABLE_LIMITED_SAMPLE
if any(aggregate_quality(row) is not MetricQuality.AVAILABLE for row in rows[-10:])
else MetricQuality.AVAILABLE
)
return FlowFeatures(daily_flow_ratio(current), mean_ratio(3), mean_ratio(10), weight, quality)
class RatioWeightedStrategy:
"""Keep the raw daily ratio; attach its score only after observing the full pool."""
metric_kind = MetricKind.RATIO
metric_version = RATIO_WEIGHTED_VERSION
unit = MetricUnit.RATIO
def evaluate(
self, history: Iterable[SectorDailyAggregate], target_trade_date: date
) -> MetricObservation:
"""Return a daily observation without pretending that a score is a ratio."""
rows = tuple(history)
current = target_aggregate(rows, target_trade_date)
features = flow_features(rows, target_trade_date)
return make_metric_observation(
current,
metric_kind=self.metric_kind,
metric_version=self.metric_version,
unit=self.unit,
value=features.daily_ratio,
quality=features.quality,
)
class SwingWeightedStrategy:
"""Combine 3- and 10-session simple means, equally weighted."""
metric_kind = MetricKind.SWING
metric_version = SWING_WEIGHTED_VERSION
unit = MetricUnit.RATIO
def evaluate(
self, history: Iterable[SectorDailyAggregate], target_trade_date: date
) -> MetricObservation:
"""Expose the mean ratio independently of the two percentile ranks."""
rows = tuple(history)
current = target_aggregate(rows, target_trade_date)
features = flow_features(rows, target_trade_date)
value = (
(features.short_ratio + features.long_ratio) / 2
if features.short_ratio is not None and features.long_ratio is not None
else None
)
return make_metric_observation(
current,
metric_kind=self.metric_kind,
metric_version=self.metric_version,
unit=self.unit,
value=value,
quality=features.quality,
)
def calendar_sector_history(
current: SectorDailyAggregate,
previous: Mapping[date, SectorDailyAggregate],
trading_dates: Sequence[date],
) -> tuple[SectorDailyAggregate, ...]:
"""Represent missing trading sessions explicitly rather than skipping them."""
dates = sorted({day for day in trading_dates if day <= current.trade_date})[-10:]
if not dates or dates[-1] != current.trade_date:
raise ValueError("the target must belong to the observed trading calendar")
return tuple(
current
if day == current.trade_date
else previous.get(day)
or replace(
current,
trade_date=day,
member_count=0,
valid_sample_count=0,
net_amount_yuan=None,
turnover_yuan=None,
membership_coverage=Decimal(0),
moneyflow_coverage=Decimal(0),
)
for day in dates
)
def _percentiles(values: Mapping[str, Decimal | None]) -> dict[str, Decimal]:
"""Compute ascending average-rank percentiles, preserving ties deterministically."""
ordered = sorted((value, code) for code, value in values.items() if value is not None)
result: dict[str, Decimal] = {}
start = 0
while start < len(ordered):
end = start + 1
while end < len(ordered) and ordered[end][0] == ordered[start][0]:
end += 1
percentile = Decimal(start + 1 + end) / (2 * len(ordered))
result.update((code, percentile) for _, code in ordered[start:end])
start = end
return result
def attach_weighted_scores(
observations: Sequence[MetricObservation],
features: Mapping[SectorKey, FlowFeatures],
) -> tuple[MetricObservation, ...]:
"""Score complete date/type/version pools, never a paginated subset.
Missing history can leave a raw value available without a final rank.
The two swing percentiles are calculated separately before combining.
"""
pools: defaultdict[tuple[date, SectorType, str], list[MetricObservation]] = defaultdict(list)
for row in observations:
if row.metric_version in WEIGHTED_VERSIONS:
pools[(row.trade_date, row.sector_type, row.metric_version)].append(row)
scores: dict[tuple[date, SectorType, str, str], Decimal | None] = {}
for pool in pools.values():
feature = {row.sector_code: features[(row.sector_type, row.sector_code)] for row in pool}
daily = _percentiles({code: item.daily_ratio for code, item in feature.items()})
short = _percentiles({code: item.short_ratio for code, item in feature.items()})
long = _percentiles({code: item.long_ratio for code, item in feature.items()})
for row in pool:
code = row.sector_code
weight = feature[code].liquidity_weight
percentile = (
daily.get(code)
if row.metric_kind is MetricKind.RATIO
else ((short[code] + long[code]) / 2 if code in short and code in long else None)
)
scores[(row.trade_date, row.sector_type, code, row.metric_version)] = (
Decimal(1000) * percentile * weight
if percentile is not None and weight is not None and row.value is not None
else None
)
return tuple(
replace(
row,
weighted_score=scores[
(row.trade_date, row.sector_type, row.sector_code, row.metric_version)
],
)
if row.metric_version in WEIGHTED_VERSIONS
else row
for row in observations
)
@@ -254,6 +254,26 @@ class InMemorySectorRadarRepository:
),
)
def load_publication_aggregates(
self, publication_ids: Sequence[str]
) -> Sequence[DailyAggregateRecord]:
"""Load exact immutable aggregate revisions, including detail fields."""
wanted = set(publication_ids)
return tuple(
sorted(
(
record
for record in self.daily_aggregates.values()
if record.publication_id in wanted
),
key=lambda record: (
record.aggregate.trade_date,
record.aggregate.sector_type,
record.aggregate.sector_code,
),
)
)
def create_publication(self, publication: RadarPublication) -> WriteCounts:
"""Create one running publication without replacing an existing identity."""
@@ -493,6 +493,34 @@ class PostgresSectorRadarRepository:
rows,
)
def load_publication_aggregates(
self, publication_ids: Sequence[str]
) -> Sequence[DailyAggregateRecord]:
"""Batch-read exact input revisions without dropping detail provenance."""
if not publication_ids:
return ()
with self._connection() as connection:
rows = connection.execute(
"""
SELECT publication_id, trade_date, sector_type, sector_code, sector_name,
member_count, valid_sample_count, net_amount_yuan, turnover_yuan,
membership_coverage, moneyflow_coverage, pct_change, leading_code
FROM sector_radar_daily_aggregate
WHERE publication_id = ANY(%s)
ORDER BY trade_date, sector_type, sector_code
""",
(list(publication_ids),),
).fetchall()
return tuple(
DailyAggregateRecord(
str(row[0]),
self._aggregate_from_row(row[1:11]),
None if row[11] is None else Decimal(str(row[11])),
None if row[12] is None else str(row[12]),
)
for row in rows
)
def create_publication(self, publication: RadarPublication) -> WriteCounts:
"""Insert a new running publication identity idempotently."""
@@ -694,6 +722,7 @@ class PostgresSectorRadarRepository:
"rank_position",
"rank_percentile",
"rank_changes",
"weighted_score",
),
("publication_id", "sector_type", "sector_code", "metric_version"),
self._ranking_rows(ranking_items),
@@ -772,6 +801,7 @@ class PostgresSectorRadarRepository:
"rank_position",
"rank_percentile",
"rank_changes",
"weighted_score",
),
("publication_id", "sector_type", "sector_code", "metric_version"),
self._ranking_rows(items),
@@ -877,7 +907,8 @@ class PostgresSectorRadarRepository:
SELECT 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
moneyflow_coverage, rank_position, rank_percentile, rank_changes,
weighted_score
FROM sector_radar_ranking
WHERE publication_id = %s
ORDER BY sector_type, metric_version, rank_position NULLS LAST, sector_code
@@ -898,7 +929,8 @@ class PostgresSectorRadarRepository:
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
moneyflow_coverage, rank_position, rank_percentile, rank_changes,
weighted_score
FROM sector_radar_ranking
WHERE publication_id = ANY(%s)
ORDER BY publication_id, sector_type, metric_kind, rank_position NULLS LAST,
@@ -938,7 +970,8 @@ class PostgresSectorRadarRepository:
ranking.implementation_kind, ranking.unit, ranking.metric_value,
ranking.quality, ranking.member_count, ranking.valid_sample_count,
ranking.membership_coverage, ranking.moneyflow_coverage,
ranking.rank_position, ranking.rank_percentile, ranking.rank_changes
ranking.rank_position, ranking.rank_percentile, ranking.rank_changes,
ranking.weighted_score
FROM pools AS pool
LEFT JOIN sector_radar_ranking AS ranking
ON ranking.publication_id = pool.publication_id
@@ -1016,7 +1049,7 @@ class PostgresSectorRadarRepository:
ranking.metric_value, ranking.quality, ranking.member_count,
ranking.valid_sample_count, ranking.membership_coverage,
ranking.moneyflow_coverage, ranking.rank_position,
ranking.rank_percentile, ranking.rank_changes
ranking.rank_percentile, ranking.rank_changes, ranking.weighted_score
FROM sector_radar_ranking AS ranking
JOIN selected ON selected.id = ranking.publication_id
ORDER BY selected.target_trade_date DESC, ranking.sector_type,
@@ -1168,6 +1201,7 @@ class PostgresSectorRadarRepository:
ranking.rank_position,
ranking.rank_percentile,
Jsonb({str(change.days): change.value for change in ranking.rank_changes}),
observation.weighted_score,
)
)
return tuple(rows)
@@ -1258,6 +1292,7 @@ class PostgresSectorRadarRepository:
valid_sample_count=int(row[11]),
membership_coverage=Decimal(str(row[12])),
moneyflow_coverage=Decimal(str(row[13])),
weighted_score=None if row[17] is None else Decimal(str(row[17])),
)
return RankedMetric(
observation=observation,
@@ -10,6 +10,7 @@ from datetime import date
from ....bootstrap.config import get_settings
from ..application.build import BuildSectorRadar, BuildSectorRadarCommand
from ..application.recompute import RecomputeSectorRadar
from ..infrastructure.postgres import PostgresSectorRadarRepository
from ..infrastructure.tushare import TushareSectorRadarAdapter
@@ -30,6 +31,11 @@ def build_parser() -> argparse.ArgumentParser:
)
mode.add_argument("--start-date", type=_parse_date, help="inclusive backfill start date")
parser.add_argument("--end-date", type=_parse_date, help="inclusive backfill end date")
parser.add_argument(
"--recompute",
action="store_true",
help="rescore saved aggregates without fetching external sources",
)
return parser
@@ -39,6 +45,8 @@ def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if (args.start_date is None) != (args.end_date is None):
raise SystemExit("--start-date and --end-date must be provided together")
if args.recompute and args.retry_publication_id:
raise SystemExit("--recompute cannot be combined with --retry-publication-id")
command = BuildSectorRadarCommand(
trade_date=args.trade_date,
start_date=args.start_date,
@@ -59,22 +67,25 @@ def main(argv: Sequence[str] | None = None) -> int:
command.end_date or "none",
bool(command.retry_publication_id),
)
source = TushareSectorRadarAdapter.from_token(
settings.tushare_token,
max_retries=settings.sector_radar_max_retries,
backoff_seconds=settings.sector_radar_retry_backoff_seconds,
request_interval_seconds=settings.sector_radar_request_interval_seconds,
)
repository = PostgresSectorRadarRepository(
settings.database_url,
advisory_lock_key=settings.sector_radar_advisory_lock_key,
)
try:
summary = BuildSectorRadar(
source,
repository,
coverage_threshold=settings.sector_radar_coverage_threshold,
).execute(command)
if args.recompute:
summary = RecomputeSectorRadar(repository).execute(command)
else:
source = TushareSectorRadarAdapter.from_token(
settings.tushare_token,
max_retries=settings.sector_radar_max_retries,
backoff_seconds=settings.sector_radar_retry_backoff_seconds,
request_interval_seconds=settings.sector_radar_request_interval_seconds,
)
summary = BuildSectorRadar(
source,
repository,
coverage_threshold=settings.sector_radar_coverage_threshold,
).execute(command)
finally:
repository.close()
except Exception as exc: # noqa: BLE001 - CLI boundary returns a redacted scheduler result
@@ -98,6 +98,7 @@ class RadarRankingRowResponse(BaseModel):
implementation_kind: Literal["independent"]
unit: MetricUnit
metric_value: Decimal | None
weighted_score: Decimal | None = None
quality: MetricQuality
member_count: int = Field(ge=0)
valid_sample_count: int = Field(ge=0)
@@ -107,6 +108,9 @@ 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
rank_change_values: dict[MetricKind, int | None] = Field(
default_factory=lambda: dict[MetricKind, int | None]()
)
pct_change: Decimal | None = None
daily_net_amount_yuan: Decimal | None = None
daily_ratio: Decimal | None = None
@@ -127,6 +131,7 @@ class RadarHistoryMetricResponse(RadarDetailModel):
rank_percentile: Decimal | None
pool_size: int
metric_value: Decimal | None
weighted_score: Decimal | None = None
missing: bool
in_top: bool
in_bottom: bool
@@ -222,6 +227,7 @@ class RadarRankingsResponse(BaseModel):
view: RadarView
rank_change_metric: MetricKind
rank_change_days: int = Field(ge=1, le=5)
comparison_trade_date: date | None = None
side: RankSide
search: str | None
publication: RadarPublicationResponse | None
@@ -303,7 +309,7 @@ def get_sector_radar_rankings(
trade_date: date | None = None,
sector_type: SectorType = SectorType.CONCEPT,
view: RadarView = RadarView.AMOUNT,
rank_change_metric: MetricKind = MetricKind.AMOUNT,
rank_change_metric: MetricKind = MetricKind.SWING,
rank_change_days: Annotated[int, Query(ge=1, le=5)] = 1,
side: RankSide = RankSide.ALL,
search: Annotated[str | None, Query(max_length=100)] = None,
@@ -458,6 +464,7 @@ def _rankings_response(page: RankingPage) -> RadarRankingsResponse:
view=query.view,
rank_change_metric=query.rank_change_metric,
rank_change_days=query.rank_change_days,
comparison_trade_date=page.comparison_trade_date,
side=query.side,
search=query.search,
publication=(
@@ -469,7 +476,10 @@ def _rankings_response(page: RankingPage) -> RadarRankingsResponse:
total=page.total,
rows=[
_ranking_response(
row, query.rank_change_days, page.extras.get(row.observation.sector_code)
row,
query.rank_change_days,
page.extras.get(row.observation.sector_code),
page.rank_change_values.get(row.observation.sector_code),
)
for row in page.rows
],
@@ -525,7 +535,10 @@ def _definition_response(
def _ranking_response(
row: RankedMetric, rank_change_days: int, extras: RankingExtras | None = None
row: RankedMetric,
rank_change_days: int,
extras: RankingExtras | None = None,
changes: dict[MetricKind, int | None] | None = None,
) -> RadarRankingRowResponse:
observation = row.observation
extras = extras or RankingExtras()
@@ -539,6 +552,7 @@ def _ranking_response(
implementation_kind=observation.implementation_kind,
unit=observation.unit,
metric_value=observation.value,
weighted_score=observation.weighted_score,
quality=observation.quality,
member_count=observation.member_count,
valid_sample_count=observation.valid_sample_count,
@@ -548,6 +562,7 @@ def _ranking_response(
rank_percentile=row.rank_percentile,
rank_change_days=rank_change_days,
rank_change=row.rank_change(rank_change_days),
rank_change_values={kind: (changes or {}).get(kind) for kind in MetricKind},
pct_change=extras.pct_change,
daily_net_amount_yuan=extras.daily_net_amount_yuan,
daily_ratio=extras.daily_ratio,
@@ -225,6 +225,35 @@ def test_no_data_is_a_stable_200_response() -> None:
assert rankings.json()["rows"] == []
def test_weighted_score_and_all_rank_changes_cross_the_http_boundary() -> None:
reader = FakeReader()
ranking = _ranking()
reader.page = replace(
reader.page,
rows=(
replace(
ranking,
observation=replace(
ranking.observation, weighted_score=Decimal("712.345678901234")
),
),
),
comparison_trade_date=date(2026, 8, 21),
rank_change_values={
"BK0001.DC": {MetricKind.AMOUNT: 3, MetricKind.RATIO: 0, MetricKind.SWING: None}
},
)
response = _client(reader).get("/api/v1/sector-radar/rankings", params={"view": "rank_change"})
assert response.status_code == 200
assert (
reader.last_query is not None and reader.last_query.rank_change_metric is MetricKind.SWING
)
payload = response.json()
assert payload["comparison_trade_date"] == "2026-08-21"
assert payload["rows"][0]["weighted_score"] == "712.345678901234"
assert payload["rows"][0]["rank_change_values"] == {"amount": 3, "ratio": 0, "swing": None}
def test_http_contract_rejects_zero_rank_percentile() -> None:
payload = _client(FakeReader()).get("/api/v1/sector-radar/rankings").json()["rows"][0]
payload["rank_percentile"] = "0"
@@ -599,10 +599,14 @@ def test_tenth_trading_day_publishes_swing_and_five_rank_changes() -> None:
swing = tuple(
ranking
for ranking in current
if ranking.observation.metric_version == "zhixing_swing_equal_3_10_v1"
if ranking.observation.metric_version == "zhixing_swing_weighted_v2"
)
assert len(swing) == 2
assert all(ranking.observation.value == Decimal("0.03") for ranking in swing)
assert all(
ranking.observation.value == pytest.approx(Decimal(150000) / Decimal(5000100))
for ranking in swing
)
assert all(ranking.observation.weighted_score is not None for ranking in swing)
assert all(
tuple(change.value for change in ranking.rank_changes) == (None, None, None, None, None)
for ranking in swing
@@ -786,7 +790,7 @@ def test_detail_history_and_ranking_extras_http_use_the_same_publication() -> No
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 Decimal(row["daily_ratio"]) == Decimal(150000) / Decimal(5000100)
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"})
@@ -805,6 +809,13 @@ def test_postgres_detail_migration_and_build_roundtrip(monkeypatch: pytest.Monke
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.application.recompute import RecomputeSectorRadar
from zhixing_server.modules.sector_radar.domain.metrics import (
AmountNetStrategy,
RatioTurnoverStrategy,
SwingEqualThreeToTenStrategy,
)
from zhixing_server.modules.sector_radar.domain.models import MetricKind
from zhixing_server.modules.sector_radar.infrastructure.postgres import (
PostgresSectorRadarRepository,
)
@@ -843,7 +854,7 @@ def test_postgres_detail_migration_and_build_roundtrip(monkeypatch: pytest.Monke
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",
"0010_radar_weighted_score",
)
row = connection.execute(
"SELECT pct_change, leading_code FROM sector_radar_daily_aggregate "
@@ -863,5 +874,140 @@ def test_postgres_detail_migration_and_build_roundtrip(monkeypatch: pytest.Monke
"SET active_buy_net_amount_yuan = 'NaN'::numeric WHERE trade_date = %s",
(target,),
)
# Exercise every ranking projection against persisted v2 scores, while
# keeping the original source publication and its NULL score readable.
start, end = target + timedelta(days=31), target + timedelta(days=46)
interval = BuildSectorRadarCommand(start_date=start, end_date=end)
source = FakeRadarSource()
legacy = BuildSectorRadar(
source,
repository,
now_fn=lambda: NOW,
strategies=(
AmountNetStrategy(),
RatioTurnoverStrategy(),
SwingEqualThreeToTenStrategy(),
),
).execute(interval)
assert legacy.status in ("success", "unchanged")
old_id = legacy.outcomes[-1].publication_id
assert old_id is not None
old_rows = tuple(repository.load_rankings(old_id))
assert all(row.observation.weighted_score is None for row in old_rows)
source.calls.clear()
rescore = RecomputeSectorRadar(repository, now_fn=lambda: NOW + timedelta(hours=1))
updated = rescore.execute(interval)
assert updated.status in ("success", "unchanged")
updated_id = updated.outcomes[-1].publication_id
assert updated_id is not None and updated_id != old_id
assert source.calls == []
assert tuple(repository.load_rankings(old_id)) == old_rows
current_rows = tuple(repository.load_rankings(updated_id))
scored = next(
row
for row in current_rows
if row.observation.metric_kind is MetricKind.SWING
and row.observation.sector_type is SectorType.CONCEPT
)
expected = ((Decimal(5000000) + 1).log10() * 100).quantize(Decimal("0.000000000001"))
assert scored.observation.weighted_score == expected
assert scored.rank_change(5) == 0
assert dict(repository.load_publication_rankings((updated_id,)))[updated_id] == current_rows
previous = dict(repository.load_previous_rankings(end + timedelta(days=1), limit_dates=1))
assert previous[end] == current_rows
historical = repository.load_ranked_history((updated_id,), ("BK0001.DC",))
assert any(row.ranking == scored for row in historical)
detail = ReadRadarDetails(repository).detail(end, SectorType.CONCEPT, "BK0001.DC")
assert detail.summary["swing"].weighted_score == expected
assert detail.pct_change == 1 and len(detail.members) == 5
assert rescore.execute(interval).status == "unchanged"
with (
psycopg.connect(database_url) as connection,
pytest.raises(psycopg.errors.CheckViolation),
connection.transaction(),
):
connection.execute(
"UPDATE sector_radar_ranking SET weighted_score = 'NaN'::numeric "
"WHERE publication_id = %s",
(updated_id,),
)
finally:
repository.close()
def test_offline_rescore_is_idempotent_preserves_old_publications_and_details() -> None:
from zhixing_server.modules.sector_radar.application.details import ReadRadarDetails
from zhixing_server.modules.sector_radar.application.read import (
RadarQuery,
RadarView,
ReadSectorRadar,
)
from zhixing_server.modules.sector_radar.application.recompute import RecomputeSectorRadar
from zhixing_server.modules.sector_radar.domain.metrics import (
AmountNetStrategy,
RatioTurnoverStrategy,
SwingEqualThreeToTenStrategy,
)
source = FakeRadarSource()
repository = InMemorySectorRadarRepository()
end = TARGET_DATE + timedelta(days=15)
command = BuildSectorRadarCommand(start_date=TARGET_DATE, end_date=end)
initial = BuildSectorRadar(
source,
repository,
now_fn=lambda: NOW,
strategies=(AmountNetStrategy(), RatioTurnoverStrategy(), SwingEqualThreeToTenStrategy()),
).execute(command)
assert initial.status == "success"
old_publications = repository.publications.copy()
old_rankings = repository.rankings.copy()
snapshot_count = len(repository.source_snapshots)
source.calls.clear()
source.fail_daily = True
service = RecomputeSectorRadar(repository, now_fn=lambda: NOW + timedelta(hours=1))
result = service.execute(command)
assert result.status == "success"
assert all(repository.publications[key] == value for key, value in old_publications.items())
assert all(repository.rankings[key] == value for key, value in old_rankings.items())
assert len(repository.source_snapshots) == snapshot_count
assert source.calls == []
latest = repository.get_last_good_publication(end)
assert latest is not None
assert "zhixing_swing_weighted_v2" in latest.metric_versions
count = len(repository.publications)
assert service.execute(command).status == "unchanged"
assert len(repository.publications) == count
reader = ReadSectorRadar(repository)
page = reader.query(RadarQuery(trade_date=end, view=RadarView.SWING))
assert len(page.rows) == 1
assert page.rows[0].observation.weighted_score is not None
assert page.rows[0].rank_change(5) == 0
detail = ReadRadarDetails(repository).detail(end, SectorType.CONCEPT, "BK0001.DC")
assert len(detail.members) == 5
assert detail.pct_change == 1
assert detail.summary["swing"].weighted_score == page.rows[0].observation.weighted_score
assert detail.summary["swing"].rank_position == page.rows[0].rank_position
def test_offline_rescore_failure_does_not_replace_last_good(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from unittest.mock import Mock
from zhixing_server.modules.sector_radar.application.recompute import RecomputeSectorRadar
repository = InMemorySectorRadarRepository()
command = BuildSectorRadarCommand(trade_date=TARGET_DATE)
BuildSectorRadar(FakeRadarSource(), repository, now_fn=lambda: NOW).execute(command)
before = repository.get_last_good_publication()
monkeypatch.setattr(
repository, "finalize_publication", Mock(side_effect=RuntimeError("injected failure"))
)
result = RecomputeSectorRadar(repository, now_fn=lambda: NOW + timedelta(hours=1)).execute(
command
)
assert result.status == "failed"
assert repository.get_last_good_publication() == before
latest = repository.get_latest_publication()
assert latest is not None and latest.status is PublicationStatus.FAILED
@@ -61,6 +61,7 @@ class FakeConnection:
1,
Decimal(100),
{"1": 3, "2": None},
None,
),
)
)
@@ -161,6 +162,7 @@ def test_load_rankings_reconstructs_values_and_rank_changes() -> None:
ranking = rankings[0]
assert ranking.observation.metric_version == "zhixing_amount_net_bn_v1"
assert ranking.observation.value == Decimal("12.5")
assert ranking.observation.weighted_score is None
assert ranking.rank_position == 1
assert ranking.rank_change(1) == 3
assert ranking.rank_change(2) is None
@@ -158,7 +158,45 @@ def test_percentile_side_is_selected_before_search_and_pagination() -> None:
def test_rank_change_uses_selected_metric_days_and_pool_sides() -> None:
reader = ReadSectorRadar(_published_repository())
from zhixing_server.modules.sector_radar.domain.persistence import (
PublicationSourceGroup,
PublicationSourceRecord,
)
from zhixing_server.modules.sector_radar.domain.source import build_source_snapshot
repository = _published_repository()
dates = [date(2026, 8, day) for day in [21, 24, 25, 26, 27, 28]]
calendar = build_source_snapshot(
api_name="trade_cal",
params={},
observed_at=NOW,
target_trade_date=TARGET_DATE,
rows=tuple({"exchange": "SSE", "cal_date": day.isoformat(), "is_open": 1} for day in dates),
)
repository.save_source_snapshots((calendar,))
repository.save_publication_sources(
(
PublicationSourceRecord(
"publication-success", PublicationSourceGroup.CALENDAR, 0, calendar
),
)
)
old = _running("past-publication", dates[0])
repository.create_publication(old)
past = tuple(
replace(
row.observation,
trade_date=dates[0],
value=Decimal(index),
sector_code="BK9999.DC" if index == 5 else row.observation.sector_code,
)
for index, row in enumerate(_amount_rankings(), 1)
)
repository.save_rankings(
RankingRecord(old.publication_id, row) for row in rank_metric_observations(past)
)
repository.finish_publication(_finish(old, PublicationStatus.SUCCESS))
reader = ReadSectorRadar(repository)
query = RadarQuery(
view=RadarView.RANK_CHANGE,
rank_change_metric=MetricKind.AMOUNT,
@@ -170,12 +208,14 @@ def test_rank_change_uses_selected_metric_days_and_pool_sides() -> None:
all_rows = reader.query(query)
assert top.total == 1
assert top.rows[0].rank_change(5) == 5
assert top.rows[0].rank_change(5) == 9
assert bottom.total == 1
assert bottom.rows[0].rank_change(5) == -4
assert bottom.rows[0].rank_change(5) == -9
assert all_rows.total == 10
assert all_rows.rows[-1].observation.sector_code == "BK0005.DC"
assert all_rows.rows[-1].rank_change(5) is None
assert top.comparison_trade_date == dates[0]
assert top.rank_change_values["BK0001.DC"][MetricKind.AMOUNT] == 9
def test_latest_partial_attempt_is_visible_but_does_not_replace_last_good() -> None:
@@ -0,0 +1,160 @@
from dataclasses import replace
from datetime import date, timedelta
from decimal import Decimal
import pytest
from zhixing_server.modules.sector_radar.application.scoring import (
calculate_rankings,
calendar_rank_changes,
comparison_dates,
)
from zhixing_server.modules.sector_radar.domain.models import (
MetricKind,
RankedMetric,
RankSide,
SectorDailyAggregate,
SectorType,
)
from zhixing_server.modules.sector_radar.domain.ranking import select_percentile_side
from zhixing_server.modules.sector_radar.domain.weighted import (
RATIO_WEIGHTED_VERSION,
SWING_WEIGHTED_VERSION,
resolve_metric_version,
)
DAYS = tuple(
date(2026, 9, 1) + timedelta(days=n)
for n in range(21)
if (date(2026, 9, 1) + timedelta(days=n)).weekday() < 5
)
def aggregate(
code: str,
day: date,
ratio: str,
turnover: str = "9999999999",
kind: SectorType = SectorType.CONCEPT,
) -> SectorDailyAggregate:
amount = Decimal(turnover)
return SectorDailyAggregate(
day,
kind,
code,
code,
10,
10,
Decimal(ratio) * (amount + 100),
amount,
Decimal(1),
Decimal(1),
)
def scores(
rows: list[SectorDailyAggregate], target: date = DAYS[9]
) -> dict[tuple[SectorType, str, MetricKind], RankedMetric]:
return {
(row.observation.sector_type, row.observation.sector_code, row.observation.metric_kind): row
for row in calculate_rankings(
target,
[item for item in rows if item.trade_date == target],
[item for item in rows if item.trade_date != target],
DAYS,
)
}
def test_liquidity_weight_can_reverse_raw_ratio_order_and_separates_pools() -> None:
rows = [
aggregate(code, day, ratio, turnover, kind)
for day in DAYS[:10]
for code, ratio, turnover, kind in [
("small", ".3", "999999", SectorType.CONCEPT),
("large", ".2", "999999999999", SectorType.CONCEPT),
("medium", ".1", "9999999999", SectorType.CONCEPT),
("industry", "-.5", "9999999999", SectorType.INDUSTRY),
]
]
ranked = scores(rows)
small = ranked[(SectorType.CONCEPT, "small", MetricKind.RATIO)]
large = ranked[(SectorType.CONCEPT, "large", MetricKind.RATIO)]
assert small.observation.value == Decimal(".3")
assert small.observation.weighted_score == 600
assert large.observation.weighted_score == 800
assert large.rank_position == 1 and small.rank_position == 2
assert (
ranked[(SectorType.INDUSTRY, "industry", MetricKind.RATIO)].observation.weighted_score
== 1000
)
def test_swing_ranks_each_window_before_combining_not_the_averaged_raw_ratio() -> None:
rows = [
aggregate(code, day, ratio)
for index, day in enumerate(DAYS[:10])
for code, ratio in [("A", ".1" if index >= 7 else "-.1"), ("B", ".03"), ("C", ".05")]
]
ranked = scores(rows)
a = ranked[(SectorType.CONCEPT, "A", MetricKind.SWING)]
b = ranked[(SectorType.CONCEPT, "B", MetricKind.SWING)]
assert a.observation.value == b.observation.value == Decimal(".03")
assert a.observation.weighted_score == pytest.approx(Decimal("666.6666666666667"))
assert b.observation.weighted_score == 500
assert a.rank_position == 2 and b.rank_position == 3
def test_tied_features_use_average_percentiles_and_code_breaks_final_ties() -> None:
ranked = scores([aggregate(code, day, "0") for day in DAYS[:10] for code in ["B", "A"]])
assert ranked[(SectorType.CONCEPT, "A", MetricKind.RATIO)].observation.weighted_score == 750
assert ranked[(SectorType.CONCEPT, "B", MetricKind.RATIO)].observation.weighted_score == 750
assert ranked[(SectorType.CONCEPT, "A", MetricKind.RATIO)].rank_position == 1
def test_missing_calendar_session_is_not_replaced_by_an_older_success() -> None:
rows = [aggregate("A", day, ".2") for day in DAYS[:10] if day != DAYS[7]]
ranked = scores(rows)
daily = ranked[(SectorType.CONCEPT, "A", MetricKind.RATIO)]
assert daily.observation.value == Decimal(".2")
assert daily.observation.weighted_score is None and daily.rank_position is None
assert ranked[(SectorType.CONCEPT, "A", MetricKind.SWING)].observation.value is None
def test_future_inputs_do_not_affect_scores_and_bottom_uses_score_order() -> None:
rows = [
aggregate(f"C{n}", day, str(n), str(10 ** (6 + n) - 1))
for day in DAYS[:10]
for n in range(1, 11)
]
original = scores(rows)
future = scores(rows + [aggregate("C1", DAYS[10], "1000000")])
assert future == original
ratio_rows = [
row for row in original.values() if row.observation.metric_kind is MetricKind.RATIO
]
bottom = select_percentile_side(ratio_rows, RankSide.BOTTOM)
assert [row.observation.sector_code for row in bottom] == ["C1"]
def test_rank_changes_resolve_trading_dates_and_do_not_mix_versions() -> None:
rows = [aggregate("A", day, ".1") for day in DAYS[:11]]
before = tuple(scores(rows, DAYS[9]).values())
current = tuple(scores(rows, DAYS[10]).values())
assert comparison_dates(DAYS, DAYS[10])[1] == DAYS[9]
missing = calendar_rank_changes(current, {DAYS[8]: before}, DAYS, DAYS[10])
assert all(row.rank_change(1) is None for row in missing)
older = tuple(
replace(row, observation=replace(row.observation, metric_version="legacy"))
for row in before
)
incompatible = calendar_rank_changes(current, {DAYS[9]: older}, DAYS, DAYS[10])
assert all(row.rank_change(1) is None for row in incompatible)
comparable = calendar_rank_changes(current, {DAYS[9]: before}, DAYS, DAYS[10])
assert all(row.rank_change(1) == 0 for row in comparable)
assert (
resolve_metric_version(MetricKind.RATIO, [RATIO_WEIGHTED_VERSION]) == RATIO_WEIGHTED_VERSION
)
assert (
resolve_metric_version(MetricKind.SWING, [SWING_WEIGHTED_VERSION]) == SWING_WEIGHTED_VERSION
)