import hashlib import json import tempfile import unittest from datetime import date, datetime, timedelta, timezone from pathlib import Path from unittest.mock import patch import quant60.snapshot_backtest as snapshot_backtest_module from quant60.config import BacktestConfig, StrategyConfig from quant60.backtest import verify_artifact_manifest from quant60.data_snapshot import ( CanonicalBarRecord, IndexMembershipRecord, build_snapshot_payload, write_data_snapshot, ) from quant60.snapshot_pipeline import ( SnapshotDecisionError, build_snapshot_decision, first_executable_window, verify_snapshot_decision, write_snapshot_decision, ) from quant60.snapshot_backtest import run_snapshot_backtest def business_days(start: date, count: int) -> list[date]: values = [] current = start while len(values) < count: if current.weekday() < 5: values.append(current) current += timedelta(days=1) return values def build_provider_snapshot( root: Path, *, st_symbol_on_last_session: str | None = None, ) -> tuple[str, date]: frozen_calendar = business_days(date(2024, 1, 2), 31) sessions = frozen_calendar[:-1] next_trading_session = frozen_calendar[-1] bars = [] memberships = [] symbols = ("600000.XSHG", "000001.XSHE") for index, session in enumerate(sessions): for symbol in symbols: if symbol == "600000.XSHG": raw = 10.0 * (1.005**index) factor = 1.0 else: unsplit = 20.0 * (1.01**index) raw = unsplit if index < 15 else unsplit / 2.0 factor = 1.0 if index < 15 else 2.0 bars.append( CanonicalBarRecord( trading_date=session, symbol=symbol, open=raw * 0.999, high=raw * 1.01, low=raw * 0.99, close=raw, volume=1_000_000, money=raw * 1_000_000, paused=False, source="fixture-provider", is_st=( symbol == st_symbol_on_last_session and session == sessions[-1] ), adjustment="none", adjustment_factor=factor, previous_close=raw / 1.005, limit_up=raw * 1.10, limit_down=raw * 0.90, ) ) memberships.append( IndexMembershipRecord( effective_date=session, index_symbol="000905.XSHG", member_symbol=symbol, source="fixture-provider", ) ) payload = build_snapshot_payload( bars=bars, memberships=memberships, provider="fixture-provider", provider_version="1", retrieved_at=datetime(2024, 3, 1, tzinfo=timezone.utc), next_trading_session=next_trading_session, query={ "index_symbol": "000905.XSHG", "start_date": sessions[0].isoformat(), "end_date": sessions[-1].isoformat(), "trading_sessions": [ item.isoformat() for item in frozen_calendar ], "adjustment": "none", }, ) paths = write_data_snapshot(payload, root) return paths["manifest"], sessions[-1] class SnapshotPipelineTests(unittest.TestCase): @staticmethod def _broker_snapshot( as_of: date, *, observed_at: datetime | None = None, next_session: date = date(2024, 2, 13), ) -> dict: signal_timestamp = f"{as_of.isoformat()}T15:00:00+08:00" executable_window = first_executable_window(next_session) observation_timestamp = ( observed_at.isoformat() if observed_at is not None else executable_window["start"] ) return { "schema_version": "1.0", "snapshot_id": "broker-fixture", "signal_as_of": signal_timestamp, "next_trading_session": next_session.isoformat(), "first_executable_window": executable_window, "observation_as_of": observation_timestamp, "as_of": observation_timestamp, "source_time": observation_timestamp, "broker": "fixture", "account_hash": "fixture-account-hash", "cash": 1_000_000, "total_asset": 1_000_000, "market_value": 0, "positions": [], "open_orders": [], "metadata": {}, } def test_next_day_broker_observation_binds_to_prior_close_signal(self): config = BacktestConfig( symbols=("600000.XSHG", "000001.XSHE"), strategy=StrategyConfig(lookback=5, top_n=1), ) with tempfile.TemporaryDirectory() as directory: root = Path(directory) snapshot_manifest, as_of = build_provider_snapshot( root / "snapshot" ) observed_at = datetime( as_of.year, as_of.month, as_of.day, 9, 0, tzinfo=timezone(timedelta(hours=8)), ) + timedelta(days=1) broker = self._broker_snapshot( as_of, observed_at=observed_at, ) result = build_snapshot_decision( snapshot_manifest=snapshot_manifest, config=config, as_of=as_of, broker_snapshot=broker, ) self.assertEqual( result["decision"]["signal_as_of"], f"{as_of.isoformat()}T15:00:00+08:00", ) self.assertEqual( result["decision"]["broker_snapshot"][ "observation_as_of" ], observed_at.isoformat(), ) paths = write_snapshot_decision( result, root / "decision", ) verified = verify_snapshot_decision(paths["manifest"]) self.assertEqual( verified["signal_as_of"], f"{as_of.isoformat()}T15:00:00+08:00", ) def test_verified_snapshot_builds_deterministic_portable_target(self): config = BacktestConfig( symbols=("600000.XSHG", "000001.XSHE"), strategy=StrategyConfig( lookback=5, top_n=1, max_weight=0.50, gross_target=0.50, cash_buffer=0.10, ), ) with tempfile.TemporaryDirectory() as directory: root = Path(directory) snapshot_manifest, as_of = build_provider_snapshot( root / "snapshot" ) first = build_snapshot_decision( snapshot_manifest=snapshot_manifest, config=config, as_of=as_of, equity=1_000_000, ) second = build_snapshot_decision( snapshot_manifest=snapshot_manifest, config=config, as_of=as_of, equity=1_000_000, ) self.assertEqual(first, second) self.assertEqual(len(first["signals"]), 2) self.assertGreater( next( row["score"] for row in first["signals"] if row["symbol"] == "000001.XSHE" ), next( row["score"] for row in first["signals"] if row["symbol"] == "600000.XSHG" ), ) selected = [ row for row in first["targets"] if row["target_weight"] > 0 ] self.assertEqual( [row["symbol"] for row in selected], ["000001.XSHE"], ) paths = write_snapshot_decision(first, root / "decision") verified = verify_snapshot_decision(paths["manifest"]) self.assertTrue(verified["ok"]) self.assertEqual(verified["signal_count"], 2) self.assertEqual(verified["target_count"], 2) def test_pit_st_member_is_excluded_before_portable_ranking(self): config = BacktestConfig( symbols=("600000.XSHG", "000001.XSHE"), strategy=StrategyConfig( lookback=5, top_n=1, max_weight=0.50, gross_target=0.50, cash_buffer=0.10, ), ) with tempfile.TemporaryDirectory() as directory: snapshot_manifest, as_of = build_provider_snapshot( Path(directory) / "snapshot", st_symbol_on_last_session="000001.XSHE", ) result = build_snapshot_decision( snapshot_manifest=snapshot_manifest, config=config, as_of=as_of, equity=1_000_000, ) self.assertEqual( result["decision"]["universe"]["000001.XSHE"]["state"], "excluded", ) self.assertIn( "ST_EXCLUDED", result["decision"]["universe"]["000001.XSHE"]["reasons"], ) self.assertNotIn( "000001.XSHE", result["decision"]["targets"], ) def test_missing_exact_membership_fails_closed(self): config = BacktestConfig( symbols=("600000.XSHG", "000001.XSHE"), strategy=StrategyConfig(lookback=5, top_n=1), ) with tempfile.TemporaryDirectory() as directory: snapshot_manifest, as_of = build_provider_snapshot( Path(directory) / "snapshot" ) with self.assertRaisesRegex( SnapshotDecisionError, "no next session", ): build_snapshot_decision( snapshot_manifest=snapshot_manifest, config=config, as_of=as_of + timedelta(days=1), equity=1_000_000, ) def test_provider_snapshot_runs_local_event_backtest(self): config = BacktestConfig( symbols=("600000.XSHG", "000001.XSHE"), strategy=StrategyConfig( lookback=5, top_n=1, max_weight=0.50, gross_target=0.50, cash_buffer=0.10, ), ) with tempfile.TemporaryDirectory() as directory: root = Path(directory) snapshot_manifest, unused_as_of = build_provider_snapshot( root / "snapshot" ) del unused_as_of with patch.object( snapshot_backtest_module, "load_verified_data_snapshot", wraps=( snapshot_backtest_module.load_verified_data_snapshot ), ) as snapshot_loader: result = run_snapshot_backtest( snapshot_manifest=snapshot_manifest, config=config, ) self.assertEqual(snapshot_loader.call_count, 1) self.assertGreater(result.report["decision_count"], 0) self.assertEqual( result.report["evidence_class"], "provider-snapshot-local-backtest", ) self.assertFalse(result.report["real_platform_backtest"]) self.assertFalse(result.report["investment_value_claim"]) self.assertGreater(len(result.signals), 0) paths = result.write_artifacts(root / "backtest") self.assertTrue( verify_artifact_manifest(paths["manifest"])["ok"] ) def test_decision_tampering_is_rejected(self): config = BacktestConfig( symbols=("600000.XSHG", "000001.XSHE"), strategy=StrategyConfig(lookback=5, top_n=1), ) with tempfile.TemporaryDirectory() as directory: root = Path(directory) snapshot_manifest, as_of = build_provider_snapshot( root / "snapshot" ) result = build_snapshot_decision( snapshot_manifest=snapshot_manifest, config=config, as_of=as_of, equity=1_000_000, ) paths = write_snapshot_decision(result, root / "decision") decision_path = Path(paths["decision"]) decision = json.loads(decision_path.read_text(encoding="utf-8")) decision["orders"] = {} decision_path.write_text(json.dumps(decision), encoding="utf-8") with self.assertRaisesRegex( SnapshotDecisionError, "artifact hash mismatch", ): verify_snapshot_decision(paths["manifest"]) def test_copied_input_calendar_cannot_be_rebound_by_artifact_hash(self): config = BacktestConfig( symbols=("600000.XSHG", "000001.XSHE"), strategy=StrategyConfig(lookback=5, top_n=1), ) with tempfile.TemporaryDirectory() as directory: root = Path(directory) snapshot_manifest, as_of = build_provider_snapshot( root / "snapshot" ) result = build_snapshot_decision( snapshot_manifest=snapshot_manifest, config=config, as_of=as_of, equity=1_000_000, ) paths = write_snapshot_decision(result, root / "decision") input_path = Path(paths["input_data_manifest"]) copied = json.loads(input_path.read_text(encoding="utf-8")) copied["query"]["trading_sessions"][-1] = "2024-02-14" input_path.write_text( json.dumps( copied, ensure_ascii=False, indent=2, sort_keys=True, allow_nan=False, ) + "\n", encoding="utf-8", ) manifest_path = Path(paths["manifest"]) manifest = json.loads( manifest_path.read_text(encoding="utf-8") ) manifest["artifact_sha256"][ "input_data_manifest.json" ] = hashlib.sha256(input_path.read_bytes()).hexdigest() manifest_path.write_text( json.dumps( manifest, ensure_ascii=False, indent=2, sort_keys=True, allow_nan=False, ) + "\n", encoding="utf-8", ) with self.assertRaisesRegex( SnapshotDecisionError, "calendar manifest hash binding mismatch", ): verify_snapshot_decision(manifest_path) def test_wrong_window_stale_source_or_duplicate_broker_facts_fail_closed(self): config = BacktestConfig( symbols=("600000.XSHG", "000001.XSHE"), strategy=StrategyConfig(lookback=5, top_n=1), ) with tempfile.TemporaryDirectory() as directory: snapshot_manifest, as_of = build_provider_snapshot( Path(directory) / "snapshot" ) observed_at = datetime( as_of.year, as_of.month, as_of.day, 9, 2, tzinfo=timezone(timedelta(hours=8)), ) + timedelta(days=1) for source_time, message in ( ( (observed_at - timedelta(seconds=61)).isoformat(), "more than 60s stale", ), ( (observed_at + timedelta(seconds=6)).isoformat(), "more than 5s after", ), ): with self.subTest(source_time=source_time): broker = self._broker_snapshot( as_of, observed_at=observed_at, ) broker["source_time"] = source_time with self.assertRaisesRegex( SnapshotDecisionError, message, ): build_snapshot_decision( snapshot_manifest=snapshot_manifest, config=config, as_of=as_of, broker_snapshot=broker, ) signal_time = datetime( as_of.year, as_of.month, as_of.day, 15, 0, tzinfo=timezone(timedelta(hours=8)), ) for observed_at, message in ( ( signal_time + timedelta(minutes=1), "outside the unique next session", ), ( observed_at.replace(hour=9, minute=30), "outside the unique next session", ), ( observed_at + timedelta(days=3), "outside the unique next session", ), ): with self.subTest(observed_at=observed_at): broker = self._broker_snapshot( as_of, observed_at=observed_at, ) with self.assertRaisesRegex( SnapshotDecisionError, message, ): build_snapshot_decision( snapshot_manifest=snapshot_manifest, config=config, as_of=as_of, broker_snapshot=broker, ) for field in ("signal_as_of", "observation_as_of"): with self.subTest(missing=field): broker = self._broker_snapshot(as_of) del broker[field] with self.assertRaisesRegex( SnapshotDecisionError, rf"missing required fields.*{field}", ): build_snapshot_decision( snapshot_manifest=snapshot_manifest, config=config, as_of=as_of, broker_snapshot=broker, ) duplicate = self._broker_snapshot(as_of) position = { "symbol": "600000.XSHG", "quantity": 100, "sellable_quantity": 100, "average_cost": 10, "market_value": 1000, } duplicate["positions"] = [position, dict(position)] with self.assertRaisesRegex( SnapshotDecisionError, "duplicate position", ): build_snapshot_decision( snapshot_manifest=snapshot_manifest, config=config, as_of=as_of, broker_snapshot=duplicate, ) if __name__ == "__main__": unittest.main()