From 31baa18ce5b33bf16e006457cf722ec00eba6860 Mon Sep 17 00:00:00 2001 From: Yuxuan Yan Date: Tue, 16 Jun 2026 17:37:16 +0800 Subject: [PATCH] Add offline workflow and coverage tests --- .gitignore | 1 + pipeline/alpha/cli.py | 7 +- pipeline/portfolio/simulator.py | 11 +- pyproject.toml | 24 + scripts/run_coverage.py | 40 ++ .../daily_bars_real_2024_01_sample.pq | Bin 0 -> 15507 bytes tests/helpers.py | 261 +++++++++ tests/test_alpha.py | 89 +++ tests/test_cli_workflow.py | 427 +++++++++++++++ tests/test_downloader.py | 10 + tests/test_downloader_contracts.py | 306 +++++++++++ tests/test_features.py | 22 + tests/test_portfolio.py | 190 +++++++ tests/test_schema_failures.py | 98 ++++ tests/test_workflow_sanity.py | 508 ++++++++++++++++++ uv.lock | 119 +++- 16 files changed, 2104 insertions(+), 9 deletions(-) create mode 100644 scripts/run_coverage.py create mode 100644 tests/fixtures/daily_bars_real_2024_01_sample.pq create mode 100644 tests/helpers.py create mode 100644 tests/test_cli_workflow.py create mode 100644 tests/test_downloader_contracts.py create mode 100644 tests/test_schema_failures.py create mode 100644 tests/test_workflow_sanity.py diff --git a/.gitignore b/.gitignore index 3d9e567..c91fad5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ __pycache__/ *.py[cod] .pytest_cache/ +.coverage *.egg-info/ .venv/ venv/ diff --git a/pipeline/alpha/cli.py b/pipeline/alpha/cli.py index 4590c7a..2d0f462 100644 --- a/pipeline/alpha/cli.py +++ b/pipeline/alpha/cli.py @@ -160,7 +160,8 @@ def reversal_vol(data_path, output_dir, lookback, vol_window): @alpha.command("eval") @click.option("--alpha-path", required=True, help="Path to alpha parquet file") @click.option("--data-path", required=True, help="Path to data parquet (for price data)") -def eval_(alpha_path, data_path): +@click.option("--report-dir", default="reports", help="Directory to save JSON report") +def eval_(alpha_path, data_path, report_dir): """Evaluate an alpha's performance (return, Sharpe, turnover). Alphas are interpreted as position WEIGHTS, not return predictors. @@ -183,9 +184,9 @@ def eval_(alpha_path, data_path): click.echo("=" * 50) # Also dump JSON - os.makedirs("reports", exist_ok=True) + os.makedirs(report_dir, exist_ok=True) alpha_name = alpha_df["alpha_name"].iloc[0] - json_path = f"reports/{alpha_name}_eval.json" + json_path = os.path.join(report_dir, f"{alpha_name}_eval.json") with open(json_path, "w") as f: json.dump(metrics, f, indent=2) click.echo(f"\nReport saved: {json_path}") diff --git a/pipeline/portfolio/simulator.py b/pipeline/portfolio/simulator.py index 9c56c4b..144b260 100644 --- a/pipeline/portfolio/simulator.py +++ b/pipeline/portfolio/simulator.py @@ -166,13 +166,14 @@ class ReferenceSimulator(ExecutionSimulator): st = wide(data_df, "isST") if "isST" in data_df.columns else opn * 0.0 symbols = sorted(set(tgt.columns) | set(opn.columns)) + data_index = close.index tgt = tgt.reindex(columns=symbols) - opn = opn.reindex(columns=symbols) + opn = opn.reindex(index=data_index, columns=symbols) close = close.reindex(columns=symbols) - preclose = preclose.reindex(columns=symbols) - amount = amount.reindex(columns=symbols) - tstat = tstat.reindex(columns=symbols) - st = st.reindex(columns=symbols) + preclose = preclose.reindex(index=data_index, columns=symbols) + amount = amount.reindex(index=data_index, columns=symbols) + tstat = tstat.reindex(index=data_index, columns=symbols) + st = st.reindex(index=data_index, columns=symbols) sym_arr = np.asarray(symbols, dtype=object) n = len(symbols) diff --git a/pyproject.toml b/pyproject.toml index a0bce24..342b772 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,8 +19,32 @@ backtrader = [ [dependency-groups] dev = [ + "coverage>=7.14.1", "pytest>=7.0.0", ] [tool.uv] package = false + +[tool.pytest.ini_options] +markers = [ + "network: tests that call live external data providers and are skipped unless explicitly enabled", +] + +[tool.coverage.run] +branch = true +relative_files = true +source = [ + ".", +] + +[tool.coverage.report] +fail_under = 80 +show_missing = true +skip_covered = false +omit = [ + "tests/*", + "docs/*", + "scripts/*", + ".venv/*", +] diff --git a/scripts/run_coverage.py b/scripts/run_coverage.py new file mode 100644 index 0000000..a2c8b26 --- /dev/null +++ b/scripts/run_coverage.py @@ -0,0 +1,40 @@ +"""Run pytest under coverage in this environment. + +The plain ``coverage run -m pytest`` path reloads NumPy under the current VS Code +Python startup environment, which breaks pandas/numpy reductions. Import NumPy +before starting coverage so the measured test run uses one stable NumPy module. +""" + +from __future__ import annotations + +import sys + +import numpy # noqa: F401 +import coverage +import pytest + + +def main(argv: list[str]) -> int: + pytest_args = argv or ["tests/", "-v"] + + cov = coverage.Coverage(config_file=True) + cov.erase() + cov.start() + test_status = pytest.main(pytest_args) + cov.stop() + cov.save() + + if test_status != 0: + return int(test_status) + + total = cov.report() + print(f"\nCoverage total: {total:.2f}%") + fail_under = float(cov.config.fail_under) + if total < fail_under: + print(f"Coverage failure: total {total:.2f}% is below {fail_under:.2f}%") + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/fixtures/daily_bars_real_2024_01_sample.pq b/tests/fixtures/daily_bars_real_2024_01_sample.pq new file mode 100644 index 0000000000000000000000000000000000000000..eea4264b527cb59d1b4707b0715a008de8829e66 GIT binary patch literal 15507 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 literal 0 HcmV?d00001 diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..1836ec7 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,261 @@ +"""Shared deterministic test data for offline workflow tests.""" + +from __future__ import annotations + +import numpy as np +import pandas as pd + +from pipeline.common.schema import ( + ALPHA_COLUMNS, + COMBO_COLUMNS, + DATA_COLUMNS, + MINUTE_BAR_COLUMNS, +) + + +GENERATED_SYMBOLS: tuple[str, ...] = ( + "sh600000", + "sz000001", + "sh600519", + "sz300750", +) + +GENERATED_SYMBOL_NAMES: dict[str, str] = { + "sh600000": "PF Bank", + "sz000001": "Ping An Bank", + "sh600519": "Kweichow Moutai", + "sz300750": "CATL", +} + + +def generated_sessions(n_sessions: int = 12) -> pd.DatetimeIndex: + """Return a fixed business-day calendar used by generated fixtures.""" + return pd.bdate_range("2024-01-02", periods=n_sessions) + + +def make_generated_daily_bars( + n_sessions: int = 12, + include_missing: bool = True, +) -> pd.DataFrame: + """Build daily bars with explicit edge cases and no randomness. + + The panel covers four A-share symbols and includes a suspended row, an ST + flag, a zero-volume row, a missing symbol-date, and limit-style open/close + moves. Values are deterministic so tests can assert exact identities. + """ + dates = generated_sessions(n_sessions) + base_close = { + "sh600000": 10.00, + "sz000001": 15.00, + "sh600519": 1200.00, + "sz300750": 180.00, + } + returns = { + "sh600000": [0.000, 0.012, -0.006, 0.018, 0.100, -0.014, 0.006, 0.000, 0.008, -0.011, 0.004, 0.009], + "sz000001": [0.000, -0.008, 0.011, -0.004, 0.006, 0.000, -0.012, 0.009, 0.005, -0.007, 0.010, -0.003], + "sh600519": [0.000, 0.006, 0.004, -0.010, 0.012, -0.006, 0.005, 0.003, -0.009, 0.007, -0.004, 0.006], + "sz300750": [0.000, -0.010, 0.014, 0.006, -0.008, 0.011, -0.004, 0.009, -0.200, 0.012, -0.006, 0.008], + } + base_volume = { + "sh600000": 1_200_000.0, + "sz000001": 900_000.0, + "sh600519": 80_000.0, + "sz300750": 240_000.0, + } + + rows: list[dict[str, object]] = [] + for sym in GENERATED_SYMBOLS: + closes = [base_close[sym]] + pattern = returns[sym] + for step in range(1, n_sessions): + ret = pattern[step % len(pattern)] + closes.append(closes[-1] * (1.0 + ret)) + closes_arr = np.asarray(closes, dtype=float) + precloses = np.concatenate([[closes_arr[0]], closes_arr[:-1]]) + + for i, date in enumerate(dates): + preclose = float(precloses[i]) + close = float(closes_arr[i]) + open_price = preclose * (1.0 + 0.25 * (close / preclose - 1.0)) + high = max(open_price, close) * 1.01 + low = min(open_price, close) * 0.99 + volume = base_volume[sym] + 10_000.0 * i + tradestatus = 1 + is_st = 0 + + if sym == "sh600000" and i == 4: + open_price = preclose * 1.10 + close = open_price + high = open_price + low = open_price + if sym == "sh600000" and i == 7: + volume = 0.0 + if sym == "sz000001" and i == 5: + open_price = preclose + close = preclose + high = preclose + low = preclose + volume = 0.0 + tradestatus = 0 + if sym == "sh600519" and i == 7: + is_st = 1 + if sym == "sz300750" and i == 8: + open_price = preclose * 0.80 + close = open_price + high = open_price + low = open_price + + amount = volume * ((open_price + close) / 2.0) + vwap = amount / volume if volume > 0 else np.nan + pct_chg = (close / preclose - 1.0) * 100.0 if preclose else 0.0 + rows.append({ + "symbol_id": sym, + "symbol_name": GENERATED_SYMBOL_NAMES[sym], + "date": date, + "open": open_price, + "high": high, + "low": low, + "close": close, + "preclose": preclose, + "volume": volume, + "amount": amount, + "vwap": vwap, + "turn": volume / 1_000_000.0, + "pctChg": pct_chg, + "tradestatus": tradestatus, + "isST": is_st, + "peTTM": 8.0 + i, + "pbMRQ": 1.0 + 0.05 * i, + "psTTM": 2.0 + 0.03 * i, + "pcfNcfTTM": 5.0 + 0.1 * i, + }) + + result = pd.DataFrame(rows) + if include_missing and n_sessions > 6: + missing_mask = ( + (result["symbol_id"] == "sz300750") + & (result["date"] == dates[6]) + ) + result = result.loc[~missing_mask].copy() + + result = result[DATA_COLUMNS] + return result.sort_values(["date", "symbol_id"]).reset_index(drop=True) + + +def make_generated_minute_bars( + daily: pd.DataFrame | None = None, +) -> pd.DataFrame: + """Expand generated daily bars into a tiny deterministic intraday panel.""" + daily = make_generated_daily_bars() if daily is None else daily.copy() + rows: list[dict[str, object]] = [] + bar_times = ("09:35:00", "10:30:00", "14:55:00") + + for daily_row in daily.sort_values(["date", "symbol_id"]).itertuples(index=False): + if int(getattr(daily_row, "tradestatus", 1)) == 0: + continue + + volume = float(daily_row.volume) + volume_slices = [0.25 * volume, 0.35 * volume, 0.40 * volume] + prices = np.linspace(float(daily_row.open), float(daily_row.close), len(bar_times)) + for j, time_text in enumerate(bar_times): + dt = pd.Timestamp(daily_row.date) + pd.Timedelta(time_text) + open_price = prices[j - 1] if j else float(daily_row.open) + close_price = float(prices[j]) + high = max(open_price, close_price) * 1.002 + low = min(open_price, close_price) * 0.998 + minute_volume = float(volume_slices[j]) + amount = minute_volume * ((open_price + close_price) / 2.0) + rows.append({ + "symbol_id": daily_row.symbol_id, + "symbol_name": daily_row.symbol_name, + "datetime": dt, + "date": pd.Timestamp(daily_row.date).normalize(), + "time": time_text, + "frequency": "5m", + "open": open_price, + "high": high, + "low": low, + "close": close_price, + "volume": minute_volume, + "amount": amount, + "vwap": amount / minute_volume if minute_volume > 0 else np.nan, + "adjustflag": "3", + }) + + return pd.DataFrame(rows, columns=MINUTE_BAR_COLUMNS) + + +def make_generated_derived_features( + daily: pd.DataFrame | None = None, +) -> pd.DataFrame: + """Return numeric daily derived values, including NaN and infinity cells.""" + daily = make_generated_daily_bars() if daily is None else daily.copy() + keys = ( + daily[["symbol_id", "date"]] + .drop_duplicates() + .sort_values(["date", "symbol_id"]) + .reset_index(drop=True) + ) + date_rank = keys["date"].rank(method="dense").astype(float) + symbol_rank = keys["symbol_id"].map({ + "sh600000": 1.0, + "sz000001": 2.0, + "sh600519": 3.0, + "sz300750": 4.0, + }) + out = keys.copy() + out["toy_feature"] = symbol_rank + date_rank / 100.0 + out["finite_feature"] = symbol_rank * date_rank + out["nan_feature"] = out["toy_feature"] + out["inf_feature"] = out["toy_feature"] + if len(out) >= 2: + out.loc[out.index[0], "nan_feature"] = np.nan + out.loc[out.index[1], "inf_feature"] = np.inf + return out + + +def make_generated_alpha_weights( + alpha_name: str = "alpha_a", + *, + scale: float = 1.0, + offset: float = 0.0, + zero_date_index: int | None = None, + n_sessions: int = 10, +) -> pd.DataFrame: + """Create a deterministic long alpha grid with optional zero-gross date.""" + dates = generated_sessions(n_sessions) + even = np.array([1.20, -0.80, 0.40, -0.80], dtype=float) + odd = np.array([-0.60, 1.10, -0.90, 0.40], dtype=float) + rows: list[dict[str, object]] = [] + for i, date in enumerate(dates): + vector = even.copy() if i % 2 == 0 else odd.copy() + vector = vector + offset + vector = vector - vector.mean() + if zero_date_index is not None and i == zero_date_index: + vector = np.zeros_like(vector) + for sym, weight in zip(GENERATED_SYMBOLS, scale * vector): + rows.append({ + "symbol_id": sym, + "date": date, + "alpha_name": alpha_name, + "weight": float(weight), + }) + result = pd.DataFrame(rows, columns=ALPHA_COLUMNS) + return result.sort_values(["symbol_id", "date"]).reset_index(drop=True) + + +def make_generated_combo_weights( + combo_name: str = "combo", + *, + zero_date_index: int | None = 2, + n_sessions: int = 10, +) -> pd.DataFrame: + """Create deterministic combo weights for portfolio construction tests.""" + alpha = make_generated_alpha_weights( + "combo_source", + zero_date_index=zero_date_index, + n_sessions=n_sessions, + ) + combo = alpha.rename(columns={"alpha_name": "combo_name"}).copy() + combo["combo_name"] = combo_name + return combo[COMBO_COLUMNS].sort_values(["symbol_id", "date"]).reset_index(drop=True) diff --git a/tests/test_alpha.py b/tests/test_alpha.py index 08eb155..4ac21f2 100644 --- a/tests/test_alpha.py +++ b/tests/test_alpha.py @@ -413,3 +413,92 @@ def test_feature_aware_alpha_reads_joined_feature_column(tmp_path): assert (result["alpha_name"] == "feature_run").all() last = result[result["date"] == result["date"].max()] assert last.set_index("symbol_id")["weight"].idxmax() == "sh600519" + + +def test_feature_paths_join_multiple_files_and_normalize_dates(tmp_path): + module_path = tmp_path / "multi_feature_alpha.py" + module_path.write_text(textwrap.dedent(''' + import pandas as pd + from pipeline.alpha.base import BaseAlpha + from pipeline.alpha.registry import register_alpha + + @register_alpha + class MultiFeatureAlpha(BaseAlpha): + name = "multi_feature_test_alpha" + + def signal_from_data( + self, + data: pd.DataFrame, + close: pd.DataFrame, + ) -> pd.DataFrame: + data = data.copy() + data["combined_feature"] = data["toy_a"] + data["toy_b"] + signal = data.pivot_table( + index="date", + columns="symbol_id", + values="combined_feature", + aggfunc="first", + ) + return signal.reindex(index=close.index, columns=close.columns) + ''')) + + data = _make_data(n_days=8) + symbol_score = {"sh600000": 1.0, "sz000001": 2.0, "sh600519": 3.0} + + feature_a = data[["symbol_id", "date"]].copy() + feature_a["date"] = feature_a["date"] + pd.Timedelta(hours=15) + feature_a["toy_a"] = feature_a["symbol_id"].map(symbol_score) + + feature_b = data[["symbol_id", "date"]].copy() + feature_b["date"] = feature_b["date"].dt.strftime("%Y-%m-%d 09:30:00") + feature_b["toy_b"] = feature_b["symbol_id"].map(symbol_score) * 10.0 + + feature_a_path = tmp_path / "toy_a.pq" + feature_b_path = tmp_path / "toy_b.pq" + feature_a.to_parquet(feature_a_path, index=False) + feature_b.to_parquet(feature_b_path, index=False) + + load_alpha_module(str(module_path)) + result = compute_alpha( + data, + "multi_feature_run", + "multi_feature_test_alpha", + feature_paths=[str(feature_a_path), str(feature_b_path)], + ) + + assert list(result.columns) == ALPHA_COLUMNS + assert (result["alpha_name"] == "multi_feature_run").all() + last = result[result["date"] == result["date"].max()] + assert last.set_index("symbol_id")["weight"].idxmax() == "sh600519" + + +def test_compute_alpha_rejects_duplicate_feature_frame_columns(): + data = _make_data() + duplicate_columns = pd.DataFrame( + [["sh600000", pd.Timestamp("2024-01-01"), 1.0, 2.0]], + columns=["symbol_id", "date", "toy_feature", "toy_feature"], + ) + + with pytest.raises(ValueError, match="duplicate columns"): + compute_alpha( + data, + "bad_features", + "reversal", + feature_frames=[duplicate_columns], + ) + + +def test_compute_alpha_rejects_feature_path_collision_with_daily_data(tmp_path): + data = _make_data() + close_collision = data[["symbol_id", "date"]].copy() + close_collision["close"] = 1.0 + close_collision_path = tmp_path / "close_collision.pq" + close_collision.to_parquet(close_collision_path, index=False) + + with pytest.raises(ValueError, match="conflict"): + compute_alpha( + data, + "close_collision", + "reversal", + feature_paths=[str(close_collision_path)], + ) diff --git a/tests/test_cli_workflow.py b/tests/test_cli_workflow.py new file mode 100644 index 0000000..e7d6b0a --- /dev/null +++ b/tests/test_cli_workflow.py @@ -0,0 +1,427 @@ +"""CLI handoff tests for the offline daily workflow.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pandas as pd +from click.testing import CliRunner + +from cli import cli +from tests.helpers import ( + make_generated_daily_bars, + make_generated_derived_features, + make_generated_minute_bars, +) + + +FIXTURE_PATH = Path(__file__).parent / "fixtures" / "daily_bars_real_2024_01_sample.pq" + + +def _invoke_ok(runner: CliRunner, args: list[str]): + result = runner.invoke(cli, args) + assert result.exit_code == 0, result.output + return result + + +def _invoke_error(runner: CliRunner, args: list[str]): + result = runner.invoke(cli, args) + assert result.exit_code != 0, result.output + return result + + +def test_cli_daily_workflow_handoffs_stay_in_tmp_path(tmp_path): + runner = CliRunner() + daily_bars = make_generated_daily_bars() + minute_bars = make_generated_minute_bars(daily_bars) + derived_features = make_generated_derived_features(daily_bars) + + daily_path = tmp_path / "daily_bars.pq" + minute_path = tmp_path / "minute_bars.pq" + derived_input_path = tmp_path / "derived_input.pq" + daily_bars.to_parquet(daily_path, index=False) + minute_bars.to_parquet(minute_path, index=False) + derived_features.to_parquet(derived_input_path, index=False) + + ingest_dir = tmp_path / "derived_ingested" + ingest_result = _invoke_ok(runner, [ + "derived", "ingest", + "--input-path", str(derived_input_path), + "--derived-name", "toy_features", + "--output-dir", str(ingest_dir), + ]) + ingested_feature_path = ingest_dir / "toy_features.pq" + assert "Saved derived data:" in ingest_result.output + assert ingested_feature_path.exists() + + validate_result = _invoke_ok(runner, [ + "derived", "validate", + "--input-path", str(ingested_feature_path), + ]) + assert "Valid derived data:" in validate_result.output + assert "rows" in validate_result.output + + computed_derived_dir = tmp_path / "derived_computed" + derived_compute_result = _invoke_ok(runner, [ + "derived", "compute", + "--daily-path", str(daily_path), + "--minute-path", str(minute_path), + "--derived-type", "minute_daily_summary", + "--derived-name", "minute_summary", + "--output-dir", str(computed_derived_dir), + ]) + minute_summary_path = computed_derived_dir / "minute_summary.pq" + assert "Loaded daily data:" in derived_compute_result.output + assert "Loaded minute bars:" in derived_compute_result.output + assert "Saved derived data:" in derived_compute_result.output + assert minute_summary_path.exists() + assert "minute_vwap" in pd.read_parquet(minute_summary_path).columns + + alpha_module_path = tmp_path / "cli_feature_alpha.py" + alpha_module_path.write_text(textwrap.dedent(""" + import pandas as pd + from pipeline.alpha.base import BaseAlpha + from pipeline.alpha.registry import register_alpha + + @register_alpha + class CliFeatureAlpha(BaseAlpha): + name = "cli_feature_alpha_workflow" + + def __init__(self, **kwargs): + self.kwargs = kwargs + + def signal_from_data( + self, + data: pd.DataFrame, + close: pd.DataFrame, + ) -> pd.DataFrame: + signal = data.pivot_table( + index="date", + columns="symbol_id", + values="minute_intraday_return", + aggfunc="first", + ) + fallback = close.pct_change(1, fill_method=None) + feature_signal = signal.reindex(index=close.index, columns=close.columns) + toy_signal = data.pivot_table( + index="date", + columns="symbol_id", + values="toy_feature", + aggfunc="first", + ) + toy_signal = toy_signal.reindex(index=close.index, columns=close.columns) + return feature_signal.fillna(fallback) + toy_signal / 1000.0 + """)) + + alpha_dir = tmp_path / "alphas" + alpha_result = _invoke_ok(runner, [ + "alpha", "compute", + "--data-path", str(daily_path), + "--feature-path", str(minute_summary_path), + "--feature-path", str(ingested_feature_path), + "--alpha-module", str(alpha_module_path), + "--alpha-type", "cli_feature_alpha_workflow", + "--alpha-name", "cli_feature_alpha", + "--output-dir", str(alpha_dir), + ]) + alpha_path = alpha_dir / "cli_feature_alpha.pq" + assert "Loaded data:" in alpha_result.output + assert "Saved alpha:" in alpha_result.output + assert "Weight stats" in alpha_result.output + assert alpha_path.exists() + assert not pd.read_parquet(alpha_path).empty + + alpha_report_dir = tmp_path / "alpha_reports" + alpha_eval_result = _invoke_ok(runner, [ + "alpha", "eval", + "--alpha-path", str(alpha_path), + "--data-path", str(daily_path), + "--report-dir", str(alpha_report_dir), + ]) + alpha_report_path = alpha_report_dir / "cli_feature_alpha_eval.json" + assert "ALPHA EVALUATION" in alpha_eval_result.output + assert "Report saved:" in alpha_eval_result.output + assert alpha_report_path.exists() + + combo_dir = tmp_path / "combos" + combo_result = _invoke_ok(runner, [ + "combo", "combine", + "--alpha-paths", f"{alpha_path},{alpha_path}", + "--combo-name", "cli_combo", + "--method", "equal_weight", + "--output-dir", str(combo_dir), + ]) + combo_path = combo_dir / "cli_combo.pq" + assert "Saved combo:" in combo_result.output + assert "Weight stats" in combo_result.output + assert combo_path.exists() + + portfolio_dir = tmp_path / "portfolio" + build_result = _invoke_ok(runner, [ + "portfolio", "build", + "--weights-path", str(combo_path), + "--data-path", str(daily_path), + "--booksize", "2000000", + "--portfolio-name", "cli_portfolio", + "--output-dir", str(portfolio_dir), + ]) + positions_path = portfolio_dir / "cli_portfolio.pq" + assert "Saved positions:" in build_result.output + assert "Gross exposure" in build_result.output + assert positions_path.exists() + + execution_dir = tmp_path / "execution" + simulate_result = _invoke_ok(runner, [ + "portfolio", "simulate", + "--positions-path", str(positions_path), + "--data-path", str(daily_path), + "--constraint", "suspension", + "--constraint", "price_limit", + "--constraint", "volume_cap", + "--cost-bps", "5", + "--slippage-bps", "5", + "--volume-frac", "0.02", + "--output-dir", str(execution_dir), + ]) + fills_path = execution_dir / "fills" / "cli_portfolio.pq" + pnl_path = execution_dir / "pnl" / "cli_portfolio.pq" + assert "Saved fills:" in simulate_result.output + assert "Saved pnl:" in simulate_result.output + assert "Total PnL:" in simulate_result.output + assert fills_path.exists() + assert pnl_path.exists() + + eval_result = _invoke_ok(runner, [ + "portfolio", "eval", + "--positions-path", str(positions_path), + "--data-path", str(daily_path), + ]) + assert "Research-portfolio metrics:" in eval_result.output + assert "cumulative_return" in eval_result.output + assert "fitness" in eval_result.output + + pqcat_result = _invoke_ok(runner, [ + "pqcat", + str(positions_path), + "--info", + ]) + assert "shape:" in pqcat_result.output + assert "dtypes:" in pqcat_result.output + assert "position_shares" in pqcat_result.output + + alphaview_result = _invoke_ok(runner, [ + "alphaview", + "--data-path", str(daily_path), + "--alpha-path", str(alpha_path), + "--symbol", "sh600000", + "--start-date", "2024-01-02", + "--end-date", "2024-01-12", + "--columns", "close,volume", + ]) + assert "symbol: sh600000" in alphaview_result.output + assert "cli_feature_alpha" in alphaview_result.output + + +def test_cli_pipeline_accepts_partitioned_daily_dataset(tmp_path): + runner = CliRunner() + daily_bars = make_generated_daily_bars(include_missing=False) + dataset_dir = tmp_path / "daily_dataset" + dataset_frame = daily_bars.copy() + dataset_frame["month"] = dataset_frame["date"].dt.strftime("%Y-%m") + dataset_frame.to_parquet(dataset_dir, partition_cols=["month"], index=False) + + alpha_dir = tmp_path / "alphas" + alpha_result = _invoke_ok(runner, [ + "alpha", "compute", + "--data-path", str(dataset_dir), + "--alpha-type", "reversal", + "--alpha-name", "dataset_reversal", + "--lookback", "3", + "--output-dir", str(alpha_dir), + ]) + alpha_path = alpha_dir / "dataset_reversal.pq" + assert "Loaded data:" in alpha_result.output + assert alpha_path.exists() + + combo_dir = tmp_path / "combos" + _invoke_ok(runner, [ + "combo", "combine", + "--alpha-paths", str(alpha_path), + "--combo-name", "dataset_combo", + "--output-dir", str(combo_dir), + ]) + combo_path = combo_dir / "dataset_combo.pq" + assert combo_path.exists() + + portfolio_dir = tmp_path / "portfolio" + _invoke_ok(runner, [ + "portfolio", "build", + "--weights-path", str(combo_path), + "--data-path", str(dataset_dir), + "--booksize", "1000000", + "--portfolio-name", "dataset_portfolio", + "--output-dir", str(portfolio_dir), + ]) + positions_path = portfolio_dir / "dataset_portfolio.pq" + assert positions_path.exists() + + execution_dir = tmp_path / "execution" + simulate_result = _invoke_ok(runner, [ + "portfolio", "simulate", + "--positions-path", str(positions_path), + "--data-path", str(dataset_dir), + "--constraint", "suspension", + "--output-dir", str(execution_dir), + ]) + assert "Saved fills:" in simulate_result.output + assert (execution_dir / "fills" / "dataset_portfolio.pq").exists() + assert (execution_dir / "pnl" / "dataset_portfolio.pq").exists() + + +def test_cli_liquid_universe_masks_to_top_liquid_names(tmp_path): + runner = CliRunner() + daily_bars = make_generated_daily_bars(n_sessions=75, include_missing=False) + daily_path = tmp_path / "daily_bars_75d.pq" + daily_bars.to_parquet(daily_path, index=False) + + alpha_dir = tmp_path / "alphas" + result = _invoke_ok(runner, [ + "alpha", "compute", + "--data-path", str(daily_path), + "--alpha-type", "reversal_rank", + "--alpha-name", "liquid_rank", + "--lookback", "3", + "--liquid-universe", + "--universe-top-n", "2", + "--output-dir", str(alpha_dir), + ]) + + alpha_path = alpha_dir / "liquid_rank.pq" + alpha = pd.read_parquet(alpha_path) + nonzero = alpha[alpha["weight"] != 0.0] + assert "Saved alpha:" in result.output + assert alpha_path.exists() + assert not nonzero.empty + assert nonzero.groupby("date")["symbol_id"].nunique().max() <= 2 + + +def test_cli_real_fixture_round_trips_through_portfolio(tmp_path): + runner = CliRunner() + + alpha_dir = tmp_path / "alphas" + _invoke_ok(runner, [ + "alpha", "compute", + "--data-path", str(FIXTURE_PATH), + "--alpha-type", "reversal_vol", + "--alpha-name", "real_cli_reversal_vol", + "--lookback", "3", + "--vol-window", "3", + "--output-dir", str(alpha_dir), + ]) + alpha_path = alpha_dir / "real_cli_reversal_vol.pq" + assert alpha_path.exists() + assert not pd.read_parquet(alpha_path).empty + + combo_dir = tmp_path / "combos" + _invoke_ok(runner, [ + "combo", "combine", + "--alpha-paths", str(alpha_path), + "--combo-name", "real_cli_combo", + "--output-dir", str(combo_dir), + ]) + combo_path = combo_dir / "real_cli_combo.pq" + assert combo_path.exists() + + portfolio_dir = tmp_path / "portfolio" + _invoke_ok(runner, [ + "portfolio", "build", + "--weights-path", str(combo_path), + "--data-path", str(FIXTURE_PATH), + "--booksize", "1000000", + "--portfolio-name", "real_cli_portfolio", + "--output-dir", str(portfolio_dir), + ]) + positions_path = portfolio_dir / "real_cli_portfolio.pq" + positions = pd.read_parquet(positions_path) + assert not positions.empty + + eval_result = _invoke_ok(runner, [ + "portfolio", "eval", + "--positions-path", str(positions_path), + "--data-path", str(FIXTURE_PATH), + ]) + assert "Research-portfolio metrics:" in eval_result.output + + +def test_cli_error_paths_are_clear_for_bad_user_inputs(tmp_path): + runner = CliRunner() + daily_bars = make_generated_daily_bars() + daily_path = tmp_path / "daily_bars.pq" + daily_bars.to_parquet(daily_path, index=False) + + unknown_alpha = _invoke_error(runner, [ + "alpha", "compute", + "--data-path", str(daily_path), + "--alpha-type", "does_not_exist", + "--alpha-name", "bad", + "--output-dir", str(tmp_path / "alphas"), + ]) + assert "Unknown alpha-type" in unknown_alpha.output + + malformed_param = _invoke_error(runner, [ + "alpha", "compute", + "--data-path", str(daily_path), + "--alpha-type", "reversal", + "--alpha-name", "bad_param", + "--param", "not-an-assignment", + "--output-dir", str(tmp_path / "alphas"), + ]) + assert "--param must be name=value" in malformed_param.output + + unknown_derived = _invoke_error(runner, [ + "derived", "compute", + "--daily-path", str(daily_path), + "--derived-type", "does_not_exist", + "--derived-name", "bad", + "--output-dir", str(tmp_path / "derived"), + ]) + assert "Unknown derived-type" in unknown_derived.output + + bad_constraint_positions = pd.DataFrame({ + "symbol_id": ["sh600000"], + "date": [pd.Timestamp("2024-01-02")], + "portfolio_name": ["bad_constraint"], + "target_weight": [1.0], + "target_value": [1000.0], + "target_shares": [100.0], + "position_shares": [100], + "position_value": [1000.0], + "price": [10.0], + }) + positions_path = tmp_path / "positions.pq" + bad_constraint_positions.to_parquet(positions_path, index=False) + unknown_constraint = _invoke_error(runner, [ + "portfolio", "simulate", + "--positions-path", str(positions_path), + "--data-path", str(daily_path), + "--constraint", "not_a_constraint", + "--output-dir", str(tmp_path / "execution"), + ]) + assert isinstance(unknown_constraint.exception, KeyError) + assert "not_a_constraint" in str(unknown_constraint.exception) + + pqcat_missing_column = _invoke_error(runner, [ + "pqcat", + str(daily_path), + "--columns", "close,not_a_column", + ]) + assert "Columns not found: not_a_column" in pqcat_missing_column.output + + alphaview_missing_symbol = _invoke_error(runner, [ + "alphaview", + "--data-path", str(daily_path), + "--alpha-path", str(positions_path), + "--symbol", "sh999999", + ]) + assert "Symbol 'sh999999' not found" in alphaview_missing_symbol.output diff --git a/tests/test_downloader.py b/tests/test_downloader.py index a472262..0a85984 100644 --- a/tests/test_downloader.py +++ b/tests/test_downloader.py @@ -1,6 +1,16 @@ +import os + import pytest from data.downloader import download_daily +pytestmark = [ + pytest.mark.network, + pytest.mark.skipif( + os.environ.get("CEQ_RUN_LIVE_DOWNLOADER") != "1", + reason="set CEQ_RUN_LIVE_DOWNLOADER=1 to run live baostock/akshare smoke tests", + ), +] + def test_download_single_stock(): """Smoke test: download data for 浦发银行 for a short window.""" diff --git a/tests/test_downloader_contracts.py b/tests/test_downloader_contracts.py new file mode 100644 index 0000000..5797e81 --- /dev/null +++ b/tests/test_downloader_contracts.py @@ -0,0 +1,306 @@ +"""Offline downloader contract tests with mocked data providers.""" + +from __future__ import annotations + +import numpy as np +import pandas as pd + +import data.downloader as downloader +import pipeline.data.downloader as pipeline_downloader +from data.downloader import download_daily, download_daily_batch +from pipeline.common.schema import DATA_COLUMNS +from pipeline.data.downloader import download_universe + + +class _FakeResult: + def __init__(self, rows, error_code="0", error_msg=""): + self.rows = rows + self.error_code = error_code + self.error_msg = error_msg + self._idx = -1 + + def next(self): + self._idx += 1 + return self._idx < len(self.rows) + + def get_row_data(self): + return self.rows[self._idx] + + +def _daily_batch_row( + date: str = "2024-01-02", + open_: str = "10", + high: str = "11", + low: str = "9", + close: str = "10.5", + preclose: str = "10", + volume: str = "1000", + amount: str = "10500", +) -> list[str]: + return [ + date, + open_, + high, + low, + close, + preclose, + volume, + amount, + "1.23", + "5.0", + "1", + "0", + "8.0", + "1.1", + "2.2", + "3.3", + ] + + +def test_download_daily_uses_baostock_before_akshare_in_auto(monkeypatch): + calls: list[str] = [] + expected = pd.DataFrame({ + "symbol": ["sh600000"], + "date": ["2024-01-02"], + "open": [10.0], + "high": [11.0], + "low": [9.0], + "close": [10.5], + "volume": [1000.0], + "amount": [10500.0], + }) + + def fake_baostock(symbol, start, end, adjust): + calls.append("baostock") + return expected + + def fake_akshare(symbol, start, end, adjust): + calls.append("akshare") + raise AssertionError("akshare should not be called after baostock succeeds") + + monkeypatch.setattr(downloader, "_download_baostock", fake_baostock) + monkeypatch.setattr(downloader, "_download_akshare", fake_akshare) + + result = download_daily("sh600000", "2024-01-02", "2024-01-02", source="auto") + + assert calls == ["baostock"] + assert result["date"].tolist() == [pd.Timestamp("2024-01-02")] + assert result["close"].tolist() == [10.5] + + +def test_download_daily_falls_back_to_akshare_when_baostock_empty(monkeypatch): + calls: list[str] = [] + fallback = pd.DataFrame({ + "symbol": ["sz000001"], + "date": ["2024-01-02"], + "open": [20.0], + "high": [21.0], + "low": [19.0], + "close": [20.5], + "volume": [2000.0], + "amount": [41000.0], + }) + + monkeypatch.setattr( + downloader, + "_download_baostock", + lambda symbol, start, end, adjust: calls.append("baostock") or None, + ) + monkeypatch.setattr( + downloader, + "_download_akshare", + lambda symbol, start, end, adjust: calls.append("akshare") or fallback, + ) + + result = download_daily("sz000001", "2024-01-02", "2024-01-02", source="auto") + + assert calls == ["baostock", "akshare"] + assert result["symbol"].tolist() == ["sz000001"] + assert result["date"].tolist() == [pd.Timestamp("2024-01-02")] + + +def test_download_daily_batch_maps_rich_schema_and_vwap(monkeypatch): + query_calls: list[dict] = [] + login_count = 0 + logout_count = 0 + + def fake_login(): + nonlocal login_count + login_count += 1 + + def fake_logout(): + nonlocal logout_count + logout_count += 1 + + def fake_query(**kwargs): + query_calls.append(kwargs) + rows = [ + _daily_batch_row(volume="1000", amount="10500"), + _daily_batch_row(date="2024-01-03", volume="0", amount="0"), + ] + return _FakeResult(rows) + + monkeypatch.setattr(downloader.bs, "login", fake_login) + monkeypatch.setattr(downloader.bs, "logout", fake_logout) + monkeypatch.setattr(downloader.bs, "query_history_k_data_plus", fake_query) + + [(symbol, frame)] = list( + download_daily_batch( + ["sh600000"], + "2024-01-02", + "2024-01-03", + adjust="hfq", + ) + ) + + assert symbol == "sh600000" + assert query_calls[0]["code"] == "sh.600000" + assert query_calls[0]["adjustflag"] == "1" + assert login_count == 1 + assert logout_count == 1 + assert frame is not None + assert frame.columns.tolist() == [ + "symbol", "date", "open", "high", "low", "close", "preclose", + "volume", "amount", "vwap", "turn", "pctChg", "tradestatus", "isST", + "peTTM", "pbMRQ", "psTTM", "pcfNcfTTM", + ] + assert np.isclose(frame["vwap"].iloc[0], 10.5) + assert pd.isna(frame["vwap"].iloc[1]) + assert pd.api.types.is_datetime64_any_dtype(frame["date"]) + assert pd.api.types.is_numeric_dtype(frame["tradestatus"]) + + +def test_download_daily_batch_relogs_and_retries_session_loss(monkeypatch): + responses = [ + _FakeResult([], error_code="10002007", error_msg="用户未登录"), + _FakeResult([_daily_batch_row()]), + ] + login_count = 0 + logout_count = 0 + + def fake_login(): + nonlocal login_count + login_count += 1 + + def fake_logout(): + nonlocal logout_count + logout_count += 1 + + monkeypatch.setattr(downloader.bs, "login", fake_login) + monkeypatch.setattr(downloader.bs, "logout", fake_logout) + monkeypatch.setattr( + downloader.bs, + "query_history_k_data_plus", + lambda **kwargs: responses.pop(0), + ) + + [(symbol, frame)] = list(download_daily_batch(["sh600000"], "2024-01-02", "2024-01-02")) + + assert symbol == "sh600000" + assert frame is not None + assert len(frame) == 1 + assert login_count == 2 + assert logout_count == 2 + + +def test_download_daily_batch_uses_akshare_fallback_when_enabled(monkeypatch): + fallback = pd.DataFrame({ + "symbol": ["sh600000"], + "date": ["2024-01-02"], + "open": [10.0], + "high": [11.0], + "low": [9.0], + "close": [10.5], + "volume": [1000.0], + "amount": [10500.0], + }) + + monkeypatch.setattr(downloader.bs, "login", lambda: None) + monkeypatch.setattr(downloader.bs, "logout", lambda: None) + monkeypatch.setattr( + downloader.bs, + "query_history_k_data_plus", + lambda **kwargs: _FakeResult([], error_code="1", error_msg="no data"), + ) + monkeypatch.setattr( + downloader, + "_download_akshare", + lambda symbol, start, end, adjust: fallback.copy(), + ) + + [(symbol, frame)] = list( + download_daily_batch( + ["sh600000"], + "2024-01-02", + "2024-01-02", + akshare_fallback=True, + ) + ) + + assert symbol == "sh600000" + assert frame is not None + assert frame["date"].tolist() == [pd.Timestamp("2024-01-02")] + assert frame["close"].tolist() == [10.5] + + +def test_download_universe_writes_daily_partitions_from_mock_batch(tmp_path, monkeypatch): + batch_frame = pd.DataFrame({ + "symbol": ["sh600000", "sh600000"], + "date": pd.to_datetime(["2024-01-02", "2024-02-01"]), + "open": [10.0, 11.0], + "high": [11.0, 12.0], + "low": [9.0, 10.0], + "close": [10.5, 11.5], + "preclose": [10.0, 10.5], + "volume": [1000.0, 1200.0], + "amount": [10500.0, 13800.0], + "vwap": [10.5, 11.5], + "turn": [1.0, 1.1], + "pctChg": [5.0, 9.5], + "tradestatus": [1, 1], + "isST": [0, 0], + "peTTM": [8.0, 8.1], + "pbMRQ": [1.1, 1.2], + "psTTM": [2.1, 2.2], + "pcfNcfTTM": [3.1, 3.2], + }) + + monkeypatch.setattr( + pipeline_downloader, + "_resolve_universe", + lambda universe, max_symbols=0: pd.DataFrame({ + "symbol_id": ["sh600000", "sz000001"], + "symbol_name": ["PF Bank", "Ping An Bank"], + }), + ) + + def fake_batch(symbols, start, end, adjust="qfq"): + assert symbols == ["sh600000", "sz000001"] + assert adjust == "qfq" + yield "sh600000", batch_frame + yield "sz000001", None + + monkeypatch.setattr(pipeline_downloader, "download_daily_batch", fake_batch) + + stats = download_universe( + universe="toy", + start_date="2024-01-02", + end_date="2024-02-01", + output_dir=str(tmp_path), + chunk_size=1, + ) + + dataset_path = tmp_path / "toy" + written = pd.read_parquet(dataset_path).sort_values(["date", "symbol_id"]).reset_index(drop=True) + assert stats == { + "dataset_path": str(dataset_path), + "n_symbols": 1, + "n_requested": 2, + "n_rows": 2, + "date_min": "2024-01-02", + "date_max": "2024-02-01", + } + assert (dataset_path / "month=2024-01").exists() + assert (dataset_path / "month=2024-02").exists() + assert written[DATA_COLUMNS].columns.tolist() == DATA_COLUMNS + assert written["symbol_name"].tolist() == ["PF Bank", "PF Bank"] diff --git a/tests/test_features.py b/tests/test_features.py index f572b2d..248ee3d 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -13,6 +13,7 @@ from pipeline.features.registry import ( get_feature, load_feature_module, ) +from pipeline.derived.compute import compute_derived def _minute_bars() -> pd.DataFrame: @@ -77,6 +78,27 @@ def test_minute_daily_summary_feature_preserves_legacy_positional_compute(): pd.testing.assert_frame_equal(direct, via_registry) +def test_legacy_feature_compute_matches_canonical_derived_compute(): + daily = pd.DataFrame({ + "symbol_id": ["sh600000", "sz000001", "sh600000"], + "date": pd.to_datetime(["2024-01-02", "2024-01-02", "2024-01-03"]), + "close": [11.0, 20.5, 12.0], + }) + + legacy_feature = compute_feature( + minute=_minute_bars(), + daily=daily, + feature_type="minute_daily_summary", + ) + canonical_derived = compute_derived( + "minute_daily_summary", + daily=daily, + minute=_minute_bars(), + ) + + pd.testing.assert_frame_equal(legacy_feature, canonical_derived) + + def test_load_external_feature_module_and_filter_params(tmp_path): module_path = tmp_path / "external_feature.py" module_path.write_text(textwrap.dedent(''' diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index 9157fd1..7b3999b 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -306,6 +306,47 @@ def test_construct_positions_schema(): assert pos["position_shares"].dtype == np.int64 +def test_construct_positions_empty_weights_returns_schema(): + data = _make_data(n_days=3) + empty_weights = pd.DataFrame(columns=["symbol_id", "date", "combo_name", "weight"]) + + pos = construct_positions( + empty_weights, + data, + booksize=1e6, + portfolio_name="empty", + ) + + assert list(pos.columns) == POSITION_COLUMNS + assert pos.empty + + +def test_construct_positions_ignores_absent_or_bad_prices(): + dates = pd.to_datetime(["2024-01-01", "2024-01-02"]) + data = pd.DataFrame([ + {"symbol_id": "sh600000", "date": dates[0], "close": np.nan, "isST": 0}, + {"symbol_id": "sz000001", "date": dates[0], "close": 20.0, "isST": 0}, + {"symbol_id": "sh600000", "date": dates[1], "close": 10.0, "isST": 0}, + {"symbol_id": "sz000001", "date": dates[1], "close": 20.0, "isST": 0}, + ]) + weights = pd.DataFrame({ + "symbol_id": ["sh600000", "sz000001", "sh600000", "sz000001"], + "date": [dates[0], dates[0], dates[1], dates[1]], + "combo_name": ["combo"] * 4, + "weight": [1.0, -1.0, 1.0, -1.0], + }) + + pos = construct_positions(weights, data, booksize=10000.0, portfolio_name="bad_price") + + bad_price_rows = pos[ + (pos["date"] == dates[0]) + & (pos["symbol_id"] == "sh600000") + ] + assert bad_price_rows.empty or (bad_price_rows["target_weight"] == 0.0).all() + assert np.isfinite(pos["target_value"]).all() + assert np.isfinite(pos["position_value"]).all() + + def test_construct_positions_threads_state_and_closes_absent(): data = _make_data() weights = _make_weights(data) @@ -321,6 +362,34 @@ def test_construct_positions_threads_state_and_closes_absent(): assert final.empty or (final["position_shares"] == 0).all() +def test_construct_positions_closes_absent_short_position(): + dates = pd.to_datetime(["2024-01-02", "2024-01-03"]) + data = pd.DataFrame([ + {"symbol_id": sym, "date": d, "close": price, "isST": 0} + for d in dates + for sym, price in (("sh600000", 10.0), ("sz000001", 20.0)) + ]) + weights = pd.DataFrame({ + "symbol_id": ["sh600000", "sz000001", "sz000001"], + "date": [dates[0], dates[0], dates[1]], + "combo_name": ["combo", "combo", "combo"], + "weight": [-1.0, 1.0, 1.0], + }) + + pos = construct_positions(weights, data, booksize=20000.0, portfolio_name="absent_short") + + first_day_short = pos[ + (pos["date"] == dates[0]) + & (pos["symbol_id"] == "sh600000") + ] + final_day_short = pos[ + (pos["date"] == dates[1]) + & (pos["symbol_id"] == "sh600000") + ] + assert (first_day_short["position_shares"] < 0).all() + assert final_day_short.empty or (final_day_short["position_shares"] == 0).all() + + def test_construct_positions_carries_book_on_zero_gross(caplog): dates = pd.to_datetime(["2024-01-01", "2024-01-02"]) symbols = ["sh600000", "sz000001"] @@ -392,6 +461,42 @@ def test_volume_cap_uses_traded_value(): assert low[0] == -10000.0 +def test_constraints_compose_repeatably_regardless_of_order(): + n = 1 + sl = _slice( + n, + tradestatus=np.array([0.0]), + limit_status=np.array([LimitStatus.UP_LIMIT.value], dtype=np.int8), + amount=np.array([1_000.0]), + price=np.array([10.0]), + ) + ctx = TradeContext(np.zeros(n, np.int64), np.array([500]), sl, 1e6) + first_order = ReferenceSimulator( + constraints=[ + SuspensionConstraint(), + PriceLimitConstraint(), + VolumeCapConstraint(max_frac=0.1), + ], + cost_bps=10, + ).fill(ctx) + reversed_order = ReferenceSimulator( + constraints=[ + VolumeCapConstraint(max_frac=0.1), + PriceLimitConstraint(), + SuspensionConstraint(), + ], + cost_bps=10, + ).fill(ctx) + + assert first_order.traded_shares.tolist() == [0] + assert first_order.realized_shares.tolist() == [0] + assert first_order.blocked.tolist() == [1] + assert np.array_equal(first_order.traded_shares, reversed_order.traded_shares) + assert np.array_equal(first_order.realized_shares, reversed_order.realized_shares) + assert np.array_equal(first_order.blocked, reversed_order.blocked) + assert np.array_equal(first_order.cost, reversed_order.cost) + + # --- ReferenceSimulator ------------------------------------------------------ def test_simulator_next_open_and_blocked_buy_holds_prev(): @@ -474,6 +579,91 @@ def test_simulator_cost_only_on_nonzero_realized_trades(): assert np.isclose(res.cost[1], 50 * 20 * 10 / 1e4) +def test_simulator_short_to_long_flip_trades_full_delta(): + dates = pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"]) + positions = pd.DataFrame({ + "symbol_id": ["sh600000", "sh600000"], + "date": [dates[0], dates[1]], + "portfolio_name": ["flip", "flip"], + "target_weight": [-1.0, 1.0], + "target_value": [-1000.0, 1000.0], + "target_shares": [-100.0, 100.0], + "position_shares": [-100, 100], + "position_value": [-1000.0, 1000.0], + "price": [10.0, 10.0], + }) + data = pd.DataFrame({ + "symbol_id": ["sh600000"] * 3, + "date": dates, + "open": [10.0, 10.0, 10.0], + "close": [10.0, 10.0, 10.0], + "preclose": [10.0, 10.0, 10.0], + "amount": [1e9, 1e9, 1e9], + "tradestatus": [1, 1, 1], + "isST": [0, 0, 0], + }) + + fills, _ = ReferenceSimulator().run(positions, data) + + by_date = fills.set_index("date") + assert by_date.loc[dates[1], "traded_shares"] == -100 + assert by_date.loc[dates[1], "realized_shares"] == -100 + assert by_date.loc[dates[2], "prev_shares"] == -100 + assert by_date.loc[dates[2], "traded_shares"] == 200 + assert by_date.loc[dates[2], "realized_shares"] == 100 + + +def test_simulator_volume_cap_partially_fills_sell(): + sl = _slice(1, amount=np.array([10_000.0]), price=np.array([10.0])) + ctx = TradeContext( + np.array([1000], np.int64), + np.array([0], np.int64), + sl, + 1_000_000.0, + ) + + result = ReferenceSimulator( + constraints=[VolumeCapConstraint(max_frac=0.10)] + ).fill(ctx) + + assert result.traded_shares.tolist() == [-100] + assert result.realized_shares.tolist() == [900] + assert result.blocked.tolist() == [1] + + +def test_simulator_missing_next_open_has_zero_cost_and_turnover(): + dates = pd.to_datetime(["2024-01-01", "2024-01-02"]) + positions = pd.DataFrame({ + "symbol_id": ["sh600000"], + "date": [dates[0]], + "portfolio_name": ["missing_open"], + "target_weight": [1.0], + "target_value": [1000.0], + "target_shares": [100.0], + "position_shares": [100], + "position_value": [1000.0], + "price": [10.0], + }) + data = pd.DataFrame({ + "symbol_id": ["sh600000", "sh600000"], + "date": dates, + "open": [10.0, np.nan], + "close": [10.0, 10.0], + "preclose": [10.0, 10.0], + "amount": [1e9, 1e9], + "tradestatus": [1, 1], + "isST": [0, 0], + }) + + fills, pnl = ReferenceSimulator(cost_bps=10, slippage_bps=5).run(positions, data) + + assert fills["traded_shares"].iloc[0] == 100 + assert fills["trade_cost"].iloc[0] == 0.0 + assert pnl["cost"].iloc[0] == 0.0 + assert pnl["turnover"].iloc[0] == 0.0 + assert pnl["gross_exposure"].iloc[0] == 1000.0 + + def test_simple_cost_model_adds_cost_and_slippage_without_price_adjustment(): model = SimpleProportionalCostModel(cost_bps=10, slippage_bps=5) diff --git a/tests/test_schema_failures.py b/tests/test_schema_failures.py new file mode 100644 index 0000000..7741e92 --- /dev/null +++ b/tests/test_schema_failures.py @@ -0,0 +1,98 @@ +"""Malformed parquet/input tests for phase boundary contracts.""" + +from __future__ import annotations + +import pandas as pd +import pytest + +from pipeline.alpha.compute import compute_alpha +from pipeline.combo.combine import combine_alphas +from pipeline.derived.compute import validate_derived_frame +from pipeline.portfolio.construct import construct_positions +from pipeline.portfolio.simulator import ReferenceSimulator +from tests.helpers import make_generated_daily_bars + + +def test_alpha_compute_rejects_daily_data_without_close(): + daily = make_generated_daily_bars().drop(columns=["close"]) + + with pytest.raises(KeyError, match="close"): + compute_alpha(daily, "bad", "reversal", lookback=3) + + +def test_alpha_feature_path_rejects_duplicate_symbol_dates(tmp_path): + daily = make_generated_daily_bars() + feature = pd.DataFrame({ + "symbol_id": ["sh600000", "sh600000"], + "date": ["2024-01-02 09:30:00", "2024-01-02 15:00:00"], + "toy_feature": [1.0, 2.0], + }) + feature_path = tmp_path / "duplicate_feature_keys.pq" + feature.to_parquet(feature_path, index=False) + + with pytest.raises(ValueError, match="duplicate symbol_id,date"): + compute_alpha( + daily, + "bad_features", + "reversal", + lookback=3, + feature_paths=[str(feature_path)], + ) + + +def test_derived_validation_rejects_bool_value_columns(): + derived = pd.DataFrame({ + "symbol_id": ["sh600000"], + "date": [pd.Timestamp("2024-01-02")], + "is_good": [True], + }) + + with pytest.raises(ValueError, match="numeric"): + validate_derived_frame(derived) + + +def test_combo_combine_rejects_missing_weight_column(tmp_path): + bad_alpha = pd.DataFrame({ + "symbol_id": ["sh600000"], + "date": [pd.Timestamp("2024-01-02")], + "alpha_name": ["bad"], + }) + bad_alpha_path = tmp_path / "bad_alpha.pq" + bad_alpha.to_parquet(bad_alpha_path, index=False) + + with pytest.raises(KeyError, match="weight"): + combine_alphas([str(bad_alpha_path)], "bad_combo") + + +def test_portfolio_build_rejects_weights_without_symbol_id(): + daily = make_generated_daily_bars() + bad_weights = pd.DataFrame({ + "date": [pd.Timestamp("2024-01-02")], + "combo_name": ["bad"], + "weight": [1.0], + }) + + with pytest.raises(KeyError, match="symbol_id"): + construct_positions( + bad_weights, + daily, + booksize=1_000_000.0, + portfolio_name="bad_portfolio", + ) + + +def test_portfolio_simulate_rejects_positions_without_position_shares(): + daily = make_generated_daily_bars() + bad_positions = pd.DataFrame({ + "symbol_id": ["sh600000"], + "date": [pd.Timestamp("2024-01-02")], + "portfolio_name": ["bad"], + "target_weight": [1.0], + "target_value": [1000.0], + "target_shares": [100.0], + "position_value": [1000.0], + "price": [10.0], + }) + + with pytest.raises(KeyError, match="position_shares"): + ReferenceSimulator().run(bad_positions, daily) diff --git a/tests/test_workflow_sanity.py b/tests/test_workflow_sanity.py new file mode 100644 index 0000000..22ab6cf --- /dev/null +++ b/tests/test_workflow_sanity.py @@ -0,0 +1,508 @@ +"""Verbose offline checks for the daily research workflow.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pandas as pd + +from pipeline.alpha.compute import compute_alpha +from pipeline.combo.combine import combine_alphas +from pipeline.common.schema import ( + ALPHA_COLUMNS, + COMBO_COLUMNS, + FILL_COLUMNS, + PNL_COLUMNS, + POSITION_COLUMNS, +) +from pipeline.portfolio.constraints import ( + PriceLimitConstraint, + SuspensionConstraint, + VolumeCapConstraint, +) +from pipeline.portfolio.construct import construct_positions +from pipeline.portfolio.research import evaluate_portfolio +from pipeline.portfolio.simulator import ReferenceSimulator +from tests.helpers import ( + GENERATED_SYMBOLS, + generated_sessions, + make_generated_alpha_weights, + make_generated_combo_weights, + make_generated_daily_bars, +) + + +FIXTURE_PATH = Path(__file__).parent / "fixtures" / "daily_bars_real_2024_01_sample.pq" + + +def _assert_sorted_by_symbol_date(frame: pd.DataFrame) -> None: + expected = frame.sort_values(["symbol_id", "date"]).reset_index(drop=True) + pd.testing.assert_frame_equal(frame.reset_index(drop=True), expected) + + +def _assert_metric_dict_is_finite(metrics: dict[str, float]) -> None: + for key in ( + "cumulative_return", + "sharpe_annual", + "turnover_annual", + "max_drawdown", + "hit_rate", + "n_dates", + ): + assert key in metrics + assert np.isfinite(metrics[key]) + assert "ic" not in metrics + assert "rank_ic" not in metrics + assert "ir" not in metrics + + +def test_tiny_workflow_golden_outputs_are_stable(tmp_path): + dates = pd.to_datetime(["2024-01-02", "2024-01-03", "2024-01-04"]) + daily_bars = pd.DataFrame([ + { + "symbol_id": "sh600000", + "symbol_name": "A", + "date": dates[0], + "open": 10.0, + "high": 10.0, + "low": 10.0, + "close": 10.0, + "preclose": 10.0, + "volume": 1_000_000.0, + "amount": 10_000_000.0, + "vwap": 10.0, + "turn": 1.0, + "pctChg": 0.0, + "tradestatus": 1, + "isST": 0, + "peTTM": 1.0, + "pbMRQ": 1.0, + "psTTM": 1.0, + "pcfNcfTTM": 1.0, + }, + { + "symbol_id": "sz000001", + "symbol_name": "B", + "date": dates[0], + "open": 20.0, + "high": 20.0, + "low": 20.0, + "close": 20.0, + "preclose": 20.0, + "volume": 1_000_000.0, + "amount": 20_000_000.0, + "vwap": 20.0, + "turn": 1.0, + "pctChg": 0.0, + "tradestatus": 1, + "isST": 0, + "peTTM": 1.0, + "pbMRQ": 1.0, + "psTTM": 1.0, + "pcfNcfTTM": 1.0, + }, + { + "symbol_id": "sh600000", + "symbol_name": "A", + "date": dates[1], + "open": 10.0, + "high": 12.0, + "low": 10.0, + "close": 12.0, + "preclose": 10.0, + "volume": 1_000_000.0, + "amount": 10_000_000.0, + "vwap": 10.0, + "turn": 1.0, + "pctChg": 20.0, + "tradestatus": 1, + "isST": 0, + "peTTM": 1.0, + "pbMRQ": 1.0, + "psTTM": 1.0, + "pcfNcfTTM": 1.0, + }, + { + "symbol_id": "sz000001", + "symbol_name": "B", + "date": dates[1], + "open": 20.0, + "high": 20.0, + "low": 18.0, + "close": 18.0, + "preclose": 20.0, + "volume": 1_000_000.0, + "amount": 20_000_000.0, + "vwap": 20.0, + "turn": 1.0, + "pctChg": -10.0, + "tradestatus": 1, + "isST": 0, + "peTTM": 1.0, + "pbMRQ": 1.0, + "psTTM": 1.0, + "pcfNcfTTM": 1.0, + }, + { + "symbol_id": "sh600000", + "symbol_name": "A", + "date": dates[2], + "open": 12.0, + "high": 13.0, + "low": 12.0, + "close": 13.0, + "preclose": 12.0, + "volume": 1_000_000.0, + "amount": 12_000_000.0, + "vwap": 12.0, + "turn": 1.0, + "pctChg": 8.33, + "tradestatus": 1, + "isST": 0, + "peTTM": 1.0, + "pbMRQ": 1.0, + "psTTM": 1.0, + "pcfNcfTTM": 1.0, + }, + { + "symbol_id": "sz000001", + "symbol_name": "B", + "date": dates[2], + "open": 18.0, + "high": 21.0, + "low": 18.0, + "close": 21.0, + "preclose": 18.0, + "volume": 1_000_000.0, + "amount": 18_000_000.0, + "vwap": 18.0, + "turn": 1.0, + "pctChg": 16.67, + "tradestatus": 1, + "isST": 0, + "peTTM": 1.0, + "pbMRQ": 1.0, + "psTTM": 1.0, + "pcfNcfTTM": 1.0, + }, + ]) + alpha = pd.DataFrame({ + "symbol_id": ["sh600000", "sz000001", "sh600000", "sz000001"], + "date": [dates[0], dates[0], dates[1], dates[1]], + "alpha_name": ["gold_alpha"] * 4, + "weight": [1.0, -1.0, -1.0, 1.0], + }) + alpha_path = tmp_path / "gold_alpha.pq" + alpha.to_parquet(alpha_path, index=False) + + combo = combine_alphas([str(alpha_path)], "gold_combo") + positions = construct_positions(combo, daily_bars, booksize=20_000.0, portfolio_name="gold_port") + fills, pnl = ReferenceSimulator().run(positions, daily_bars) + + expected_combo = pd.DataFrame({ + "symbol_id": ["sh600000", "sh600000", "sz000001", "sz000001"], + "date": [dates[0], dates[1], dates[0], dates[1]], + "combo_name": ["gold_combo"] * 4, + "weight": [1.0, -1.0, -1.0, 1.0], + }) + expected_positions = pd.DataFrame({ + "symbol_id": ["sh600000", "sh600000", "sz000001", "sz000001"], + "date": [dates[0], dates[1], dates[0], dates[1]], + "portfolio_name": ["gold_port"] * 4, + "target_weight": [0.5, -0.5, -0.5, 0.5], + "target_value": [10000.0, -10000.0, -10000.0, 10000.0], + "target_shares": [1000.0, -10000.0 / 12.0, -500.0, 10000.0 / 18.0], + "position_shares": [1000, -833, -500, 556], + "position_value": [10000.0, -9996.0, -10000.0, 10008.0], + "price": [10.0, 12.0, 20.0, 18.0], + }) + expected_fills = pd.DataFrame({ + "symbol_id": ["sh600000", "sz000001", "sh600000", "sz000001"], + "date": [dates[1], dates[1], dates[2], dates[2]], + "portfolio_name": ["gold_port"] * 4, + "prev_shares": [0, 0, 1000, -500], + "target_shares": [1000, -500, -833, 556], + "traded_shares": [1000, -500, -1833, 1056], + "realized_shares": [1000, -500, -833, 556], + "blocked": [0, 0, 0, 0], + "trade_cost": [0.0, 0.0, 0.0, 0.0], + }) + expected_pnl = pd.DataFrame({ + "date": [dates[1], dates[2]], + "portfolio_name": ["gold_port", "gold_port"], + "gross_exposure": [21000.0, 22505.0], + "net_exposure": [3000.0, 847.0], + "pnl": [3000.0, 835.0], + "cost": [0.0, 0.0], + "turnover": [1.0, 2.0502], + "n_positions": [2, 2], + }) + + pd.testing.assert_frame_equal(combo, expected_combo) + pd.testing.assert_frame_equal(positions, expected_positions) + pd.testing.assert_frame_equal(fills, expected_fills) + pd.testing.assert_frame_equal(pnl, expected_pnl) + + +def test_generated_alpha_combo_portfolio_execution_workflow(tmp_path): + daily_bars = make_generated_daily_bars() + + computed_alpha = compute_alpha( + data=daily_bars, + alpha_name="generated_reversal_3d", + alpha_type="reversal", + lookback=3, + ) + + assert list(computed_alpha.columns) == ALPHA_COLUMNS + assert not computed_alpha.empty + assert set(computed_alpha["symbol_id"]).issubset(set(GENERATED_SYMBOLS)) + assert computed_alpha["date"].min() > daily_bars["date"].min() + assert computed_alpha["weight"].notna().all() + assert computed_alpha["weight"].abs().sum() > 0.0 + assert {"ic", "rank_ic", "ir"}.isdisjoint(computed_alpha.columns) + _assert_sorted_by_symbol_date(computed_alpha) + + alpha_a = make_generated_alpha_weights("alpha_a", zero_date_index=2) + alpha_b = make_generated_alpha_weights( + "alpha_b", + scale=0.5, + offset=0.25, + zero_date_index=2, + ) + alpha_a_path = tmp_path / "alpha_a.pq" + alpha_b_path = tmp_path / "alpha_b.pq" + alpha_a.to_parquet(alpha_a_path, index=False) + alpha_b.to_parquet(alpha_b_path, index=False) + + identity_combo = combine_alphas([str(alpha_a_path)], "identity_combo") + assert list(identity_combo.columns) == COMBO_COLUMNS + assert (identity_combo["combo_name"] == "identity_combo").all() + pd.testing.assert_frame_equal( + identity_combo[["symbol_id", "date", "weight"]], + alpha_a[["symbol_id", "date", "weight"]], + ) + + equal_combo = combine_alphas([str(alpha_a_path), str(alpha_b_path)], "equal_combo") + expected_equal_weights = ( + pd.concat([alpha_a, alpha_b], ignore_index=True) + .groupby(["symbol_id", "date"], as_index=False)["weight"] + .mean() + .sort_values(["symbol_id", "date"]) + .reset_index(drop=True) + ) + pd.testing.assert_frame_equal( + equal_combo[["symbol_id", "date", "weight"]], + expected_equal_weights, + ) + + portfolio_weights = make_generated_combo_weights("workflow_combo", zero_date_index=2) + positions = construct_positions( + weights_df=portfolio_weights, + data_df=daily_bars, + booksize=2_000_000.0, + portfolio_name="workflow_portfolio", + ) + + assert list(positions.columns) == POSITION_COLUMNS + assert not positions.empty + assert (positions["portfolio_name"] == "workflow_portfolio").all() + assert pd.api.types.is_integer_dtype(positions["position_shares"]) + assert np.allclose( + positions["position_value"], + positions["position_shares"].astype(float) * positions["price"].fillna(0.0), + ) + + target_gross_by_date = positions.groupby("date")["target_weight"].apply(lambda s: s.abs().sum()) + nonzero_target_dates = target_gross_by_date[target_gross_by_date > 0.0] + assert np.allclose(nonzero_target_dates, 1.0) + + nonzero_share_counts = positions.loc[positions["position_shares"] != 0, "position_shares"].abs() + assert (nonzero_share_counts >= 100).all() + + zero_gross_date = generated_sessions(10)[2] + previous_date = generated_sessions(10)[1] + zero_gross_positions = positions[positions["date"] == zero_gross_date].set_index("symbol_id") + previous_positions = positions[positions["date"] == previous_date].set_index("symbol_id") + common_symbols = zero_gross_positions.index.intersection(previous_positions.index) + assert not common_symbols.empty + assert (zero_gross_positions.loc[common_symbols, "target_weight"] == 0.0).all() + pd.testing.assert_series_equal( + zero_gross_positions.loc[common_symbols, "position_shares"], + previous_positions.loc[common_symbols, "position_shares"], + check_names=False, + ) + + simulator = ReferenceSimulator( + constraints=[ + SuspensionConstraint(), + PriceLimitConstraint(), + VolumeCapConstraint(max_frac=0.02), + ], + cost_bps=5, + slippage_bps=5, + ) + fills, pnl = simulator.run(positions, daily_bars) + + assert list(fills.columns) == FILL_COLUMNS + assert list(pnl.columns) == PNL_COLUMNS + assert not fills.empty + assert not pnl.empty + assert (fills["realized_shares"] == fills["prev_shares"] + fills["traded_shares"]).all() + assert fills["blocked"].sum() > 0 + + fill_prices = fills.merge( + daily_bars[["symbol_id", "date", "open"]], + on=["symbol_id", "date"], + how="left", + validate="many_to_one", + ) + expected_trade_cost = ( + fill_prices["traded_shares"].abs() + * fill_prices["open"].fillna(0.0) + * 10 + / 10_000 + ) + assert np.allclose(fill_prices["trade_cost"], expected_trade_cost) + + cost_by_date = fills.groupby("date")["trade_cost"].sum() + assert np.allclose( + pnl.set_index("date")["cost"], + cost_by_date.reindex(pnl["date"], fill_value=0.0), + ) + + booksize_used_by_simulator = positions.groupby("date")["target_value"].apply(lambda s: s.abs().sum()).max() + traded_value_by_date = ( + fill_prices.assign(traded_value=fill_prices["traded_shares"].abs() * fill_prices["open"]) + .groupby("date")["traded_value"] + .sum() + ) + assert np.allclose( + pnl.set_index("date")["turnover"], + traded_value_by_date.reindex(pnl["date"], fill_value=0.0) / booksize_used_by_simulator, + ) + + metrics = evaluate_portfolio(positions, daily_bars) + _assert_metric_dict_is_finite(metrics) + + +def test_generated_workflow_outputs_keep_parquet_schema_contracts(tmp_path): + daily_bars = make_generated_daily_bars(n_sessions=10, include_missing=False) + alpha = compute_alpha( + data=daily_bars, + alpha_name="schema_reversal", + alpha_type="reversal", + lookback=3, + ) + alpha_path = tmp_path / "schema_reversal.pq" + alpha.to_parquet(alpha_path, index=False) + + combo = combine_alphas([str(alpha_path)], "schema_combo") + positions = construct_positions( + weights_df=combo, + data_df=daily_bars, + booksize=1_000_000.0, + portfolio_name="schema_portfolio", + ) + fills, pnl = ReferenceSimulator(cost_bps=5, slippage_bps=5).run(positions, daily_bars) + + assert list(alpha.columns) == ALPHA_COLUMNS + assert pd.api.types.is_object_dtype(alpha["symbol_id"]) + assert pd.api.types.is_datetime64_any_dtype(alpha["date"]) + assert pd.api.types.is_object_dtype(alpha["alpha_name"]) + assert pd.api.types.is_float_dtype(alpha["weight"]) + assert not alpha.isna().any().any() + assert np.isfinite(alpha["weight"]).all() + + assert list(combo.columns) == COMBO_COLUMNS + assert pd.api.types.is_object_dtype(combo["symbol_id"]) + assert pd.api.types.is_datetime64_any_dtype(combo["date"]) + assert pd.api.types.is_object_dtype(combo["combo_name"]) + assert pd.api.types.is_float_dtype(combo["weight"]) + assert not combo.isna().any().any() + assert np.isfinite(combo["weight"]).all() + + assert list(positions.columns) == POSITION_COLUMNS + assert pd.api.types.is_integer_dtype(positions["position_shares"]) + assert pd.api.types.is_datetime64_any_dtype(positions["date"]) + assert not positions.isna().any().any() + position_numeric_columns = [ + "target_weight", + "target_value", + "target_shares", + "position_value", + "price", + ] + assert np.isfinite(positions[position_numeric_columns]).all().all() + + assert list(fills.columns) == FILL_COLUMNS + assert pd.api.types.is_integer_dtype(fills["prev_shares"]) + assert pd.api.types.is_integer_dtype(fills["target_shares"]) + assert pd.api.types.is_integer_dtype(fills["traded_shares"]) + assert pd.api.types.is_integer_dtype(fills["realized_shares"]) + assert pd.api.types.is_integer_dtype(fills["blocked"]) + assert not fills.isna().any().any() + assert np.isfinite(fills["trade_cost"]).all() + + assert list(pnl.columns) == PNL_COLUMNS + assert pd.api.types.is_integer_dtype(pnl["n_positions"]) + assert not pnl.isna().any().any() + pnl_numeric_columns = [ + "gross_exposure", + "net_exposure", + "pnl", + "cost", + "turnover", + ] + assert np.isfinite(pnl[pnl_numeric_columns]).all().all() + + +def test_frozen_real_fixture_runs_high_level_workflow(tmp_path): + real_daily_bars = pd.read_parquet(FIXTURE_PATH) + + assert real_daily_bars.shape == (36, 19) + assert set(real_daily_bars["symbol_id"]) == set(GENERATED_SYMBOLS) + assert real_daily_bars["date"].min() == pd.Timestamp("2024-01-02") + assert real_daily_bars["date"].max() == pd.Timestamp("2024-01-12") + assert real_daily_bars.groupby("date")["symbol_id"].nunique().eq(4).all() + + reversal_alpha = compute_alpha( + data=real_daily_bars, + alpha_name="real_reversal_3d", + alpha_type="reversal", + lookback=3, + ) + reversal_vol_alpha = compute_alpha( + data=real_daily_bars, + alpha_name="real_reversal_vol_3d", + alpha_type="reversal_vol", + lookback=3, + vol_window=3, + ) + + reversal_path = tmp_path / "real_reversal.pq" + reversal_vol_path = tmp_path / "real_reversal_vol.pq" + reversal_alpha.to_parquet(reversal_path, index=False) + reversal_vol_alpha.to_parquet(reversal_vol_path, index=False) + + combo = combine_alphas([str(reversal_path), str(reversal_vol_path)], "real_equal_combo") + positions = construct_positions( + weights_df=combo, + data_df=real_daily_bars, + booksize=1_000_000.0, + portfolio_name="real_fixture_portfolio", + ) + fills, pnl = ReferenceSimulator(cost_bps=5, slippage_bps=5).run(positions, real_daily_bars) + metrics = evaluate_portfolio(positions, real_daily_bars) + + assert not reversal_alpha.empty + assert not reversal_vol_alpha.empty + assert not combo.empty + assert not positions.empty + assert not fills.empty + assert not pnl.empty + assert np.isfinite(combo["weight"]).all() + assert np.isfinite(positions["target_weight"]).all() + assert np.isfinite(pnl[["gross_exposure", "net_exposure", "pnl", "cost", "turnover"]]).all().all() + _assert_metric_dict_is_finite(metrics) diff --git a/uv.lock b/uv.lock index 35c01ad..5819422 100644 --- a/uv.lock +++ b/uv.lock @@ -299,6 +299,7 @@ backtrader = [ [package.dev-dependencies] dev = [ + { name = "coverage" }, { name = "pytest" }, ] @@ -315,7 +316,10 @@ requires-dist = [ provides-extras = ["backtrader"] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=7.0.0" }] +dev = [ + { name = "coverage", specifier = ">=7.14.1" }, + { name = "pytest", specifier = ">=7.0.0" }, +] [[package]] name = "click" @@ -498,6 +502,119 @@ wheels = [ { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, ] +[[package]] +name = "coverage" +version = "7.14.1" +source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" } +sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } +wheels = [ + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/92/69/0d2ef01ff4b8fcecd4cba920d11e92fa4f96ae412441d3b56a90a258e69b/coverage-7.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf", size = 219722, upload-time = "2026-05-26T20:38:14.002Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f8/ae/9afdeaa31b9d9ce98124b6abf8bb49119bf71aecae04f8567c189d91299f/coverage-7.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf", size = 220240, upload-time = "2026-05-26T20:38:17.424Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/51/69/c998589871df7ea7dba865cc5ee32b5a3e1d47ba6c68ef91104c7c46fa5e/coverage-7.14.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d", size = 246981, upload-time = "2026-05-26T20:38:19.266Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/fc/10/1c7d04c13040dac531d21b712bbe08f902e6dd9b58f5d77875c4d030f8f2/coverage-7.14.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2", size = 248812, upload-time = "2026-05-26T20:38:20.75Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c1/65/2a38a4607ef27cadcfbcee034dba5830ae2569f90144a0f4c7dbf47d30b0/coverage-7.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47", size = 250675, upload-time = "2026-05-26T20:38:22.159Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c9/a2/a446ed9752a4a59b79e0fb6cbb319f6facb2183045c0725462625e66f87e/coverage-7.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550", size = 252590, upload-time = "2026-05-26T20:38:23.63Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/9e/fd/e81fbd7ba752365546e9842b1cbdaad3d6919d2a522c590aef16a281ec5e/coverage-7.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e", size = 247691, upload-time = "2026-05-26T20:38:25.057Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/53/35/f3c26fdaae9ea937d154ca4d372e5ea0a4167ff70d36c6074ac2eacb2f83/coverage-7.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f", size = 248716, upload-time = "2026-05-26T20:38:26.406Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/2e/14/940b6c49551fd343e8507ee2b0ba7af5d0aa04ed5bf768285cb7c72a9884/coverage-7.14.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1", size = 246721, upload-time = "2026-05-26T20:38:28.282Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/aa/2c/40fc0634186c28292a662dff578866b3913983d6c375a3c2a74020938719/coverage-7.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5", size = 250533, upload-time = "2026-05-26T20:38:29.753Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/de/e3/2c26bf1e811f9df991ff2a9bdddebdd13ee0665d564df7d05979f9146297/coverage-7.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b", size = 246990, upload-time = "2026-05-26T20:38:31.516Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a8/b0/060260ef56bd92363ebdce0c7095ce422b06e69aae71828efeca473ab1ca/coverage-7.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332", size = 247593, upload-time = "2026-05-26T20:38:33.065Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/63/f3/501502046efeb0d6d94b5ca54941d95f1184183dd6bdb7f283985783bb4a/coverage-7.14.1-cp310-cp310-win32.whl", hash = "sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59", size = 222330, upload-time = "2026-05-26T20:38:35.36Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a0/5d/1bf99f2c558f128faf7906817ccbdb576ba815d3b41ce2ac1719b70a3663/coverage-7.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253", size = 223261, upload-time = "2026-05-26T20:38:37.196Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7d/d7/477ad149490e6cb849f28abea1dabb9c823cea72e7500c81b4240ce619c0/coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f", size = 219848, upload-time = "2026-05-26T20:38:38.715Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/91/82/a5eb47257c50601bb7b9a9d2857c67b7a3a85ad74180eb2c98bb1fbe0ce5/coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4", size = 220354, upload-time = "2026-05-26T20:38:40.232Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/43/8b/78419b5391a5cb706b6544390507e469d83ffc9a8248b02c4011aceb9365/coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1", size = 250771, upload-time = "2026-05-26T20:38:41.782Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/77/63/e77aaacd491182210d639636b7a8bba23ffffa9b82aa3762da9431855fa9/coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f", size = 252683, upload-time = "2026-05-26T20:38:43.305Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/65/1c/a022e3cfbec2ac241640003cb3a817e161d9c7f5aa9b49173756cdc03204/coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129", size = 254791, upload-time = "2026-05-26T20:38:45.361Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/61/d6/967e408aca4c1ceb88cb0cc677169110ae7f5995fb5eaf5fb1f5a1bb8f5d/coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860", size = 256748, upload-time = "2026-05-26T20:38:46.91Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b8/be/869188f7fe28638078ec479331ace6dc5f7b40b7153eb616f47ab79404d8/coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c", size = 250907, upload-time = "2026-05-26T20:38:48.493Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/07/aa/adb7d3b4278d690e68703abcd76ab1b948242e3668d921711551b78f9ddb/coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7", size = 252483, upload-time = "2026-05-26T20:38:50.074Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/43/61/331c74103c62dcb0c4b9b3a0de9a61aca016208b0a90f109592a9f9ecc28/coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec", size = 250545, upload-time = "2026-05-26T20:38:51.613Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f6/b6/c5dae3c104d89be04828f61810e6b3473825482e4c288cc4ed04553e08ae/coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef", size = 254310, upload-time = "2026-05-26T20:38:53.503Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ad/a1/2b9d5863e3b83c01ad8199e3c597802fbb3a9dc90b058885804c20296d31/coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df", size = 250266, upload-time = "2026-05-26T20:38:55.414Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7f/5e/0e511fbdb269359be26fe678a1c3fa1f2aa2a01573cc3f54268c8d6d4797/coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9", size = 251174, upload-time = "2026-05-26T20:38:57.141Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/85/10/e55307b622b3dd9671cb321824502dc10f93e72f2802b9946159a8edadeb/coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548", size = 222354, upload-time = "2026-05-26T20:38:58.727Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/71/cf/107421693cfb71e4f1ca5bf70443f64d4161878068d07a3e51c7ad21d17b/coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e", size = 223290, upload-time = "2026-05-26T20:39:00.413Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b8/1d/3e3644585eb29e9dafefb19555078529a4d7cce12bd21929664eea989277/coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3", size = 221953, upload-time = "2026-05-26T20:39:02.159Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, +] + [[package]] name = "curl-cffi" version = "0.15.0"