Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,10 @@ jobs:
- run: uv run mypy

test:
runs-on: ${{ matrix.os }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
python: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
Expand All @@ -40,7 +39,7 @@ jobs:
- run: uv sync --extra dev --extra parallel --extra plots
- run: uv run pytest --cov --cov-report=xml
- uses: codecov/codecov-action@v4
if: matrix.os == 'ubuntu-latest' && matrix.python == '3.12'
if: matrix.python == '3.12'
with:
files: ./coverage.xml
fail_ci_if_error: false
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- CI matrix dropped `macos-latest`; tests now run on `ubuntu-latest` only
across Python 3.10, 3.11, 3.12.
- `mypy` configuration relaxed from `strict = true` to a pragmatic subset
(still enforces typed defs and generics) so the pandas-stubs `Any`-return
noise doesn't drown signal.
- `scipy` minimum bumped to `1.17`; `anderson(..., method="interpolate")`
is now passed explicitly to silence the SciPy 1.19 deprecation.

### Added
- `pandas-stubs` in the `dev` extra for typed pandas calls.
- Initial package scaffold extracted from the
`volatility-spillovers-methodology` research repository.
- Realized covariance, positive/negative semicovariance and vech utilities.
Expand Down
21 changes: 17 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ classifiers = [
dependencies = [
"numpy>=1.26",
"pandas>=2.1",
"scipy>=1.11",
"scipy>=1.17",
"scikit-learn>=1.4",
"statsmodels>=0.14",
]
Expand All @@ -51,6 +51,7 @@ dev = [
"hypothesis>=6.100",
"ruff>=0.6",
"mypy>=1.10",
"pandas-stubs>=2.1",
"pre-commit>=3.7",
"pyarrow>=15",
]
Expand Down Expand Up @@ -78,16 +79,28 @@ src = ["src", "tests"]

[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "NPY", "PD", "SIM", "RUF"]
ignore = ["PD901"]
ignore = [
"PD010", # `.pivot()` is intentional: it raises on duplicates, unlike `.pivot_table()`
]

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["B018", "PD"]

[tool.mypy]
python_version = "3.10"
strict = true
files = ["src/connectedness"]
plugins = []
# Pragmatic strictness: catch real bugs without drowning in pandas-stubs `Any` returns.
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_unreachable = true
# `pandas-stubs` returns `Any` from most DataFrame ops; relaxing this is the only
# practical way to keep strict checking on the rest.
warn_return_any = false
disallow_any_generics = true

[[tool.mypy.overrides]]
module = ["sklearn.*", "statsmodels.*", "joblib.*", "tqdm.*", "matplotlib.*", "scipy.*"]
Expand Down
4 changes: 2 additions & 2 deletions src/connectedness/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def normality_report(
if decision not in valid:
raise ValueError(f"decision must be one of {valid}, got {decision!r}")

rows: list[dict[str, float | bool]] = []
rows: list[dict[str, float | bool | str]] = []
for col in df.columns:
x = df[col].dropna().to_numpy(dtype=np.float64)
if x.size < 8:
Expand All @@ -48,7 +48,7 @@ def normality_report(
sw_stat, sw_p = shapiro(x)
else:
sw_stat, sw_p = np.nan, np.nan
ad = anderson(x, dist="norm")
ad = anderson(x, dist="norm", method="interpolate")

p_for_decision = {
"jarque_bera": jb_p,
Expand Down
2 changes: 1 addition & 1 deletion src/connectedness/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def pivot_to_wide(
asset_col: str = "asset",
price_col: str = "price",
) -> pd.DataFrame:
"""Pivot a long-format price DataFrame to a wide ``index=time × columns=asset`` table."""
"""Pivot a long-format price DataFrame to a wide table (index=time, columns=asset)."""
return (
df.sort_values([asset_col, time_col])
.pivot(index=time_col, columns=asset_col, values=price_col)
Expand Down
22 changes: 17 additions & 5 deletions src/connectedness/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ def __init__(
self._transformed: dict[str, pd.DataFrame] = {}
self._static: dict[str, SpilloverResult] = {}
self._rolling: dict[str, pd.DataFrame] = {}
self._rolling_kwargs: dict | None = None
self._rolling_window: int | None = None
self._rolling_n_jobs: int = 1
self._rolling_progress: bool = False

def compute_returns(self) -> SpilloverPipeline:
self._rets = simple_returns(self._prices)
Expand Down Expand Up @@ -103,19 +105,29 @@ def fit_static(self) -> SpilloverPipeline:
self._static[name] = static_spillover(df, self._config)
return self

def fit_rolling(self, window: int = 365, *, n_jobs: int = 1, progress: bool = False) -> SpilloverPipeline:
self._rolling_kwargs = {"window": window, "n_jobs": n_jobs, "progress": progress}
def fit_rolling(
self, window: int = 365, *, n_jobs: int = 1, progress: bool = False
) -> SpilloverPipeline:
self._rolling_window = window
self._rolling_n_jobs = n_jobs
self._rolling_progress = progress
return self

def run(self) -> PipelineResult:
if self._realized is None:
self.compute_realized_measures()
if not self._static:
self.fit_static()
if self._rolling_kwargs is not None and not self._rolling:
if self._rolling_window is not None and not self._rolling:
for name in self._measure_names:
df = self._transformed.get(name, self._measure(name))
self._rolling[name] = rolling_spillover(df, config=self._config, **self._rolling_kwargs)
self._rolling[name] = rolling_spillover(
df,
window=self._rolling_window,
config=self._config,
n_jobs=self._rolling_n_jobs,
progress=self._rolling_progress,
)
assert self._realized is not None
return PipelineResult(
measures=self._realized,
Expand Down
11 changes: 8 additions & 3 deletions src/connectedness/realized.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,18 @@
import pandas as pd


def _stack_daily(rets: pd.DataFrame) -> tuple[pd.DatetimeIndex, list[str], dict[pd.Timestamp, np.ndarray]]:
def _stack_daily(
rets: pd.DataFrame,
) -> tuple[pd.DatetimeIndex, list[str], dict[pd.Timestamp, np.ndarray]]:
if "date" not in rets.columns:
raise ValueError("`rets` must contain a 'date' column; see `connectedness.returns.simple_returns`.")
raise ValueError(
"`rets` must contain a 'date' column; see `connectedness.returns.simple_returns`."
)
value_cols = [c for c in rets.columns if c != "date"]
blocks: dict[pd.Timestamp, np.ndarray] = {}
for day, grp in rets.groupby("date", sort=True):
blocks[pd.Timestamp(day)] = grp[value_cols].to_numpy(dtype=np.float64)
ts = pd.Timestamp(day) # type: ignore[arg-type]
blocks[ts] = grp[value_cols].to_numpy(dtype=np.float64)
dates = pd.DatetimeIndex(sorted(blocks))
return dates, value_cols, blocks

Expand Down
3 changes: 2 additions & 1 deletion src/connectedness/returns.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ def log_returns(prices: pd.DataFrame, *, drop_inf: bool = True) -> pd.DataFrame:
if not prices.index.is_monotonic_increasing:
prices = prices.sort_index()
with np.errstate(divide="ignore", invalid="ignore"):
rets = np.log(prices).diff().dropna()
rets = np.log(prices.to_numpy())
rets = pd.DataFrame(rets, index=prices.index, columns=prices.columns).diff().dropna()
if drop_inf:
rets = rets.replace([np.inf, -np.inf], np.nan).dropna(how="any")
rets = rets.copy()
Expand Down
9 changes: 5 additions & 4 deletions src/connectedness/spillover.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@ def _spillover_table(
) -> pd.DataFrame:
spill_mat = theta_normalized * 100.0
tbl = pd.DataFrame(spill_mat, index=names, columns=names)
tbl["Directional FROM others"] = from_.values
tbl.loc["Directional TO others"] = [*to_.values, float(to_.sum())]
tbl.loc["NET Directional"] = [*net_.values, np.nan]
tbl["Directional FROM others"] = from_.to_numpy()
tbl.loc["Directional TO others"] = [*to_.to_numpy().tolist(), float(to_.sum())]
tbl.loc["NET Directional"] = [*net_.to_numpy().tolist(), np.nan]
return tbl


Expand Down Expand Up @@ -162,7 +162,8 @@ def one_window(start: int) -> tuple[pd.Timestamp, float] | None:
from joblib import Parallel, delayed
except ImportError as exc: # pragma: no cover
raise ImportError(
"joblib is required for n_jobs != 1; install with `pip install connectedness[parallel]`"
"joblib is required for n_jobs != 1; "
"install with `pip install connectedness[parallel]`"
) from exc
results = Parallel(n_jobs=n_jobs)(delayed(one_window)(s) for s in iterator)

Expand Down
4 changes: 3 additions & 1 deletion tests/unit/test_mhar.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ def test_build_lags_block_structure() -> None:
index=pd.date_range("2024-01-01", periods=60),
)
out = build_lags(df, MHARConfig())
assert {*df.columns, "a_lag1", "b_lag1", "a_wavg", "b_wavg", "a_mavg", "b_mavg"} <= set(out.columns)
assert {*df.columns, "a_lag1", "b_lag1", "a_wavg", "b_wavg", "a_mavg", "b_mavg"} <= set(
out.columns
)
assert len(out) == 60 - 30 # monthly_window dictates dropna depth


Expand Down
4 changes: 1 addition & 3 deletions tests/unit/test_returns.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@ def test_simple_returns_has_date_column(intraday_prices: pd.DataFrame) -> None:
def test_simple_returns_matches_diff(intraday_prices: pd.DataFrame) -> None:
rets = simple_returns(intraday_prices)
expected = intraday_prices.diff().dropna()
np.testing.assert_allclose(
rets[expected.columns].to_numpy(), expected.to_numpy()
)
np.testing.assert_allclose(rets[expected.columns].to_numpy(), expected.to_numpy())


def test_log_returns_drops_inf(intraday_prices: pd.DataFrame) -> None:
Expand Down
Loading