193 lines
5.7 KiB
Python
193 lines
5.7 KiB
Python
import unittest
|
|
from datetime import datetime, timezone
|
|
|
|
from quant60.pit import (
|
|
PITIntegrityError,
|
|
PITRecord,
|
|
PointInTimeTable,
|
|
assert_information_available,
|
|
)
|
|
from quant60.universe import (
|
|
SecurityEligibility,
|
|
UniverseState,
|
|
build_universe,
|
|
classify_security,
|
|
)
|
|
|
|
|
|
UTC = timezone.utc
|
|
|
|
|
|
def record(
|
|
*,
|
|
value,
|
|
effective="2024-03-31T00:00:00+08:00",
|
|
available="2024-04-30T18:00:00+08:00",
|
|
revision=0,
|
|
):
|
|
return PITRecord(
|
|
entity_id="600000.XSHG",
|
|
field="net_profit",
|
|
effective_time=effective,
|
|
available_time=available,
|
|
value=value,
|
|
source="fixture",
|
|
source_version="v1",
|
|
revision=revision,
|
|
)
|
|
|
|
|
|
class PITUniverseTests(unittest.TestCase):
|
|
def test_pit_query_never_uses_future_announcement(self):
|
|
table = PointInTimeTable([record(value=100)])
|
|
before = table.query(
|
|
"600000.XSHG",
|
|
"net_profit",
|
|
as_of="2024-04-30T17:59:59+08:00",
|
|
)
|
|
after = table.query(
|
|
"600000.XSHG",
|
|
"net_profit",
|
|
as_of="2024-04-30T18:00:00+08:00",
|
|
)
|
|
self.assertIsNone(before)
|
|
self.assertEqual(after.value, 100)
|
|
|
|
def test_revision_is_visible_only_after_its_own_available_time(self):
|
|
table = PointInTimeTable(
|
|
[
|
|
record(value=100),
|
|
record(
|
|
value=90,
|
|
available="2024-05-05T09:00:00+08:00",
|
|
revision=1,
|
|
),
|
|
]
|
|
)
|
|
first = table.query(
|
|
"600000.XSHG",
|
|
"net_profit",
|
|
as_of="2024-05-01T15:00:00+08:00",
|
|
)
|
|
revised = table.query(
|
|
"600000.XSHG",
|
|
"net_profit",
|
|
as_of="2024-05-06T15:00:00+08:00",
|
|
)
|
|
self.assertEqual(first.value, 100)
|
|
self.assertEqual(revised.value, 90)
|
|
|
|
def test_future_injection_gate_fails_closed(self):
|
|
future = record(
|
|
value=100,
|
|
available="2024-05-02T09:00:00+08:00",
|
|
)
|
|
with self.assertRaisesRegex(PITIntegrityError, "future-available"):
|
|
assert_information_available(
|
|
[future],
|
|
as_of="2024-05-01T15:00:00+08:00",
|
|
)
|
|
|
|
def test_naive_timestamps_and_duplicate_identity_are_rejected(self):
|
|
with self.assertRaisesRegex(PITIntegrityError, "timezone"):
|
|
record(
|
|
value=100,
|
|
effective=datetime(2024, 3, 31),
|
|
)
|
|
item = record(value=100)
|
|
with self.assertRaisesRegex(PITIntegrityError, "duplicate"):
|
|
PointInTimeTable([item, item])
|
|
|
|
def test_data_version_is_order_independent(self):
|
|
left = record(value=100)
|
|
right = PITRecord(
|
|
entity_id="000001.XSHE",
|
|
field="net_profit",
|
|
effective_time=datetime(2024, 3, 31, tzinfo=UTC),
|
|
available_time=datetime(2024, 4, 30, tzinfo=UTC),
|
|
value=80,
|
|
source="fixture",
|
|
source_version="v1",
|
|
)
|
|
self.assertEqual(
|
|
PointInTimeTable([left, right]).data_version,
|
|
PointInTimeTable([right, left]).data_version,
|
|
)
|
|
|
|
def test_universe_states_keep_untradable_holdings_visible(self):
|
|
frozen = classify_security(
|
|
SecurityEligibility(
|
|
symbol="600000.SH",
|
|
index_member=False,
|
|
listing_sessions=500,
|
|
median_turnover=1_000_000,
|
|
minimum_turnover=100_000,
|
|
suspended=True,
|
|
held_quantity=1000,
|
|
sellable_quantity=1000,
|
|
)
|
|
)
|
|
sell_only = classify_security(
|
|
SecurityEligibility(
|
|
symbol="000001.SZ",
|
|
index_member=True,
|
|
listing_sessions=500,
|
|
median_turnover=1_000_000,
|
|
minimum_turnover=100_000,
|
|
is_st=True,
|
|
held_quantity=1000,
|
|
sellable_quantity=1000,
|
|
)
|
|
)
|
|
self.assertEqual(frozen.state, UniverseState.FROZEN)
|
|
self.assertTrue(frozen.can_hold)
|
|
self.assertFalse(frozen.can_sell)
|
|
self.assertEqual(sell_only.state, UniverseState.SELL_ONLY)
|
|
self.assertTrue(sell_only.can_sell)
|
|
hold_only = classify_security(
|
|
SecurityEligibility(
|
|
symbol="000333.SZ",
|
|
index_member=True,
|
|
listing_sessions=500,
|
|
median_turnover=50_000,
|
|
minimum_turnover=100_000,
|
|
held_quantity=1000,
|
|
sellable_quantity=1000,
|
|
)
|
|
)
|
|
self.assertEqual(hold_only.state, UniverseState.HOLD_ONLY)
|
|
self.assertTrue(hold_only.can_hold)
|
|
self.assertFalse(hold_only.can_open)
|
|
|
|
def test_openable_and_excluded_are_distinct(self):
|
|
decisions = build_universe(
|
|
[
|
|
SecurityEligibility(
|
|
symbol="600000.SH",
|
|
index_member=True,
|
|
listing_sessions=500,
|
|
median_turnover=1_000_000,
|
|
minimum_turnover=100_000,
|
|
),
|
|
SecurityEligibility(
|
|
symbol="300750.SZ",
|
|
index_member=True,
|
|
listing_sessions=20,
|
|
median_turnover=1_000_000,
|
|
minimum_turnover=100_000,
|
|
),
|
|
]
|
|
)
|
|
self.assertEqual(
|
|
decisions["600000.XSHG"].state,
|
|
UniverseState.OPENABLE,
|
|
)
|
|
self.assertEqual(
|
|
decisions["300750.XSHE"].state,
|
|
UniverseState.EXCLUDED,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|