bagelquant-bt 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.3
2
+ Name: bagelquant-bt
3
+ Version: 0.1.0
4
+ Summary: Backtesting and factor evaluation for the BagelQuant ecosystem
5
+ Author: BagelQuant
6
+ License: Apache-2.0
7
+ Requires-Dist: numpy>=2.4.0
8
+ Requires-Dist: plotly>=6.0.0
9
+ Requires-Dist: polars>=1.35.0
10
+ Requires-Python: >=3.13
11
+ Description-Content-Type: text/markdown
12
+
13
+ # bagelquant-bt
14
+
15
+ `bagelquant-bt` provides backtesting, factor evaluation, performance metrics, and
16
+ Plotly visualization for long-form Polars panels.
17
+
18
+ The public input shape is always explicit:
19
+
20
+ - prices: `time`, `asset_id`, `price`
21
+ - weights: `time`, `asset_id`, `weight`
22
+ - factors: `time`, `asset_id`, `factor`
23
+
24
+ Weights at `time=t` earn each asset's close-to-close return from `t` to the next
25
+ available observation.
26
+
27
+ ```python
28
+ import polars as pl
29
+
30
+ from bagelquant_bt import BacktestConfig, run_backtest
31
+
32
+ prices = pl.DataFrame(
33
+ {
34
+ "time": ["2024-01-02", "2024-01-03"],
35
+ "asset_id": ["AAA", "AAA"],
36
+ "price": [100.0, 102.0],
37
+ }
38
+ )
39
+ weights = pl.DataFrame(
40
+ {"time": ["2024-01-02"], "asset_id": ["AAA"], "weight": [1.0]}
41
+ )
42
+
43
+ result = run_backtest(
44
+ weights,
45
+ prices,
46
+ kind="weights",
47
+ config=BacktestConfig(initial_capital=1_000_000),
48
+ )
49
+
50
+ print(result.returns)
51
+ print(result.summary)
52
+ ```
53
+
54
+ Factor evaluation computes daily cross-sectional IC, quantile returns, a
55
+ top-minus-bottom spread, and a TOP N equal-weight backtest.
56
+
57
+ Visualization helpers return Plotly figures:
58
+
59
+ ```python
60
+ from bagelquant_bt.visualization import plot_cumulative_returns
61
+
62
+ fig = plot_cumulative_returns(result)
63
+ fig.write_html("cumulative_returns.html")
64
+ ```
65
+
66
+ ## Development
67
+
68
+ ```bash
69
+ uv run ruff check .
70
+ uv run pytest
71
+ ```
@@ -0,0 +1,59 @@
1
+ # bagelquant-bt
2
+
3
+ `bagelquant-bt` provides backtesting, factor evaluation, performance metrics, and
4
+ Plotly visualization for long-form Polars panels.
5
+
6
+ The public input shape is always explicit:
7
+
8
+ - prices: `time`, `asset_id`, `price`
9
+ - weights: `time`, `asset_id`, `weight`
10
+ - factors: `time`, `asset_id`, `factor`
11
+
12
+ Weights at `time=t` earn each asset's close-to-close return from `t` to the next
13
+ available observation.
14
+
15
+ ```python
16
+ import polars as pl
17
+
18
+ from bagelquant_bt import BacktestConfig, run_backtest
19
+
20
+ prices = pl.DataFrame(
21
+ {
22
+ "time": ["2024-01-02", "2024-01-03"],
23
+ "asset_id": ["AAA", "AAA"],
24
+ "price": [100.0, 102.0],
25
+ }
26
+ )
27
+ weights = pl.DataFrame(
28
+ {"time": ["2024-01-02"], "asset_id": ["AAA"], "weight": [1.0]}
29
+ )
30
+
31
+ result = run_backtest(
32
+ weights,
33
+ prices,
34
+ kind="weights",
35
+ config=BacktestConfig(initial_capital=1_000_000),
36
+ )
37
+
38
+ print(result.returns)
39
+ print(result.summary)
40
+ ```
41
+
42
+ Factor evaluation computes daily cross-sectional IC, quantile returns, a
43
+ top-minus-bottom spread, and a TOP N equal-weight backtest.
44
+
45
+ Visualization helpers return Plotly figures:
46
+
47
+ ```python
48
+ from bagelquant_bt.visualization import plot_cumulative_returns
49
+
50
+ fig = plot_cumulative_returns(result)
51
+ fig.write_html("cumulative_returns.html")
52
+ ```
53
+
54
+ ## Development
55
+
56
+ ```bash
57
+ uv run ruff check .
58
+ uv run pytest
59
+ ```
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "bagelquant-bt"
3
+ version = "0.1.0"
4
+ description = "Backtesting and factor evaluation for the BagelQuant ecosystem"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ license = { text = "Apache-2.0" }
8
+ authors = [{ name = "BagelQuant" }]
9
+ dependencies = [
10
+ "numpy>=2.4.0",
11
+ "plotly>=6.0.0",
12
+ "polars>=1.35.0",
13
+ ]
14
+
15
+ [dependency-groups]
16
+ dev = [
17
+ "pytest>=9.0.0",
18
+ "ruff>=0.14.0",
19
+ ]
20
+
21
+ [build-system]
22
+ requires = ["uv_build>=0.8.0,<0.9.0"]
23
+ build-backend = "uv_build"
24
+
25
+ [tool.pytest.ini_options]
26
+ pythonpath = ["src"]
27
+ testpaths = ["tests"]
28
+
29
+ [tool.ruff]
30
+ line-length = 88
31
+ src = ["src", "tests"]
32
+
33
+ [tool.ruff.lint]
34
+ select = ["E", "F", "I", "B", "UP", "RUF"]
@@ -0,0 +1,32 @@
1
+ """Backtesting and factor evaluation for the BagelQuant ecosystem."""
2
+
3
+ from .config import BacktestConfig, TransactionCostConfig
4
+ from .engine import run_weight_backtest
5
+ from .exceptions import (
6
+ BacktestConfigError,
7
+ BagelQuantBacktestError,
8
+ InputValidationError,
9
+ )
10
+ from .factor import run_factor_evaluation
11
+ from .reporting import summary_report
12
+ from .results import (
13
+ BacktestResult,
14
+ FactorEvaluationResult,
15
+ PerformanceSummary,
16
+ TransactionCostBreakdown,
17
+ )
18
+
19
+ __all__ = [
20
+ "BacktestConfig",
21
+ "BacktestConfigError",
22
+ "BacktestResult",
23
+ "BagelQuantBacktestError",
24
+ "FactorEvaluationResult",
25
+ "InputValidationError",
26
+ "PerformanceSummary",
27
+ "TransactionCostBreakdown",
28
+ "TransactionCostConfig",
29
+ "run_factor_evaluation",
30
+ "run_weight_backtest",
31
+ "summary_report",
32
+ ]
@@ -0,0 +1,47 @@
1
+ """Configuration objects for backtesting and factor evaluation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from .exceptions import BacktestConfigError
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class TransactionCostConfig:
12
+ """Per-trade transaction cost settings."""
13
+
14
+ rate: float = 0.00015
15
+ min_fee: float = 5.0
16
+
17
+ def __post_init__(self) -> None:
18
+ if self.rate < 0:
19
+ raise BacktestConfigError("transaction cost rate must be nonnegative")
20
+ if self.min_fee < 0:
21
+ raise BacktestConfigError("transaction cost min_fee must be nonnegative")
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class BacktestConfig:
26
+ """Shared backtest and factor evaluation configuration."""
27
+
28
+ initial_capital: float
29
+ transaction_cost: TransactionCostConfig = field(
30
+ default_factory=TransactionCostConfig
31
+ )
32
+ annualization: int = 252
33
+ ic_method: str = "spearman"
34
+ quantiles: int = 5
35
+ top_n: int = 50
36
+
37
+ def __post_init__(self) -> None:
38
+ if self.initial_capital <= 0:
39
+ raise BacktestConfigError("initial_capital must be positive")
40
+ if self.annualization <= 0:
41
+ raise BacktestConfigError("annualization must be positive")
42
+ if self.ic_method not in {"spearman", "pearson"}:
43
+ raise BacktestConfigError("ic_method must be 'spearman' or 'pearson'")
44
+ if self.quantiles < 2:
45
+ raise BacktestConfigError("quantiles must be at least 2")
46
+ if self.top_n <= 0:
47
+ raise BacktestConfigError("top_n must be positive")
@@ -0,0 +1,31 @@
1
+ """Transaction cost and turnover calculations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import polars as pl
6
+
7
+ from .inputs import ASSET_ID, TIME
8
+
9
+
10
+ def turnover(weights: pl.DataFrame) -> pl.DataFrame:
11
+ """Compute daily absolute weight turnover."""
12
+
13
+ return (
14
+ weights.sort([ASSET_ID, TIME])
15
+ .with_columns(
16
+ pl.col("weight")
17
+ .fill_null(0.0)
18
+ .shift(1)
19
+ .over(ASSET_ID)
20
+ .fill_null(0.0)
21
+ .alias("previous_weight")
22
+ )
23
+ .with_columns(
24
+ (pl.col("weight").fill_null(0.0) - pl.col("previous_weight"))
25
+ .abs()
26
+ .alias("weight_delta")
27
+ )
28
+ .group_by(TIME)
29
+ .agg(pl.col("weight_delta").sum().alias("turnover"))
30
+ .sort(TIME)
31
+ )
@@ -0,0 +1,207 @@
1
+ """Backtest orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import polars as pl
6
+
7
+ from .config import BacktestConfig
8
+ from .costs import turnover
9
+ from .exceptions import BacktestConfigError, InputValidationError
10
+ from .inputs import ASSET_ID, TIME, validate_prices, validate_weights
11
+ from .performance import summarize_performance
12
+ from .results import BacktestResult, TransactionCostBreakdown
13
+ from .returns import (
14
+ _expand_portfolio_weights,
15
+ asset_close_to_close_returns,
16
+ cumulative_returns,
17
+ portfolio_returns,
18
+ )
19
+
20
+
21
+ def run_weight_backtest(
22
+ weights: pl.DataFrame,
23
+ prices: pl.DataFrame,
24
+ *,
25
+ config: BacktestConfig | None = None,
26
+ ) -> BacktestResult:
27
+ """Backtest a long-form portfolio weight frame."""
28
+
29
+ resolved_config = _require_config(config)
30
+ aligned_weights = validate_weights(weights)
31
+ aligned_prices = validate_prices(prices)
32
+ return backtest_weight_frame(
33
+ aligned_weights,
34
+ aligned_prices,
35
+ config=resolved_config,
36
+ )
37
+
38
+
39
+ def backtest_weight_frame(
40
+ weights: pl.DataFrame,
41
+ prices: pl.DataFrame,
42
+ *,
43
+ config: BacktestConfig,
44
+ ) -> BacktestResult:
45
+ """Backtest an already materialized long-form weight frame."""
46
+
47
+ aligned_weights = validate_weights(weights)
48
+ aligned_prices = validate_prices(prices)
49
+ forward_returns = asset_close_to_close_returns(aligned_prices)
50
+ return _backtest_weight_frame_with_forward_returns(
51
+ aligned_weights,
52
+ aligned_prices,
53
+ forward_returns,
54
+ config=config,
55
+ )
56
+
57
+
58
+ def _backtest_weight_frame_with_forward_returns(
59
+ weights: pl.DataFrame,
60
+ prices: pl.DataFrame,
61
+ forward_returns: pl.DataFrame,
62
+ *,
63
+ config: BacktestConfig,
64
+ ) -> BacktestResult:
65
+ """Backtest a weight frame with a precomputed forward-return panel."""
66
+
67
+ executable_weights = _expand_portfolio_weights(weights, prices, forward_returns)
68
+ if executable_weights.is_empty():
69
+ raise InputValidationError("at least two overlapping price times are required")
70
+
71
+ gross_returns = portfolio_returns(executable_weights, forward_returns)
72
+ turn = turnover(executable_weights)
73
+ costs, returns, value = _simulate_cost_adjusted_returns(
74
+ weights=executable_weights,
75
+ gross_returns=gross_returns,
76
+ config=config,
77
+ )
78
+ summary, performance = summarize_performance(
79
+ returns=returns,
80
+ turnover=turn,
81
+ costs=costs,
82
+ initial_capital=config.initial_capital,
83
+ annualization=config.annualization,
84
+ )
85
+ return BacktestResult(
86
+ weights=executable_weights,
87
+ asset_returns=forward_returns,
88
+ returns=returns,
89
+ value=value,
90
+ turnover=turn,
91
+ transaction_costs=costs,
92
+ summary=summary,
93
+ performance=performance,
94
+ )
95
+
96
+
97
+ def _simulate_cost_adjusted_returns(
98
+ *,
99
+ weights: pl.DataFrame,
100
+ gross_returns: pl.DataFrame,
101
+ config: BacktestConfig,
102
+ ) -> tuple[TransactionCostBreakdown, pl.DataFrame, pl.DataFrame]:
103
+ trade_summary = _trade_summary(weights)
104
+ gross_by_time = {
105
+ row[TIME]: float(row["gross_return"] or 0.0)
106
+ for row in gross_returns.iter_rows(named=True)
107
+ }
108
+
109
+ current_value = float(config.initial_capital)
110
+ cost_rows: list[dict[str, object]] = []
111
+ return_rows: list[dict[str, object]] = []
112
+ value_rows: list[dict[str, object]] = []
113
+
114
+ for row in trade_summary.iter_rows(named=True):
115
+ time = row[TIME]
116
+ weight_deltas = [float(delta) for delta in row["weight_deltas"]]
117
+ traded_asset_count = len(weight_deltas)
118
+ weight_delta = sum(weight_deltas)
119
+ traded_notional = weight_delta * current_value
120
+ raw_fee = traded_notional * config.transaction_cost.rate
121
+ total_fee = sum(
122
+ max(
123
+ delta * current_value * config.transaction_cost.rate,
124
+ config.transaction_cost.min_fee,
125
+ )
126
+ for delta in weight_deltas
127
+ )
128
+
129
+ cost_return = total_fee / current_value if current_value else 0.0
130
+ gross_return = gross_by_time.get(time, 0.0)
131
+ net_return = gross_return - cost_return
132
+ gross_value = current_value * (1.0 + gross_return)
133
+ current_value *= 1.0 + net_return
134
+ cost_rows.append(
135
+ {
136
+ TIME: time,
137
+ "traded_asset_count": traded_asset_count,
138
+ "traded_notional": traded_notional,
139
+ "raw_fee": raw_fee,
140
+ "min_fee_adjustment": total_fee - raw_fee,
141
+ "total_fee": total_fee,
142
+ "cost_return": cost_return,
143
+ }
144
+ )
145
+ return_rows.append(
146
+ {TIME: time, "gross_return": gross_return, "net_return": net_return}
147
+ )
148
+ value_rows.append(
149
+ {TIME: time, "gross_value": gross_value, "net_value": current_value}
150
+ )
151
+
152
+ returns = pl.DataFrame(return_rows).sort(TIME)
153
+ value = (
154
+ pl.DataFrame(value_rows)
155
+ .sort(TIME)
156
+ .join(
157
+ cumulative_returns(returns, "gross_return"),
158
+ on=TIME,
159
+ )
160
+ .join(
161
+ cumulative_returns(returns, "net_return"),
162
+ on=TIME,
163
+ )
164
+ )
165
+ return TransactionCostBreakdown(pl.DataFrame(cost_rows).sort(TIME)), returns, value
166
+
167
+
168
+ def _trade_summary(weights: pl.DataFrame) -> pl.DataFrame:
169
+ return (
170
+ weights.sort([ASSET_ID, TIME])
171
+ .with_columns(
172
+ pl.col("weight")
173
+ .fill_null(0.0)
174
+ .shift(1)
175
+ .over(ASSET_ID)
176
+ .fill_null(0.0)
177
+ .alias("previous_weight")
178
+ )
179
+ .with_columns(
180
+ (pl.col("weight").fill_null(0.0) - pl.col("previous_weight"))
181
+ .abs()
182
+ .alias("weight_delta")
183
+ )
184
+ .group_by(TIME)
185
+ .agg(
186
+ pl.col("weight_delta")
187
+ .filter(pl.col("weight_delta") > 0.0)
188
+ .alias("weight_deltas"),
189
+ )
190
+ .sort(TIME)
191
+ )
192
+
193
+
194
+ def _require_config(config: BacktestConfig | None) -> BacktestConfig:
195
+ if config is None:
196
+ raise BacktestConfigError(
197
+ "config is required because initial_capital is needed for minimum fees"
198
+ )
199
+ return config
200
+
201
+
202
+ def _partition_key(key: object) -> object:
203
+ if isinstance(key, tuple):
204
+ return key[0]
205
+ if isinstance(key, list):
206
+ return key[0]
207
+ return key
@@ -0,0 +1,13 @@
1
+ """Package-specific exceptions for bagelquant-bt."""
2
+
3
+
4
+ class BagelQuantBacktestError(Exception):
5
+ """Base exception for bagelquant-bt."""
6
+
7
+
8
+ class InputValidationError(BagelQuantBacktestError):
9
+ """Raised when user-provided tabular inputs are invalid."""
10
+
11
+
12
+ class BacktestConfigError(BagelQuantBacktestError):
13
+ """Raised when backtest configuration values are invalid."""