Skip to content

Commit ecac90c

Browse files
committed
test: update tests
1 parent ee744be commit ecac90c

40 files changed

+1674
-1647
lines changed

okama/common/make_asset_list.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,12 +106,34 @@ def __getitem__(self, item):
106106
def _get_asset_obj_dict(ls: list) -> dict:
107107
def get_item(symbol):
108108
asset_item = symbol if hasattr(symbol, "symbol") and hasattr(symbol, "ror") else asset.Asset(symbol)
109-
if asset_item.pl.years == 0 and asset_item.pl.months <= 2:
109+
# Primary guard: use asset_item.pl when available
110+
try:
111+
if asset_item.pl.years == 0 and asset_item.pl.months <= 2:
112+
raise ShortPeriodLengthError(
113+
f"{asset_item.symbol} period length is {asset_item.pl.months}. It should be at least 3 months."
114+
)
115+
except AttributeError:
116+
# Fallback guard: if pl is missing or malformed, use the length of ror series
117+
pass
118+
119+
# Additional robust guard: ensure at least 3 monthly observations in ror
120+
try:
121+
ror_len = len(asset_item.ror)
122+
except Exception:
123+
ror_len = 0
124+
# Only enforce if pl did not already trigger; allow both to be consistent
125+
if ror_len < 3:
126+
# Try to provide a meaningful months value in the message
127+
months_msg = getattr(getattr(asset_item, "pl", None), "months", ror_len)
110128
raise ShortPeriodLengthError(
111-
f"{asset_item.symbol} period length is {asset_item.pl.months}. It should be at least 3 months."
129+
f"{asset_item.symbol} period length is {months_msg}. It should be at least 3 months."
112130
)
113131
return asset_item
114132

133+
# For a single item avoid parallel overhead and ensure exceptions propagate directly
134+
if len(ls) == 1:
135+
obj = get_item(ls[0])
136+
return {obj.symbol: obj}
115137
asset_obj_list = Parallel(n_jobs=-1, backend="threading")(delayed(get_item)(s) for s in ls)
116138
return {obj.symbol: obj for obj in asset_obj_list}
117139

@@ -298,7 +320,7 @@ def _make_real_return_time_series(self, df: pd.DataFrame) -> pd.DataFrame:
298320
Rate of return monthly data is adjusted for inflation.
299321
"""
300322
if not hasattr(self, "inflation"):
301-
raise ValueError("Real return is not defined. Set inflation=True when initiating the class.")
323+
raise ValueError("Real Return is not defined. Set inflation=True when initiating the class.")
302324
df = (1.0 + df).divide(1.0 + self.inflation_ts, axis=0) - 1.0
303325
df.drop(columns=[self.inflation], inplace=True)
304326
return df

okama/frontier/multi_period.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ def __repr__(self):
130130
"rebalancing_rel_deviation": self.rebalancing_strategy.rel_deviation,
131131
"bounds": self.bounds,
132132
"inflation": self.inflation if hasattr(self, "inflation") else "None",
133+
"n_points": self.n_points,
133134
}
134135
return repr(pd.Series(dic))
135136

@@ -573,6 +574,9 @@ def objective_function(w):
573574
point["Risk"] = weights.fun
574575
point["FTOL"] = self._FTOL[i]
575576
point["iter"] = weights.nit
577+
# Provide unified access to weights similar to other methods
578+
# Keep per-asset weights in the dict for backward compatibility
579+
point["Weights"] = weights.x
576580
break
577581
if not weights.success:
578582
raise RecursionError(f"No solution found for target CAGR value: {target_value}.")

tests/asset_list/conftest.py

Lines changed: 8 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -101,40 +101,12 @@
101101
import numpy as np
102102
import pandas as pd
103103

104-
105-
class _ListDefaults:
106-
def __init__(self):
107-
# Minimal monthly ror series (PeriodIndex with monthly freq)
108-
self.ror_index = pd.period_range("2020-01", "2020-03", freq="M")
109-
self.ror_a = pd.Series([0.01, 0.02, 0.03], index=self.ror_index, name="A.US")
110-
self.ror_b = pd.Series([0.00, -0.01, 0.02], index=self.ror_index, name="B.US")
111-
self.first_ts = self.ror_index[0].to_timestamp()
112-
self.last_ts = self.ror_index[-1].to_timestamp()
113-
114-
115-
class _FakeAsset:
116-
"""Minimal Asset-like object used in ListMaker patches."""
117-
118-
def __init__(self, symbol: str, ror: pd.Series, currency: str = "USD", name: str | None = None):
119-
self.symbol = symbol
120-
self.ticker = symbol.split(".")[0]
121-
self.name = name or f"{self.ticker} name"
122-
self.currency = currency
123-
self.ror = ror
124-
# first/last dates are taken from ror index
125-
self.first_date = ror.index[0].to_timestamp()
126-
self.last_date = ror.index[-1].to_timestamp()
127-
128-
129-
class _FakeCurrencyAsset:
130-
def __init__(self, symbol: str):
131-
# symbol expected like 'USD.FX'
132-
self.symbol = symbol
133-
self.ticker = symbol.split(".")[0]
134-
self.currency = self.ticker # base currency string
135-
# Provide wide range so it doesn't constrain
136-
self.first_date = pd.Timestamp("1990-01-01")
137-
self.last_date = pd.Timestamp("2100-01-01")
104+
# Re-export helper classes for backward compatibility in tests
105+
from tests.helpers.factories import (
106+
ListDefaults as _ListDefaults,
107+
FakeAsset as _FakeAsset,
108+
FakeCurrencyAsset as _FakeCurrencyAsset,
109+
)
138110

139111

140112
@pytest.fixture
@@ -163,35 +135,8 @@ def list_basic_patches(mocker):
163135
}
164136

165137

166-
@pytest.fixture
167-
def synthetic_env(mocker):
168-
"""Three assets over 24 months with controlled correlation for index metrics."""
169-
rng = np.random.default_rng(12345)
170-
idx = pd.period_range("2020-01", periods=24, freq="M")
171-
172-
a1 = pd.Series(rng.normal(0.01 / 12, 0.05, size=len(idx)), index=idx, name="IDX.US")
173-
a2 = pd.Series(rng.normal(0.008 / 12, 0.04, size=len(idx)), index=idx, name="A.US")
174-
a3_noise = rng.normal(0, 0.02, size=len(idx))
175-
a3 = pd.Series(0.5 * a1.values + a3_noise, index=idx, name="B.US")
176-
177-
fake_assets = {
178-
"IDX.US": _FakeAsset("IDX.US", a1, currency="USD", name="Index"),
179-
"A.US": _FakeAsset("A.US", a2, currency="USD", name="Asset A"),
180-
"B.US": _FakeAsset("B.US", a3, currency="USD", name="Asset B"),
181-
}
182-
m_get_dict = mocker.patch(
183-
"okama.common.make_asset_list.ListMaker._get_asset_obj_dict", return_value=fake_assets
184-
)
185-
m_currency_asset = mocker.patch(
186-
"okama.common.make_asset_list.asset.Asset", side_effect=_FakeCurrencyAsset
187-
)
188-
189-
yield {
190-
"index": idx,
191-
"series": {k: v for k, v in [("IDX.US", a1), ("A.US", a2), ("B.US", a3)]},
192-
"m_get_dict": m_get_dict,
193-
"m_currency_asset": m_currency_asset,
194-
}
138+
# Note: "synthetic_env" is now defined globally in tests/conftest.py.
139+
# If you need custom environment in asset_list tests, create a new fixture with a distinct name.
195140

196141

197142
@pytest.fixture

tests/asset_list/test_asset_list_mocking.py renamed to tests/asset_list/test_asset_list.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,19 @@ def test_dividends_and_yield_pipeline(mocker):
234234
ror = pd.Series(0.0, index=idx, name="D.US")
235235

236236
class _AssetWithDiv(_FakeAsset):
237+
def __init__(self, symbol: str, ror: pd.Series, currency: str = "USD", name: str | None = None):
238+
# Do NOT call super().__init__ because the base class assigns to
239+
# `self.close_monthly`, which would conflict with the read-only
240+
# property defined below. Here we only set the minimal attributes
241+
# required by ListMaker/AssetList logic in tests.
242+
self.symbol = symbol
243+
self.ticker = symbol.split(".")[0]
244+
self.name = name or f"{self.ticker} name"
245+
self.currency = currency
246+
self.ror = ror
247+
self.first_date = ror.index[0].to_timestamp()
248+
self.last_date = ror.index[-1].to_timestamp()
249+
237250
@property
238251
def dividends(self):
239252
return pd.Series(1.0, index=idx, name=self.symbol)
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import pytest
2+
import pandas as pd
3+
import numpy as np
4+
5+
import okama as ok
6+
from tests.asset_list.conftest import _FakeAsset, _FakeCurrencyAsset
7+
8+
9+
@pytest.fixture()
10+
def _inflation_env(mocker):
11+
"""
12+
Prepare a single-asset environment with deterministic monthly inflation.
13+
14+
Asset A.US has constant monthly return r_a. Inflation is constant monthly r_i.
15+
We patch ListMaker assets and macro.Inflation to be deterministic and offline.
16+
"""
17+
idx = pd.period_range("2020-01", periods=24, freq="M")
18+
r_a = 0.01 # 1% per month asset
19+
r_i = 0.002 # 0.2% per month inflation
20+
21+
asset_ror = pd.Series(r_a, index=idx, name="A.US")
22+
infl_monthly = pd.Series(r_i, index=idx.to_timestamp(how="end"), name="USD.INFL")
23+
24+
# Patch assets
25+
fake_assets = {"A.US": _FakeAsset("A.US", asset_ror)}
26+
mocker.patch("okama.common.make_asset_list.ListMaker._get_asset_obj_dict", return_value=fake_assets)
27+
mocker.patch("okama.common.make_asset_list.asset.Asset", side_effect=_FakeCurrencyAsset)
28+
29+
# Patch Inflation so that AssetList(inflation=True) picks our values
30+
class _FakeInflation:
31+
def __init__(self, symbol: str, first_date=None, last_date=None):
32+
self.symbol = symbol
33+
# first/last dates are taken from the monthly series index
34+
self.first_date = infl_monthly.index[0].to_period("M").to_timestamp()
35+
self.last_date = infl_monthly.index[-1].to_period("M").to_timestamp()
36+
# ListMaker expects PeriodIndex in _add_inflation path
37+
self.values_monthly = infl_monthly.to_period("M")
38+
39+
mocker.patch("okama.common.make_asset_list.macro.Inflation", side_effect=_FakeInflation)
40+
41+
return {
42+
"index": idx,
43+
"asset_ror": asset_ror,
44+
"infl_periodic": infl_monthly.to_period("M"),
45+
"r_a": r_a,
46+
"r_i": r_i,
47+
}
48+
49+
50+
def test_inflation_basic_pipeline_and_properties(_inflation_env):
51+
al = ok.AssetList(["A.US"], ccy="USD", inflation=True)
52+
53+
# Inflation attributes should exist and align with our patched series
54+
assert hasattr(al, "inflation")
55+
assert hasattr(al, "inflation_ts")
56+
assert isinstance(al.inflation_ts, pd.Series)
57+
# Index should be PeriodIndex with monthly frequency and match assets_ror index (inner join applied)
58+
assert isinstance(al.inflation_ts.index, pd.PeriodIndex)
59+
assert list(al.inflation_ts.index) == list(al.assets_ror.index)
60+
61+
# Name of inflation series should be the inflation symbol
62+
assert isinstance(al.inflation, str) and al.inflation.endswith(".INFL")
63+
assert al.inflation_ts.name == al.inflation
64+
65+
# Mean annualized inflation from monthly 0.2% should be close to 12 * 0.002 for small rates
66+
# (Exact CAGR is tested below; here only sanity check for arithmetic mean annualization in tests)
67+
mean_annual = float(al.inflation_ts.values.mean() * 12)
68+
assert pytest.approx(mean_annual, rel=1e-12) == 0.002 * 12
69+
70+
71+
@pytest.mark.parametrize(
72+
"compute_metric",
73+
[
74+
"real_mean_return", # property: annualized arithmetic mean adjusted by arithmetic mean inflation
75+
"real_cagr", # method: get_cagr(real=True) full period
76+
],
77+
)
78+
def test_real_metrics_with_inflation_parametrized(_inflation_env, compute_metric):
79+
al = ok.AssetList(["A.US"], ccy="USD", inflation=True)
80+
81+
# Helper to compute expected real mean return using arithmetic means (as in tests elsewhere)
82+
def expected_real_mean_return():
83+
# Asset annualized arithmetic mean from AssetList
84+
mean_asset_annual = float(al.mean_return["A.US"]) # already annualized in implementation
85+
# Inflation annualized arithmetic mean (monthly -> annual by *12)
86+
infl_mean_annual = float(al.inflation_ts.values.mean() * 12)
87+
return (1.0 + mean_asset_annual) / (1.0 + infl_mean_annual) - 1.0
88+
89+
# Helper to compute expected real CAGR over the full period
90+
def expected_real_cagr_full():
91+
ror = al.assets_ror["A.US"]
92+
n = len(ror)
93+
wi_last = float((1.0 + ror).prod())
94+
# Annualize to 12 months per year
95+
cagr_asset = wi_last ** (12.0 / n) - 1.0
96+
97+
infl = al.inflation_ts
98+
wi_infl_last = float((1.0 + infl).prod())
99+
cagr_infl = wi_infl_last ** (12.0 / n) - 1.0
100+
return (1.0 + cagr_asset) / (1.0 + cagr_infl) - 1.0
101+
102+
if compute_metric == "real_mean_return":
103+
got = float(al.real_mean_return["A.US"]) # property should exist for inflation=True case
104+
exp = expected_real_mean_return()
105+
assert pytest.approx(got, rel=1e-12) == exp
106+
107+
elif compute_metric == "real_cagr":
108+
got = float(al.get_cagr(real=True)["A.US"]) # full-period real CAGR
109+
exp = expected_real_cagr_full()
110+
assert pytest.approx(got, rel=1e-12) == exp
111+
112+
else:
113+
raise AssertionError("Unknown metric param")

0 commit comments

Comments
 (0)