feat: add Quant OS A-share baseline
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user