import json import tempfile import unittest from datetime import date, datetime, timezone from pathlib import Path from adapters.jqdata_local import ( JQDataUnavailableError, authenticate, fetch_index_daily_snapshot, ) from quant60.data_snapshot import ( CanonicalBarRecord, DataQualityError, verify_data_snapshot, ) class FakeFrame: def __init__(self, rows): self.rows = rows def iterrows(self): return iter(self.rows) class FakeJQData: __version__ = "fake-1" def __init__(self): self.authenticated = False self.last_get_bars_kwargs = None self.last_get_extras_kwargs = None self.trade_day_queries = [] self.full_calendar_queries = 0 def auth(self, username, password): self.authenticated = username == "user" and password == "password" def is_auth(self): return self.authenticated def get_trade_days(self, *, start_date, end_date): self.trade_day_queries.append( { "start_date": start_date, "end_date": end_date, } ) return [start_date, end_date] def get_all_trade_days(self): self.full_calendar_queries += 1 return [ date(2024, 1, 2), date(2024, 1, 3), date(2024, 1, 4), ] def get_index_stocks(self, index_symbol, *, date): return ["600000.XSHG", "000001.XSHE"] def get_extras( self, info, security_list, *, start_date, end_date, df, ): self.last_get_extras_kwargs = { "info": info, "security_list": list(security_list), "start_date": start_date, "end_date": end_date, "df": df, } return FakeFrame( [ ( start_date, {symbol: False for symbol in security_list}, ), ( end_date, {symbol: False for symbol in security_list}, ), ] ) def get_bars(self, symbol, **kwargs): self.last_get_bars_kwargs = dict(kwargs) offset = 0 if symbol.startswith("600000") else 10 return FakeFrame( [ ( 0, { "date": kwargs["start_dt"], "open": 10 + offset, "high": 11 + offset, "low": 9 + offset, "close": 10.5 + offset, "volume": 1000, "money": 10_500 + offset * 1000, "factor": 1.0, "paused": False, "high_limit": 11.5 + offset, "low_limit": 8.5 + offset, "pre_close": 10 + offset, }, ), ( 1, { "date": kwargs["end_dt"], "open": 10.5 + offset, "high": 11.5 + offset, "low": 10 + offset, "close": 11 + offset, "volume": 1200, "money": 13_200 + offset * 1200, "factor": 1.0, "paused": False, "high_limit": 12 + offset, "low_limit": 9 + offset, "pre_close": 10.5 + offset, }, ), ] ) class DataJQDataTests(unittest.TestCase): def test_authentication_never_accepts_failed_login(self): sdk = FakeJQData() with self.assertRaisesRegex(JQDataUnavailableError, "not accepted"): authenticate( sdk, username="user", password="wrong", interactive=False, ) authenticate( sdk, username="user", password="password", interactive=False, ) self.assertTrue(sdk.authenticated) def test_fake_jqdata_snapshot_is_canonical_and_verifiable(self): sdk = FakeJQData() authenticate( sdk, username="user", password="password", interactive=False, ) with tempfile.TemporaryDirectory() as directory: paths = fetch_index_daily_snapshot( sdk, index_symbol="000905.XSHG", start_date=date(2024, 1, 2), end_date=date(2024, 1, 3), output_dir=directory, retrieved_at=datetime(2024, 1, 4, tzinfo=timezone.utc), ) verified = verify_data_snapshot(paths["manifest"]) self.assertTrue(verified["ok"]) self.assertEqual(verified["bar_count"], 4) self.assertEqual(verified["membership_count"], 4) self.assertEqual( sdk.last_get_bars_kwargs["end_dt"].date(), date(2024, 1, 3), ) self.assertEqual( sdk.last_get_bars_kwargs["end_dt"].time(), datetime.strptime("23:59:59", "%H:%M:%S").time(), ) self.assertIs( sdk.last_get_bars_kwargs["include_now"], True, ) self.assertEqual( sdk.last_get_extras_kwargs["info"], "is_st", ) manifest = json.loads( Path(paths["manifest"]).read_text(encoding="utf-8") ) self.assertEqual( manifest["next_trading_session"], "2024-01-04", ) self.assertEqual( manifest["query"]["trading_sessions"], ["2024-01-02", "2024-01-03", "2024-01-04"], ) self.assertEqual(sdk.full_calendar_queries, 1) content = Path(paths["manifest"]).read_text(encoding="utf-8") self.assertNotIn("password", content) self.assertNotIn("user", content) bars = ( Path(directory) / "bars.jsonl" ).read_text(encoding="utf-8") self.assertIn('"adjustment_factor":1.0', bars) self.assertIn('"limit_up":', bars) def test_next_session_comes_from_provider_calendar_across_holiday(self): class NationalDayCalendarJQData(FakeJQData): def get_all_trade_days(self): self.full_calendar_queries += 1 return [ date(2024, 9, 27), date(2024, 9, 30), date(2024, 10, 8), date(2024, 10, 9), ] sdk = NationalDayCalendarJQData() with tempfile.TemporaryDirectory() as directory: paths = fetch_index_daily_snapshot( sdk, index_symbol="000905.XSHG", start_date=date(2024, 9, 27), end_date=date(2024, 9, 30), output_dir=directory, retrieved_at=datetime(2024, 10, 9, tzinfo=timezone.utc), ) manifest = json.loads( Path(paths["manifest"]).read_text(encoding="utf-8") ) self.assertEqual( manifest["next_trading_session"], "2024-10-08", ) self.assertEqual( manifest["query"]["trading_sessions"][-1], "2024-10-08", ) def test_invalid_next_session_calendar_response_fails_closed(self): class BadCalendarJQData(FakeJQData): def get_all_trade_days(self): return [date(2024, 1, 2), date(2024, 1, 3)] with tempfile.TemporaryDirectory() as directory: with self.assertRaisesRegex( JQDataUnavailableError, "unique next trading session", ): fetch_index_daily_snapshot( BadCalendarJQData(), index_symbol="000905.XSHG", start_date=date(2024, 1, 2), end_date=date(2024, 1, 3), output_dir=directory, retrieved_at=datetime( 2024, 1, 4, tzinfo=timezone.utc, ), ) def test_inconsistent_range_and_full_calendars_fail_closed(self): class MissingMiddleSessionJQData(FakeJQData): def get_all_trade_days(self): return [ date(2024, 1, 2), date(2024, 1, 3), date(2024, 1, 4), ] def get_trade_days(self, *, start_date, end_date): return [start_date] with tempfile.TemporaryDirectory() as directory: with self.assertRaisesRegex( JQDataUnavailableError, "inconsistent sessions", ): fetch_index_daily_snapshot( MissingMiddleSessionJQData(), index_symbol="000905.XSHG", start_date=date(2024, 1, 2), end_date=date(2024, 1, 3), output_dir=directory, retrieved_at=datetime( 2024, 1, 4, tzinfo=timezone.utc, ), ) def test_same_shanghai_day_is_rejected_before_query(self): sdk = FakeJQData() with tempfile.TemporaryDirectory() as directory: with self.assertRaisesRegex( JQDataUnavailableError, "24:00 finalization", ): fetch_index_daily_snapshot( sdk, index_symbol="000905.XSHG", start_date=date(2024, 1, 2), end_date=date(2024, 1, 3), output_dir=directory, retrieved_at=datetime( 2024, 1, 3, 6, tzinfo=timezone.utc, ), ) self.assertIsNone(sdk.last_get_bars_kwargs) self.assertIsNone(sdk.last_get_extras_kwargs) def test_missing_pit_st_status_fails_closed(self): class MissingStatusJQData(FakeJQData): def get_extras(self, *args, **kwargs): frame = super().get_extras(*args, **kwargs) frame.rows[-1][1].pop("600000.XSHG") return frame with tempfile.TemporaryDirectory() as directory: with self.assertRaisesRegex( JQDataUnavailableError, "omitted required field 600000.XSHG", ): fetch_index_daily_snapshot( MissingStatusJQData(), index_symbol="000905.XSHG", start_date=date(2024, 1, 2), end_date=date(2024, 1, 3), output_dir=directory, retrieved_at=datetime( 2024, 1, 4, tzinfo=timezone.utc, ), ) def test_missing_required_provider_rule_field_fails_closed(self): class MissingLimitJQData(FakeJQData): def get_bars(self, symbol, **kwargs): frame = super().get_bars(symbol, **kwargs) del frame.rows[0][1]["high_limit"] return frame with tempfile.TemporaryDirectory() as directory: with self.assertRaisesRegex( JQDataUnavailableError, "omitted required field high_limit", ): fetch_index_daily_snapshot( MissingLimitJQData(), index_symbol="000905.XSHG", start_date=date(2024, 1, 2), end_date=date(2024, 1, 3), output_dir=directory, retrieved_at=datetime( 2024, 1, 4, tzinfo=timezone.utc, ), ) def test_membership_without_same_session_bar_fails_quality_gate(self): class MissingEndBarJQData(FakeJQData): def get_bars(self, symbol, **kwargs): frame = super().get_bars(symbol, **kwargs) frame.rows.pop() return frame with tempfile.TemporaryDirectory() as directory: with self.assertRaisesRegex( DataQualityError, "membership has no same-session canonical bar", ): fetch_index_daily_snapshot( MissingEndBarJQData(), index_symbol="000905.XSHG", start_date=date(2024, 1, 2), end_date=date(2024, 1, 3), output_dir=directory, retrieved_at=datetime( 2024, 1, 4, tzinfo=timezone.utc, ), ) def test_bad_ohlc_fails_quality_gate(self): with self.assertRaisesRegex(DataQualityError, "open must lie"): CanonicalBarRecord( trading_date=date(2024, 1, 2), symbol="600000.SH", open=12, high=11, low=9, close=10, volume=100, money=1000, paused=False, source="fixture", ) if __name__ == "__main__": unittest.main()