192 lines
6.6 KiB
Python
192 lines
6.6 KiB
Python
import copy
|
|
import hashlib
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from quant60.features import TrainOnlyFeaturePreprocessor
|
|
from quant60.ledger import canonical_json
|
|
from quant60.model_bundle import (
|
|
ModelBundleIntegrityError,
|
|
create_model_bundle,
|
|
load_verified_model_bundle,
|
|
model_bundle_sha256,
|
|
verify_model_bundle,
|
|
write_model_bundle,
|
|
)
|
|
from quant60.research import DeterministicRidge
|
|
|
|
|
|
HASHES = {
|
|
"data_sha256": hashlib.sha256(b"training-data").hexdigest(),
|
|
"source_sha256": hashlib.sha256(b"source-tree").hexdigest(),
|
|
"config_sha256": hashlib.sha256(b"training-config").hexdigest(),
|
|
}
|
|
|
|
|
|
class ModelBundleTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.training_rows = [
|
|
{"momentum": -0.2, "volatility": 0.10},
|
|
{"momentum": -0.1, "volatility": None},
|
|
{"momentum": 0.0, "volatility": 0.20},
|
|
{"momentum": 0.1, "volatility": 0.25},
|
|
{"momentum": 0.2, "volatility": 0.30},
|
|
]
|
|
self.processor = TrainOnlyFeaturePreprocessor(
|
|
winsor_fraction=0.1
|
|
).fit(self.training_rows)
|
|
transformed = self.processor.transform(self.training_rows)
|
|
matrix = [
|
|
[row[name] for name in self.processor.output_names]
|
|
for row in transformed
|
|
]
|
|
self.model = DeterministicRidge(alpha=0.5).fit(
|
|
matrix,
|
|
[-0.03, -0.01, 0.0, 0.02, 0.04],
|
|
)
|
|
self.bundle = create_model_bundle(
|
|
self.processor,
|
|
self.model,
|
|
trained_through="2024-12-31",
|
|
label_name="t_plus_1_open_to_t_plus_6_open_excess",
|
|
horizon_trading_days=5,
|
|
**HASHES,
|
|
)
|
|
|
|
def test_freezes_preprocessor_model_label_and_lineage(self):
|
|
verified = verify_model_bundle(self.bundle)
|
|
self.assertEqual(
|
|
verified.feature_order,
|
|
tuple(sorted(self.training_rows[0])),
|
|
)
|
|
self.assertEqual(
|
|
verified.output_feature_order,
|
|
self.processor.output_names,
|
|
)
|
|
self.assertEqual(
|
|
verified.coefficients,
|
|
self.model.coef_,
|
|
)
|
|
self.assertEqual(verified.intercept, self.model.intercept_)
|
|
self.assertEqual(verified.trained_through.isoformat(), "2024-12-31")
|
|
self.assertEqual(verified.horizon_trading_days, 5)
|
|
self.assertEqual(verified.data_sha256, HASHES["data_sha256"])
|
|
|
|
def test_write_read_and_prediction_replay_are_deterministic(self):
|
|
replay_rows = [
|
|
{"volatility": None, "momentum": 0.07},
|
|
{"momentum": -100.0, "volatility": 100.0},
|
|
]
|
|
transformed = self.processor.transform(replay_rows)
|
|
matrix = [
|
|
[row[name] for name in self.processor.output_names]
|
|
for row in transformed
|
|
]
|
|
expected = self.model.predict(matrix)
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
left = write_model_bundle(
|
|
self.bundle,
|
|
Path(directory) / "left.json",
|
|
)
|
|
right = write_model_bundle(
|
|
copy.deepcopy(self.bundle),
|
|
Path(directory) / "right.json",
|
|
)
|
|
self.assertEqual(left.read_bytes(), right.read_bytes())
|
|
loaded = load_verified_model_bundle(
|
|
left,
|
|
expected_bundle_sha256=self.bundle["bundle_sha256"],
|
|
**{
|
|
f"expected_{key}": value
|
|
for key, value in HASHES.items()
|
|
},
|
|
)
|
|
self.assertEqual(loaded.predict(replay_rows), expected)
|
|
independently_loaded = load_verified_model_bundle(right)
|
|
self.assertEqual(
|
|
loaded.prediction_bytes(replay_rows),
|
|
independently_loaded.prediction_bytes(replay_rows),
|
|
)
|
|
self.assertEqual(loaded.canonical_bytes, left.read_bytes())
|
|
|
|
def test_content_tampering_fails_closed(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = write_model_bundle(
|
|
self.bundle,
|
|
Path(directory) / "bundle.json",
|
|
)
|
|
tampered = json.loads(path.read_text(encoding="utf-8"))
|
|
tampered["model"]["coefficients"][0] += 0.01
|
|
path.write_text(
|
|
canonical_json(tampered) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
with self.assertRaisesRegex(
|
|
ModelBundleIntegrityError,
|
|
"SHA-256 mismatch",
|
|
):
|
|
load_verified_model_bundle(path)
|
|
|
|
def test_resealed_semantic_tampering_fails_closed(self):
|
|
tampered = copy.deepcopy(self.bundle)
|
|
tampered["preprocessor"]["output_feature_order"] = list(
|
|
reversed(tampered["preprocessor"]["output_feature_order"])
|
|
)
|
|
tampered["bundle_sha256"] = model_bundle_sha256(tampered)
|
|
with self.assertRaisesRegex(
|
|
ModelBundleIntegrityError,
|
|
"output_feature_order is inconsistent",
|
|
):
|
|
verify_model_bundle(tampered)
|
|
|
|
def test_expected_identity_rejects_a_valid_but_wrong_bundle(self):
|
|
with self.assertRaisesRegex(
|
|
ModelBundleIntegrityError,
|
|
"data_sha256 does not match expected identity",
|
|
):
|
|
verify_model_bundle(
|
|
self.bundle,
|
|
expected_data_sha256="f" * 64,
|
|
)
|
|
|
|
def test_placeholder_lineage_hash_is_rejected(self):
|
|
with self.assertRaisesRegex(
|
|
ModelBundleIntegrityError,
|
|
"all-zero placeholder",
|
|
):
|
|
create_model_bundle(
|
|
self.processor,
|
|
self.model,
|
|
trained_through="2024-12-31",
|
|
label_name="label",
|
|
horizon_trading_days=5,
|
|
data_sha256="0" * 64,
|
|
source_sha256=HASHES["source_sha256"],
|
|
config_sha256=HASHES["config_sha256"],
|
|
)
|
|
|
|
def test_unfitted_components_and_non_finite_state_are_rejected(self):
|
|
with self.assertRaisesRegex(ValueError, "preprocessor is not fitted"):
|
|
create_model_bundle(
|
|
TrainOnlyFeaturePreprocessor(),
|
|
self.model,
|
|
trained_through="2024-12-31",
|
|
label_name="label",
|
|
horizon_trading_days=5,
|
|
**HASHES,
|
|
)
|
|
tampered = copy.deepcopy(self.bundle)
|
|
tampered["model"]["intercept"] = float("nan")
|
|
with self.assertRaisesRegex(
|
|
ModelBundleIntegrityError,
|
|
"canonical JSON",
|
|
):
|
|
verify_model_bundle(tampered)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|