feat(sector-radar): add weighted scores and rank-change views
This commit is contained in:
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user