Skip to content

Commit cba9340

Browse files
tschmclaude
andauthored
refactor: split data.py god-module and decompose B(10) blocks (#871) (#894)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c84356d commit cba9340

3 files changed

Lines changed: 508 additions & 376 deletions

File tree

src/jquantstats/_data_reshape.py

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
"""Reshaping operations for `Data`: resampling, copying, slicing, truncation.
2+
3+
`_ReshapeMixin` collects the methods that return a *new* `Data` derived from an
4+
existing one. They are factored out of ``data.py`` to keep that module focused
5+
on construction and accessors; the mixin only reads the three dataclass fields
6+
(``returns``, ``index``, ``benchmark``) and rebuilds via `_rebuild`, which
7+
imports `Data` lazily to avoid re-forming the ``data`` ↔ mixin import cycle.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from datetime import date, datetime
13+
from typing import TYPE_CHECKING
14+
15+
import polars as pl
16+
17+
from .exceptions import IntegerIndexBoundError
18+
19+
if TYPE_CHECKING:
20+
from .data import Data
21+
22+
23+
class _ReshapeMixin:
24+
"""Mixin providing the `Data` operations that yield a new `Data`.
25+
26+
The concrete class (`Data`) supplies the ``returns``, ``index`` and
27+
``benchmark`` dataclass fields; they are annotated here so the mixin's
28+
methods type-check without importing `Data` at module load (which would
29+
re-form an import cycle). No runtime attributes are created — the mixin
30+
carries empty slots.
31+
"""
32+
33+
__slots__ = ()
34+
35+
# Provided by the concrete Data dataclass; declared for type-checkers only.
36+
returns: pl.DataFrame
37+
index: pl.DataFrame
38+
benchmark: pl.DataFrame | None
39+
40+
def _rebuild(
41+
self,
42+
*,
43+
returns: pl.DataFrame,
44+
index: pl.DataFrame,
45+
benchmark: pl.DataFrame | None = None,
46+
) -> Data:
47+
"""Build a fresh `Data` from the given frames.
48+
49+
`Data` is imported lazily so this module stays importable from
50+
``data.py`` without a cycle.
51+
52+
Args:
53+
returns: Returns frame for the new object.
54+
index: Date/row index frame for the new object.
55+
benchmark: Optional benchmark frame for the new object.
56+
57+
Returns:
58+
Data: A new `Data` built from the supplied frames.
59+
"""
60+
from .data import Data
61+
62+
return Data(returns=returns, index=index, benchmark=benchmark)
63+
64+
def resample(self, every: str = "1mo") -> Data:
65+
"""Resample returns and benchmark to a different frequency.
66+
67+
Args:
68+
every (str): Resampling frequency (e.g., ``'1mo'``, ``'1y'``).
69+
Defaults to ``'1mo'``.
70+
71+
Returns:
72+
Data: Resampled data at the requested frequency.
73+
74+
"""
75+
76+
def resample_frame(dframe: pl.DataFrame) -> pl.DataFrame:
77+
"""Resample a single DataFrame to the target frequency using compound returns."""
78+
dframe = self.index.hstack(dframe) # Add the date column for resampling
79+
80+
return dframe.group_by_dynamic(
81+
index_column=self.index.columns[0], every=every, period=every, closed="right", label="right"
82+
).agg(
83+
[
84+
((pl.col(col) + 1.0).product() - 1.0).alias(col)
85+
for col in dframe.columns
86+
if col != self.index.columns[0]
87+
]
88+
)
89+
90+
resampled_returns = resample_frame(self.returns)
91+
resampled_benchmark = resample_frame(self.benchmark) if self.benchmark is not None else None
92+
resampled_index = resampled_returns.select(self.index.columns[0])
93+
94+
return self._rebuild(
95+
returns=resampled_returns.drop(self.index.columns[0]),
96+
benchmark=resampled_benchmark.drop(self.index.columns[0]) if resampled_benchmark is not None else None,
97+
index=resampled_index,
98+
)
99+
100+
def copy(self) -> Data:
101+
"""Create a deep copy of the Data object.
102+
103+
Returns:
104+
Data: A new Data object with copies of the returns and benchmark.
105+
106+
"""
107+
benchmark = self.benchmark.clone() if self.benchmark is not None else None
108+
return self._rebuild(returns=self.returns.clone(), benchmark=benchmark, index=self.index.clone())
109+
110+
def head(self, n: int = 5) -> Data:
111+
"""Return the first n rows of the combined returns and benchmark data.
112+
113+
Args:
114+
n (int, optional): Number of rows to return. Defaults to 5.
115+
116+
Returns:
117+
Data: A new Data object containing the first n rows of the combined data.
118+
119+
"""
120+
benchmark_head = self.benchmark.head(n) if self.benchmark is not None else None
121+
return self._rebuild(returns=self.returns.head(n), benchmark=benchmark_head, index=self.index.head(n))
122+
123+
def tail(self, n: int = 5) -> Data:
124+
"""Return the last n rows of the combined returns and benchmark data.
125+
126+
Args:
127+
n (int, optional): Number of rows to return. Defaults to 5.
128+
129+
Returns:
130+
Data: A new Data object containing the last n rows of the combined data.
131+
132+
"""
133+
benchmark_tail = self.benchmark.tail(n) if self.benchmark is not None else None
134+
return self._rebuild(returns=self.returns.tail(n), benchmark=benchmark_tail, index=self.index.tail(n))
135+
136+
def truncate(
137+
self,
138+
start: date | datetime | str | int | None = None,
139+
end: date | datetime | str | int | None = None,
140+
) -> Data:
141+
"""Return a new Data object truncated to the inclusive [start, end] range.
142+
143+
When the index is temporal (Date/Datetime), truncation is performed by
144+
comparing the date column against ``start`` and ``end`` values.
145+
146+
When the index is integer-based, row slicing is used instead, and
147+
``start`` and ``end`` must be non-negative integers. Passing
148+
non-integer bounds to an integer-indexed Data raises `TypeError`.
149+
150+
Args:
151+
start: Optional lower bound (inclusive). A date/datetime value
152+
when the index is temporal; a non-negative `int` row
153+
index when the data has no temporal index.
154+
end: Optional upper bound (inclusive). Same type rules as
155+
``start``.
156+
157+
Returns:
158+
Data: A new Data object filtered to the specified range.
159+
160+
Raises:
161+
TypeError: When the index is not temporal and a non-integer bound
162+
is supplied.
163+
164+
"""
165+
date_column = self.index.columns[0]
166+
167+
if self.index[date_column].dtype.is_temporal():
168+
new_index, new_returns, new_benchmark = self._truncate_temporal(date_column, start, end)
169+
else:
170+
new_index, new_returns, new_benchmark = self._truncate_integer(start, end)
171+
172+
return self._rebuild(returns=new_returns, benchmark=new_benchmark, index=new_index)
173+
174+
def _truncate_temporal(
175+
self,
176+
date_column: str,
177+
start: date | datetime | str | int | None,
178+
end: date | datetime | str | int | None,
179+
) -> tuple[pl.DataFrame, pl.DataFrame, pl.DataFrame | None]:
180+
"""Truncate a temporal index by comparing the date column to [start, end]."""
181+
cond = pl.lit(True)
182+
if start is not None:
183+
cond = cond & (pl.col(date_column) >= pl.lit(start))
184+
if end is not None:
185+
cond = cond & (pl.col(date_column) <= pl.lit(end))
186+
mask = self.index.select(cond.alias("mask"))["mask"]
187+
new_benchmark = self.benchmark.filter(mask) if self.benchmark is not None else None
188+
return self.index.filter(mask), self.returns.filter(mask), new_benchmark
189+
190+
@staticmethod
191+
def _resolve_row_bound(name: str, value: date | datetime | str | int | None, default: int) -> int:
192+
"""Validate and resolve an integer truncation bound to a row index.
193+
194+
Args:
195+
name: The bound's name (``"start"`` or ``"end"``) for the message.
196+
value: The supplied bound; ``None`` and ``int`` are accepted.
197+
default: The row index to use when *value* is ``None``.
198+
199+
Returns:
200+
int: *value* when it is an ``int``, otherwise *default*.
201+
202+
Raises:
203+
IntegerIndexBoundError: If *value* is neither ``None`` nor ``int``.
204+
"""
205+
if value is not None and not isinstance(value, int):
206+
raise IntegerIndexBoundError(name, type(value).__name__)
207+
return value if value is not None else default
208+
209+
def _truncate_integer(
210+
self,
211+
start: date | datetime | str | int | None,
212+
end: date | datetime | str | int | None,
213+
) -> tuple[pl.DataFrame, pl.DataFrame, pl.DataFrame | None]:
214+
"""Truncate an integer index by row slicing; bounds must be integers."""
215+
row_start = self._resolve_row_bound("start", start, 0)
216+
row_end = self._resolve_row_bound("end", end, self.index.height - 1) + 1
217+
length = max(0, row_end - row_start)
218+
new_benchmark = self.benchmark.slice(row_start, length) if self.benchmark is not None else None
219+
return self.index.slice(row_start, length), self.returns.slice(row_start, length), new_benchmark

0 commit comments

Comments
 (0)