from datetime import UTC, date, datetime from decimal import Decimal from zhixing_server.modules.sector_radar.domain.models import StockFactStatus from zhixing_server.modules.sector_radar.domain.normalize import normalize_stock_facts from zhixing_server.modules.sector_radar.domain.source import ( DailyRow, MoneyflowDcRow, SourceResult, StockBasicRow, SuspendRow, build_source_snapshot, ) TARGET_DATE = date(2026, 8, 28) OBSERVED_AT = datetime(2026, 8, 28, 17, 30, tzinfo=UTC) def result[T](api_name: str, rows: tuple[T, ...]) -> SourceResult[T]: snapshot = build_source_snapshot( api_name=api_name, params={"trade_date": "20260828"}, rows=(), target_trade_date=TARGET_DATE, observed_at=OBSERVED_AT, ) return SourceResult((snapshot,), rows) def basic(ts_code: str, *, list_date: date = date(2020, 1, 1)) -> StockBasicRow: return StockBasicRow( ts_code=ts_code, symbol=ts_code.split(".")[0], name=ts_code, market="主板", exchange="SZSE", list_status="L", list_date=list_date, delist_date=None, ) def daily(ts_code: str, amount: Decimal | None) -> DailyRow: return DailyRow( ts_code=ts_code, trade_date=TARGET_DATE, close=Decimal("10"), pre_close=Decimal("10"), pct_chg=Decimal(0), volume=Decimal(0), amount_thousand_yuan=amount, ) def moneyflow(ts_code: str, amount: Decimal | None) -> MoneyflowDcRow: return MoneyflowDcRow( trade_date=TARGET_DATE, ts_code=ts_code, name=ts_code, net_amount_ten_thousand_yuan=amount, net_amount_rate=Decimal(0), pct_change=Decimal(0), close=Decimal("10"), ) def test_stock_fact_normalization_preserves_all_missing_and_zero_states() -> None: codes = tuple(f"00000{index}.SZ" for index in range(1, 9)) basics = tuple( basic(code, list_date=date(2027, 1, 1) if code == codes[7] else date(2020, 1, 1)) for code in codes ) daily_rows = ( daily(codes[0], Decimal("1")), daily(codes[3], None), daily(codes[4], Decimal("1")), daily(codes[5], Decimal("1")), daily(codes[6], Decimal("0")), daily(codes[7], Decimal("1")), ) moneyflow_rows = ( moneyflow(codes[0], Decimal("0")), moneyflow(codes[3], Decimal("1")), moneyflow(codes[5], None), moneyflow(codes[6], Decimal("0")), moneyflow(codes[7], Decimal("1")), ) suspensions = ( SuspendRow( ts_code=codes[1], trade_date=TARGET_DATE, suspend_timing="09:30", suspend_type="停牌", ), ) facts = normalize_stock_facts( target_trade_date=TARGET_DATE, candidate_codes=codes, stock_basics=result("stock_basic", basics), suspensions=result("suspend_d", suspensions), daily=result("daily", daily_rows), moneyflow=result("moneyflow_dc", moneyflow_rows), ) by_code = {fact.ts_code: fact for fact in facts} assert by_code[codes[0]].status is StockFactStatus.AVAILABLE assert by_code[codes[0]].turnover_yuan == Decimal("1000") assert by_code[codes[0]].net_amount_yuan == Decimal("0") assert by_code[codes[1]].status is StockFactStatus.SUSPENDED assert by_code[codes[2]].status is StockFactStatus.MISSING_DAILY assert by_code[codes[3]].status is StockFactStatus.NULL_DAILY_AMOUNT assert by_code[codes[4]].status is StockFactStatus.MISSING_MONEYFLOW assert by_code[codes[5]].status is StockFactStatus.NULL_MONEYFLOW assert by_code[codes[6]].status is StockFactStatus.LOW_LIQUIDITY assert by_code[codes[7]].status is StockFactStatus.LIFECYCLE_INVALID