213 lines
8.3 KiB
Python
213 lines
8.3 KiB
Python
import hashlib
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from quant60.backtest import BacktestEngine, verify_artifact_manifest
|
|
from quant60.cli import main
|
|
from quant60.config import config_from_dict, load_config
|
|
from quant60.ledger import HashChainLedger
|
|
from quant60.synthetic import generate_synthetic_bars
|
|
|
|
|
|
PROJECT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
class BacktestCliTests(unittest.TestCase):
|
|
def test_empty_config_uses_valid_defaults(self):
|
|
config = config_from_dict({})
|
|
config.validate()
|
|
|
|
def test_non_finite_config_values_are_rejected(self):
|
|
cases = (
|
|
{"initial_cash": "NaN"},
|
|
{"fees": {"commission_rate": float("inf")}},
|
|
{"execution": {"participation_rate": float("nan")}},
|
|
{"execution": {"slippage_bps": float("inf")}},
|
|
{"strategy": {"max_weight": float("nan")}},
|
|
{"strategy": {"gross_target": float("inf")}},
|
|
{"strategy": {"cash_buffer": float("nan")}},
|
|
{"initial_cash": True},
|
|
{"execution": {"lot_size": 100.0}},
|
|
{"execution": {"cancel_open_orders_at_end": 1}},
|
|
{"strategy": {"lookback": True}},
|
|
{"strategy": {"rebalance_every": 5.0}},
|
|
{"synthetic": {"trading_days": 90.0}},
|
|
{"symbols": "600000.XSHG"},
|
|
)
|
|
for raw in cases:
|
|
with self.subTest(raw=raw):
|
|
with self.assertRaises(ValueError):
|
|
config_from_dict(raw)
|
|
|
|
def test_backtest_is_byte_deterministic(self):
|
|
config = load_config(PROJECT / "configs" / "baseline.json")
|
|
bars = generate_synthetic_bars(
|
|
config.symbols,
|
|
config.synthetic.start_date,
|
|
config.synthetic.trading_days,
|
|
config.synthetic.seed,
|
|
config.synthetic.base_volume,
|
|
)
|
|
first = BacktestEngine(config).run(bars)
|
|
second = BacktestEngine(config).run(bars)
|
|
self.assertEqual(first.run_id, second.run_id)
|
|
self.assertEqual(first.report, second.report)
|
|
with tempfile.TemporaryDirectory() as left, tempfile.TemporaryDirectory() as right:
|
|
left_paths = first.write_artifacts(left)
|
|
right_paths = second.write_artifacts(right)
|
|
for key in left_paths:
|
|
left_hash = hashlib.sha256(left_paths[key].read_bytes()).hexdigest()
|
|
right_hash = hashlib.sha256(right_paths[key].read_bytes()).hexdigest()
|
|
self.assertEqual(left_hash, right_hash, key)
|
|
self.assertTrue(HashChainLedger.read_jsonl(left_paths["events"]).verify())
|
|
report = json.loads(left_paths["report"].read_text(encoding="utf-8"))
|
|
self.assertFalse(report["investment_value_claim"])
|
|
self.assertGreater(report["fill_count"], 0)
|
|
records = [
|
|
json.loads(line)
|
|
for line in left_paths["events"].read_text(
|
|
encoding="utf-8"
|
|
).splitlines()
|
|
]
|
|
timestamps = [
|
|
datetime.fromisoformat(record["timestamp"]) for record in records
|
|
]
|
|
self.assertEqual(timestamps, sorted(timestamps))
|
|
|
|
def test_decisions_use_first_trading_session_close_of_each_week(self):
|
|
config = load_config(PROJECT / "configs" / "baseline.json")
|
|
bars = generate_synthetic_bars(
|
|
config.symbols,
|
|
config.synthetic.start_date,
|
|
config.synthetic.trading_days,
|
|
config.synthetic.seed,
|
|
config.synthetic.base_volume,
|
|
)
|
|
result = BacktestEngine(config).run(bars)
|
|
sessions = sorted({item.trading_date for item in bars})
|
|
first_by_week = {}
|
|
for session in sessions:
|
|
first_by_week.setdefault(session.isocalendar()[:2], session)
|
|
decision_dates = {
|
|
datetime.fromisoformat(item["as_of"]).date()
|
|
for item in result.targets
|
|
}
|
|
self.assertTrue(decision_dates)
|
|
for decision_date in decision_dates:
|
|
self.assertEqual(
|
|
decision_date,
|
|
first_by_week[decision_date.isocalendar()[:2]],
|
|
)
|
|
|
|
def test_incomplete_market_session_fails_closed(self):
|
|
config = load_config(PROJECT / "configs" / "baseline.json")
|
|
bars = generate_synthetic_bars(
|
|
config.symbols,
|
|
config.synthetic.start_date,
|
|
config.synthetic.trading_days,
|
|
config.synthetic.seed,
|
|
config.synthetic.base_volume,
|
|
)
|
|
removed = bars.pop(len(config.symbols) * 30)
|
|
with self.assertRaisesRegex(
|
|
ValueError,
|
|
"incomplete market-data session.*" + removed.symbol.replace(".", r"\."),
|
|
):
|
|
BacktestEngine(config).run(bars)
|
|
|
|
def test_cli_smoke_and_ledger_verify(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
output = Path(directory) / "run"
|
|
self.assertEqual(
|
|
main(
|
|
[
|
|
"smoke",
|
|
"--config",
|
|
str(PROJECT / "configs" / "baseline.json"),
|
|
"--output",
|
|
str(output),
|
|
]
|
|
),
|
|
0,
|
|
)
|
|
self.assertEqual(main(["verify-ledger", str(output / "events.jsonl")]), 0)
|
|
self.assertEqual(
|
|
main(["verify-manifest", str(output / "manifest.json")]),
|
|
0,
|
|
)
|
|
self.assertTrue(
|
|
verify_artifact_manifest(output / "manifest.json")["ok"]
|
|
)
|
|
|
|
def test_manifest_verifier_rejects_tampered_or_incomplete_runs(self):
|
|
config = load_config(PROJECT / "configs" / "baseline.json")
|
|
bars = generate_synthetic_bars(
|
|
config.symbols,
|
|
config.synthetic.start_date,
|
|
config.synthetic.trading_days,
|
|
config.synthetic.seed,
|
|
config.synthetic.base_volume,
|
|
)
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
output = Path(directory) / "run"
|
|
BacktestEngine(config).run(bars).write_artifacts(output)
|
|
report_path = output / "report.json"
|
|
report_path.write_bytes(report_path.read_bytes() + b" ")
|
|
with self.assertRaisesRegex(ValueError, "report artifact hash mismatch"):
|
|
verify_artifact_manifest(output / "manifest.json")
|
|
|
|
(output / ".quant60-incomplete").write_text(
|
|
"interrupted\n",
|
|
encoding="ascii",
|
|
)
|
|
with self.assertRaisesRegex(ValueError, "publication is incomplete"):
|
|
verify_artifact_manifest(output / "manifest.json")
|
|
|
|
second_output = Path(directory) / "second-run"
|
|
BacktestEngine(config).run(bars).write_artifacts(second_output)
|
|
manifest_path = second_output / "manifest.json"
|
|
unexpected = second_output / "untracked-result.txt"
|
|
unexpected.write_text("not part of the run\n", encoding="utf-8")
|
|
with self.assertRaisesRegex(
|
|
ValueError,
|
|
"artifact directory is not an exact completed set",
|
|
):
|
|
verify_artifact_manifest(manifest_path)
|
|
unexpected.unlink()
|
|
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
manifest["engine_sources"]["portable_core.py"] = "0" * 64
|
|
manifest_path.write_text(
|
|
json.dumps(manifest, sort_keys=True),
|
|
encoding="utf-8",
|
|
)
|
|
with self.assertRaisesRegex(
|
|
ValueError,
|
|
"engine_sources aggregate hash mismatch",
|
|
):
|
|
verify_artifact_manifest(manifest_path)
|
|
|
|
def test_cli_backtest_alias(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
output = Path(directory) / "run"
|
|
self.assertEqual(
|
|
main(
|
|
[
|
|
"backtest",
|
|
"--config",
|
|
str(PROJECT / "configs" / "baseline.json"),
|
|
"--output",
|
|
str(output),
|
|
]
|
|
),
|
|
0,
|
|
)
|
|
self.assertTrue((output / "report.json").is_file())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|