Initial extraction from monorepo
This commit is contained in:
commit
f7ef7ca4ab
56 changed files with 6163 additions and 0 deletions
126
tests/test_returns.py
Normal file
126
tests/test_returns.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""Returns loader + bootstrap behaviour."""
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from fire_planner.returns.bootstrap import block_bootstrap
|
||||
from fire_planner.returns.shiller import ReturnsBundle, load_from_csv, synthetic_returns
|
||||
|
||||
|
||||
def test_synthetic_returns_shape() -> None:
|
||||
b = synthetic_returns(seed=1, n_years=120)
|
||||
assert b.n_years == 120
|
||||
assert b.stock_nominal.shape == (120, )
|
||||
assert b.years[0] == 1871
|
||||
|
||||
|
||||
def test_synthetic_deterministic_for_seed() -> None:
|
||||
a = synthetic_returns(seed=42, n_years=10)
|
||||
b = synthetic_returns(seed=42, n_years=10)
|
||||
np.testing.assert_array_equal(a.stock_nominal, b.stock_nominal)
|
||||
|
||||
|
||||
def test_real_return_smoke() -> None:
|
||||
b = ReturnsBundle(
|
||||
years=np.array([2020], dtype=np.int32),
|
||||
stock_nominal=np.array([0.10]),
|
||||
bond_nominal=np.array([0.04]),
|
||||
cpi=np.array([0.03]),
|
||||
)
|
||||
# (1.10 / 1.03) - 1 ≈ 0.06796
|
||||
assert abs(b.stock_real()[0] - 0.06796116505) < 1e-9
|
||||
|
||||
|
||||
def test_load_from_csv(tmp_path: Path) -> None:
|
||||
csv_path = tmp_path / "returns.csv"
|
||||
csv_path.write_text("year,stock_nominal_return,bond_nominal_return,cpi_inflation\n"
|
||||
"1990,0.05,0.07,0.025\n"
|
||||
"1991,-0.10,0.04,0.03\n")
|
||||
b = load_from_csv(csv_path)
|
||||
assert b.n_years == 2
|
||||
assert b.stock_nominal[1] == pytest.approx(-0.10)
|
||||
assert b.cpi[0] == pytest.approx(0.025)
|
||||
|
||||
|
||||
def test_returns_bundle_rejects_mismatched_lengths() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
ReturnsBundle(
|
||||
years=np.array([2020, 2021], dtype=np.int32),
|
||||
stock_nominal=np.array([0.1]),
|
||||
bond_nominal=np.array([0.04, 0.05]),
|
||||
cpi=np.array([0.03, 0.025]),
|
||||
)
|
||||
|
||||
|
||||
def test_returns_bundle_rejects_empty() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
ReturnsBundle(
|
||||
years=np.array([], dtype=np.int32),
|
||||
stock_nominal=np.array([]),
|
||||
bond_nominal=np.array([]),
|
||||
cpi=np.array([]),
|
||||
)
|
||||
|
||||
|
||||
def test_bootstrap_shape() -> None:
|
||||
bundle = synthetic_returns(seed=1, n_years=150)
|
||||
rng = np.random.default_rng(0)
|
||||
paths = block_bootstrap(bundle, n_paths=100, n_years=60, block_size=5, rng=rng)
|
||||
assert paths.shape == (100, 60, 3)
|
||||
|
||||
|
||||
def test_bootstrap_deterministic_with_seed() -> None:
|
||||
bundle = synthetic_returns(seed=1, n_years=150)
|
||||
a = block_bootstrap(bundle, n_paths=50, n_years=30, block_size=5, rng=np.random.default_rng(0))
|
||||
b = block_bootstrap(bundle, n_paths=50, n_years=30, block_size=5, rng=np.random.default_rng(0))
|
||||
np.testing.assert_array_equal(a, b)
|
||||
|
||||
|
||||
def test_bootstrap_block_size_one_is_iid() -> None:
|
||||
"""Block size 1 reduces to simple IID resampling — covariance
|
||||
structure isn't preserved, but all draws come from the source."""
|
||||
bundle = synthetic_returns(seed=2, n_years=100)
|
||||
rng = np.random.default_rng(0)
|
||||
paths = block_bootstrap(bundle, n_paths=10, n_years=20, block_size=1, rng=rng)
|
||||
src_set = set(zip(bundle.stock_nominal, bundle.bond_nominal, bundle.cpi, strict=True))
|
||||
drawn_set = set((float(s), float(b), float(c)) for path in paths for s, b, c in path)
|
||||
assert drawn_set <= src_set
|
||||
|
||||
|
||||
def test_bootstrap_preserves_block_runs() -> None:
|
||||
"""For block_size=5, every consecutive 5-year run within a path
|
||||
must equal a 5-year run from the source (mod circular)."""
|
||||
bundle = synthetic_returns(seed=3, n_years=50)
|
||||
rng = np.random.default_rng(0)
|
||||
paths = block_bootstrap(bundle, n_paths=5, n_years=15, block_size=5, rng=rng)
|
||||
src = np.stack([bundle.stock_nominal, bundle.bond_nominal, bundle.cpi], axis=-1)
|
||||
src_n = src.shape[0]
|
||||
for path in paths:
|
||||
for block_start in range(0, 15, 5):
|
||||
block = path[block_start:block_start + 5]
|
||||
# Find this block in source by matching the first row, then
|
||||
# checking consecutiveness (mod circular).
|
||||
for src_idx in range(src_n):
|
||||
circ_block = np.stack([src[(src_idx + i) % src_n] for i in range(5)])
|
||||
if np.allclose(block, circ_block):
|
||||
break
|
||||
else:
|
||||
raise AssertionError(f"block {block_start} not a circular slice of source")
|
||||
|
||||
|
||||
def test_bootstrap_rejects_zero_block_size() -> None:
|
||||
bundle = synthetic_returns(seed=1, n_years=30)
|
||||
with pytest.raises(ValueError):
|
||||
block_bootstrap(bundle, n_paths=10, n_years=10, block_size=0)
|
||||
|
||||
|
||||
def test_bootstrap_n_years_not_multiple_of_block() -> None:
|
||||
"""13 years from 5-year blocks: 3 blocks then truncate to 13."""
|
||||
bundle = synthetic_returns(seed=1, n_years=50)
|
||||
paths = block_bootstrap(bundle,
|
||||
n_paths=4,
|
||||
n_years=13,
|
||||
block_size=5,
|
||||
rng=np.random.default_rng(0))
|
||||
assert paths.shape == (4, 13, 3)
|
||||
Loading…
Add table
Add a link
Reference in a new issue