diff --git a/app/pages/1_Cash_Deploy.py b/app/pages/1_Cash_Deploy.py index 25de3b5..04bb12f 100644 --- a/app/pages/1_Cash_Deploy.py +++ b/app/pages/1_Cash_Deploy.py @@ -63,14 +63,6 @@ def _metrics_row(label: str, m: dict[str, float], sharpe: float) -> dict[str, st } -def _sharpe_vs_cash(equity: pd.Series, cash_level: pd.Series, periods_per_year: int) -> float: - # Risk-free = the money-market leg's own periodic return, aligned to the - # equity curve's return dates inside sharpe_ratio. - returns = metrics.periodic_returns(equity) - risk_free = cash_level.pct_change() - return metrics.sharpe_ratio(returns, periods_per_year, risk_free) - - def _sidebar_rule() -> DeployRule | None: """Collect a deploy rule from the sidebar (a preset or a custom one).""" choice = st.sidebar.selectbox("Deploy rule", [*PRESET_RULES, CUSTOM_RULE], index=3) @@ -279,7 +271,7 @@ def main() -> None: _metrics_row( config.name, result.metrics(), - _sharpe_vs_cash(result.equity, cash_level, periods_per_year), + metrics.sharpe_vs_cash(result.equity, cash_level, periods_per_year), ) ] for label, series in baselines.items(): @@ -287,7 +279,7 @@ def main() -> None: _metrics_row( label, metrics.summarize(series, periods_per_year), - _sharpe_vs_cash(series, cash_level, periods_per_year), + metrics.sharpe_vs_cash(series, cash_level, periods_per_year), ) ) st.dataframe(pd.DataFrame(rows).set_index("Strategy"), width="stretch") diff --git a/app/pages/2_Optimize.py b/app/pages/2_Optimize.py new file mode 100644 index 0000000..7798fd3 --- /dev/null +++ b/app/pages/2_Optimize.py @@ -0,0 +1,321 @@ +"""Strategy Optimizer β€” search cash-deploy parameters that beat the index. + +Runs a Bayesian (Optuna TPE) parameter search over the cash-deploy strategy's +cash reserve, refill rate and drawdown-triggered deploy tranches, looking for +configurations that beat a 100%-stocks buy-and-hold. Honesty about +generalization comes from walk-forward validation: parameters are fitted on a +train window and scored on a later, unseen test window, and the out-of-sample +result is what the page leads with. + +This is *parameter search over history*, not machine learning β€” see the warning +on the page. Like ``1_Cash_Deploy.py`` it is a thin presentation layer; all +accounting and search logic live in ``portfolio_research_lab``. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Allow running directly (`streamlit run app/Home.py`) without installing the +# package, by adding the src/ layout to the import path. +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +SRC = PROJECT_ROOT / "src" +if SRC.exists() and str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +import pandas as pd # noqa: E402 +import plotly.graph_objects as go # noqa: E402 +import streamlit as st # noqa: E402 + +from portfolio_research_lab import metrics, optimizer # noqa: E402 +from portfolio_research_lab.cash_deploy import run_cash_deploy # noqa: E402 +from portfolio_research_lab.data import load_stocks_cash # noqa: E402 +from portfolio_research_lab.models import PRESET_RULES, CashDeployConfig # noqa: E402 +from portfolio_research_lab.optimizer import ( # noqa: E402 + ObjectiveKind, + SearchSpace, + WalkForwardResult, + index_equity, +) + +SP500_DATA = PROJECT_ROOT / "data" / "sp500daily.csv" +FED_FUNDS_DATA = PROJECT_ROOT / "data" / "fed-funds-rate.csv" +STOCK = "S&P 500" +CASH = "Cash (Fed Funds)" + +# Reserve/refill applied to the preset rules in the comparison table (the +# page-1 defaults), so the presets are judged on their rule, not tuned settings. +PRESET_RESERVE = 0.30 +PRESET_REFILL = 0.25 + +_OBJECTIVE_LABELS: dict[str, ObjectiveKind] = { + "Excess CAGR vs index": ObjectiveKind.EXCESS_CAGR, + "Excess CAGR, drawdown-capped": ObjectiveKind.EXCESS_CAGR_DD_CAPPED, + "Sharpe (vs cash)": ObjectiveKind.SHARPE_VS_CASH, + "CAGR": ObjectiveKind.CAGR, +} + +st.set_page_config(page_title="Optimizer Β· Portfolio Research Lab", page_icon="πŸ”Ž", layout="wide") + + +@st.cache_data(show_spinner=False) +def _load_stocks_cash() -> pd.DataFrame: + return load_stocks_cash(SP500_DATA, FED_FUNDS_DATA, stock_name=STOCK, cash_name=CASH) + + +def _metrics_row(label: str, m: dict[str, float], sharpe: float) -> dict[str, str]: + return { + "Strategy": label, + "Total return": f"{m['total_return']:.2%}", + "CAGR": f"{m['cagr']:.2%}", + "Volatility (ann.)": f"{m['annualized_volatility']:.2%}", + "Max drawdown": f"{m['max_drawdown']:.2%}", + "Sharpe (vs cash)": f"{sharpe:.2f}", + } + + +def _comparison_table(prices: pd.DataFrame, best: CashDeployConfig) -> pd.DataFrame: + """Optimized config vs 100% stocks and the four presets over the full window.""" + ppy = best.trading_days_per_year + cash_level = prices[CASH] + rows: list[dict[str, str]] = [] + + result = run_cash_deploy(prices, best) + rows.append( + _metrics_row( + "Optimized", + result.metrics(), + metrics.sharpe_vs_cash(result.equity, cash_level, ppy), + ) + ) + + index_eq = index_equity(prices, best) + rows.append( + _metrics_row( + "100% stocks (index)", + metrics.summarize(index_eq, ppy), + metrics.sharpe_vs_cash(index_eq, cash_level, ppy), + ) + ) + + for name, rule in PRESET_RULES.items(): + cfg = best.model_copy( + update={ + "reserve_pct": PRESET_RESERVE, + "refill_rate_per_year": PRESET_REFILL, + "rule": rule, + } + ) + preset_result = run_cash_deploy(prices, cfg) + rows.append( + _metrics_row( + f"Preset Β· {name}", + preset_result.metrics(), + metrics.sharpe_vs_cash(preset_result.equity, cash_level, ppy), + ) + ) + + return pd.DataFrame(rows).set_index("Strategy") + + +def _bucket_table(best: CashDeployConfig) -> pd.DataFrame: + return pd.DataFrame( + { + "Drawdown trigger": [f"{t:.1%}" for t in best.rule.thresholds], + "Reserve deployed": [f"{u:.1%}" for u in best.rule.usages], + } + ) + + +def _walk_forward_table(wf: WalkForwardResult) -> pd.DataFrame: + rows = [] + for i, fold in enumerate(wf.folds, start=1): + rows.append( + { + "Fold": i, + "Train": f"{fold.train_window[0]:%Y-%m} β†’ {fold.train_window[1]:%Y-%m}", + "Test": f"{fold.test_window[0]:%Y-%m} β†’ {fold.test_window[1]:%Y-%m}", + "Train excess CAGR": f"{fold.train_excess_cagr:+.2%}", + "Test excess CAGR": f"{fold.test_excess_cagr:+.2%}", + } + ) + return pd.DataFrame(rows).set_index("Fold") + + +def _equity_chart(strategy: pd.Series, index: pd.Series) -> go.Figure: + fig = go.Figure() + fig.add_trace( + go.Scatter(x=index.index, y=index, name="100% stocks", mode="lines", line={"width": 1}) + ) + fig.add_trace(go.Scatter(x=strategy.index, y=strategy, name="Optimized", mode="lines")) + fig.update_layout( + margin={"t": 20, "b": 20}, yaxis_title="Portfolio value", hovermode="x unified" + ) + return fig + + +def _history_chart(history: pd.DataFrame) -> go.Figure: + running_best = history["value"].cummax() + fig = go.Figure() + fig.add_trace( + go.Scatter( + x=history["trial"], + y=history["value"], + name="Trial", + mode="markers", + marker={"size": 4, "opacity": 0.5}, + ) + ) + fig.add_trace(go.Scatter(x=history["trial"], y=running_best, name="Best so far", mode="lines")) + fig.update_layout( + margin={"t": 20, "b": 20}, + xaxis_title="Trial", + yaxis_title="Objective", + hovermode="x unified", + ) + return fig + + +def main() -> None: + st.title("πŸ”Ž Strategy Optimizer") + st.caption( + "Search the cash-deploy parameters β€” cash reserve, refill rate and " + "drawdown deploy tranches β€” for settings that beat the index. For " + "research and education only β€” not financial advice." + ) + st.warning( + "**This fits parameters to the past.** Picking the best of many " + "configurations on one historical price path is curve-fitting: a config " + "that beat the index historically will not necessarily do so in future. " + "Trust the **out-of-sample** (test) column, not the in-sample one." + ) + st.info( + "**S&P 500 price return only** β€” dividends are not reinvested, which " + "understates stock returns and mildly favours holding cash, so the bar " + "to 'beat the index' here is easier than against a total-return index." + ) + + try: + prices = _load_stocks_cash() + except (ValueError, FileNotFoundError) as exc: + st.error(f"Could not load price data: {exc}") + st.stop() + + # --- Search controls -------------------------------------------------- + st.sidebar.header("1 Β· Objective") + objective_label = st.sidebar.selectbox("Maximize", list(_OBJECTIVE_LABELS), index=0) + objective_kind = _OBJECTIVE_LABELS[objective_label] + dd_cap = 0.35 + if objective_kind is ObjectiveKind.EXCESS_CAGR_DD_CAPPED: + dd_cap = st.sidebar.slider("Max drawdown cap (%)", 10, 80, 35, step=5) / 100.0 + + st.sidebar.header("2 Β· Search space") + max_buckets = st.sidebar.slider("Max deploy buckets", 1, 5, 5) + + st.sidebar.header("3 Β· Budget & validation") + n_trials = st.sidebar.slider("Trials per fold", 25, 1000, 200, step=25) + n_folds = st.sidebar.slider("Walk-forward folds", 2, 8, 5) + train_frac = st.sidebar.slider("Train fraction per fold", 0.3, 0.8, 0.5, step=0.05) + seed = int(st.sidebar.number_input("Random seed", min_value=0, value=0, step=1)) + + st.sidebar.header("4 Β· Window") + years = prices.index.map(lambda ts: ts.year) + start_year = st.sidebar.slider( + "Start year", int(years.min()), int(years.max()), int(years.min()) + ) + prices = prices[years >= start_year] + if len(prices) < 100: + st.warning("Not enough data in the selected window to optimize.") + st.stop() + + total_trials = (n_folds + 1) * n_trials + st.sidebar.caption(f"β‰ˆ {total_trials:,} backtests (~{total_trials * 0.04:.0f}s at ~40ms each).") + + run = st.sidebar.button("Run search", type="primary") + + if run: + space = SearchSpace(max_buckets=max_buckets) + base = CashDeployConfig( + name="Optimized", + rule=PRESET_RULES["Recommended"], # placeholder; overwritten by the search + stock_symbol=STOCK, + cash_symbol=CASH, + ) + progress = st.progress(0.0) + status = st.empty() + last_update = {"n": 0} + + def on_trial(done: int, total: int, best: float) -> None: + if done - last_update["n"] >= 25 or done >= total: + last_update["n"] = done + progress.progress(min(done / total, 1.0)) + status.write(f"Trial {done:,}/{total:,} Β· best objective so far: {best:.4f}") + + with st.spinner("Searching…"): + wf = optimizer.walk_forward( + prices, + base=base, + objective_kind=objective_kind, + space=space, + n_folds=n_folds, + train_frac=train_frac, + n_trials=n_trials, + seed=seed, + dd_cap=dd_cap, + on_trial=on_trial, + ) + progress.progress(1.0) + status.write(f"Done β€” evaluated {total_trials:,} configurations.") + st.session_state["opt_result"] = wf + st.session_state["opt_prices"] = prices + + wf = st.session_state.get("opt_result") + if wf is None: + st.info("Set the search controls in the sidebar and press **Run search**.") + return + + prices = st.session_state["opt_prices"] + best = wf.final.best + + # --- Recommended config ---------------------------------------------- + st.subheader("Recommended configuration") + st.caption("Optimized over the full selected window β€” the parameters you would deploy.") + c1, c2 = st.columns(2) + c1.metric("Cash reserve target", f"{best.reserve_pct:.1%}") + c2.metric("Refill rate", f"{best.refill_rate_per_year:.1%} / year") + st.table(_bucket_table(best)) + + # --- Out-of-sample validation ---------------------------------------- + st.subheader("Walk-forward validation (out-of-sample)") + st.caption( + "Each fold optimizes on its train window, then measures the frozen winner " + "on the later, unseen test window. The **test** column is the honest number." + ) + st.metric("Mean out-of-sample excess CAGR vs index", f"{wf.mean_test_excess_cagr:+.2%}") + st.dataframe(_walk_forward_table(wf), width="stretch") + st.caption( + f"Beat the index **in-sample** in {wf.n_beat_index_train}/{len(wf.folds)} folds; " + f"**out-of-sample** in {wf.n_beat_index_test}/{len(wf.folds)} folds. " + "A large gap between the two is the curve-fitting tax." + ) + + # --- Comparison ------------------------------------------------------- + st.subheader("Comparison over the full window") + st.caption( + f"Presets use the default {PRESET_RESERVE:.0%} reserve / {PRESET_REFILL:.0%} refill, " + "so they are judged on their deploy rule." + ) + st.dataframe(_comparison_table(prices, best), width="stretch") + + st.subheader("Equity curve") + result = run_cash_deploy(prices, best) + st.plotly_chart(_equity_chart(result.equity, index_equity(prices, best)), width="stretch") + + st.subheader("Search progress (final fit)") + st.caption("Objective value per trial and the running best, over the full-window fit.") + st.plotly_chart(_history_chart(wf.final.history), width="stretch") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 8c7bb7a..8c96356 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,8 @@ dependencies = [ # moment a page renders a table/chart. Cap below it until the regression is # fixed upstream. See https://github.com/apache/arrow (pyarrow 25.0.0). "pyarrow>=14,<25", + # Bayesian (TPE) hyperparameter search driving the strategy optimizer. + "optuna>=3.6", ] [project.optional-dependencies] diff --git a/src/portfolio_research_lab/__init__.py b/src/portfolio_research_lab/__init__.py index 36ff5a3..3a1dba0 100644 --- a/src/portfolio_research_lab/__init__.py +++ b/src/portfolio_research_lab/__init__.py @@ -9,6 +9,7 @@ from __future__ import annotations +from portfolio_research_lab.cash_deploy import CashDeployResult, run_cash_deploy from portfolio_research_lab.data import ( infer_periods_per_year, load_price_data, @@ -16,21 +17,39 @@ parse_price_csv, rate_to_index, ) -from portfolio_research_lab.models import StrategyConfig +from portfolio_research_lab.models import CashDeployConfig, DeployRule, StrategyConfig +from portfolio_research_lab.optimizer import ( + ObjectiveKind, + OptimizationResult, + SearchSpace, + WalkForwardResult, + optimize, + walk_forward, +) from portfolio_research_lab.simulator import SimulationResult, run_simulation from portfolio_research_lab.strategies import BuyAndHold, Strategy __all__ = [ "BuyAndHold", + "CashDeployConfig", + "CashDeployResult", + "DeployRule", + "ObjectiveKind", + "OptimizationResult", + "SearchSpace", "SimulationResult", "Strategy", "StrategyConfig", + "WalkForwardResult", "infer_periods_per_year", "load_price_data", "load_rate_series", + "optimize", "parse_price_csv", "rate_to_index", + "run_cash_deploy", "run_simulation", + "walk_forward", ] __version__ = "0.1.0" diff --git a/src/portfolio_research_lab/metrics.py b/src/portfolio_research_lab/metrics.py index 7851fcc..42645bd 100644 --- a/src/portfolio_research_lab/metrics.py +++ b/src/portfolio_research_lab/metrics.py @@ -79,6 +79,21 @@ def sharpe_ratio( return float(excess.mean() * periods_per_year / vol) +def sharpe_vs_cash( + equity: pd.Series, + cash_level: pd.Series, + periods_per_year: int = TRADING_DAYS_PER_YEAR, +) -> float: + """Sharpe ratio of an equity curve using a money-market leg as the risk-free rate. + + The risk-free rate is the ``cash_level`` index's own periodic return, aligned + to the equity curve's return dates inside :func:`sharpe_ratio`. + """ + returns = periodic_returns(equity) + risk_free = cash_level.pct_change() + return sharpe_ratio(returns, periods_per_year, risk_free) + + def drawdown_series(equity: pd.Series) -> pd.Series: """Drawdown at each point: ``value / running_peak - 1`` (values <= 0).""" running_peak = equity.cummax() diff --git a/src/portfolio_research_lab/optimizer.py b/src/portfolio_research_lab/optimizer.py new file mode 100644 index 0000000..d6e347c --- /dev/null +++ b/src/portfolio_research_lab/optimizer.py @@ -0,0 +1,434 @@ +"""Strategy optimizer for the cash-deploy backtest. + +This module searches the cash-deploy parameter space β€” cash reserve, refill rate, +and the drawdown-triggered deploy tranches β€” for configurations that *beat the +index* (100%-stocks buy-and-hold) over historical price data. + +It is **parameter search over a single historical price path, not supervised +machine learning**: there is no labelled training set, only a different slice of +the same S&P series to validate against. The search uses Optuna's TPE (Bayesian) +sampler; honesty about generalization comes from :func:`walk_forward`, which +optimizes on a train window and measures the frozen winner on a later, unseen +test window. + +Like the rest of :mod:`portfolio_research_lab`, nothing here imports Streamlit, +so the optimizer runs from notebooks, scripts and tests. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from enum import Enum + +import optuna +import pandas as pd + +from portfolio_research_lab import metrics +from portfolio_research_lab.cash_deploy import CashDeployResult, run_cash_deploy +from portfolio_research_lab.models import CashDeployConfig, DeployRule, StrategyConfig +from portfolio_research_lab.simulator import run_simulation + +# Sorted grid of candidate drawdown thresholds. Sampling a distinct subset makes +# every candidate's thresholds strictly-increasing-in-(0, 1) *by construction* β€” +# no repair loop, no rejected trials. +THRESHOLD_GRID: tuple[float, ...] = (0.05, 0.075, 0.10, 0.15, 0.20, 0.25, 0.30, 0.40, 0.50) + +# How many top configurations to keep for the leaderboard / overfitting diagnostic. +LEADERBOARD_SIZE = 20 + + +@dataclass(frozen=True, slots=True) +class SearchSpace: + """Bounds and grids the optimizer samples parameters from. + + Ranges are deliberately tighter than the model's full legal domain so the + search spends its budget on sensible regions (e.g. a reserve of 5-60% rather + than the model-legal 0-100%). Everything is overridable from the UI. + """ + + threshold_grid: tuple[float, ...] = THRESHOLD_GRID + max_buckets: int = 5 + usage_range: tuple[float, float] = (0.05, 1.0) + reserve_range: tuple[float, float] = (0.05, 0.60) + refill_range: tuple[float, float] = (0.0, 1.0) + + +class ObjectiveKind(Enum): + """What the optimizer maximizes. Higher is always better.""" + + EXCESS_CAGR = "excess_cagr" + EXCESS_CAGR_DD_CAPPED = "excess_cagr_dd_capped" + SHARPE_VS_CASH = "sharpe_vs_cash" + CAGR = "cagr" + + +@dataclass(frozen=True, slots=True) +class ObjectiveContext: + """Everything an objective needs beyond the strategy result itself. + + ``benchmark_cagr`` is the 100%-stocks buy-and-hold CAGR over the *same* slice + the strategy was run on β€” the "index" the strategy must beat. ``cash_level`` + is that slice's money-market leg, used as the risk-free rate for Sharpe. + """ + + benchmark_cagr: float + cash_level: pd.Series + periods_per_year: int + dd_cap: float = 0.35 + dd_penalty: float = 1.0 + + +# An objective scores a run from its result and its already-computed metrics +# (passed in so metrics() is not recomputed per trial). +Objective = Callable[[CashDeployResult, dict[str, float]], float] + + +def make_objective(kind: ObjectiveKind, ctx: ObjectiveContext) -> Objective: + """Build the scoring function for ``kind``, closing over ``ctx``.""" + + def excess_cagr(result: CashDeployResult, m: dict[str, float]) -> float: + return m["cagr"] - ctx.benchmark_cagr + + def excess_cagr_dd_capped(result: CashDeployResult, m: dict[str, float]) -> float: + excess = m["cagr"] - ctx.benchmark_cagr + # max_drawdown is <= 0; -max_drawdown is the positive drawdown magnitude. + breach = max(0.0, -m["max_drawdown"] - ctx.dd_cap) + return excess - ctx.dd_penalty * breach + + def sharpe(result: CashDeployResult, m: dict[str, float]) -> float: + return metrics.sharpe_vs_cash(result.equity, ctx.cash_level, ctx.periods_per_year) + + def cagr(result: CashDeployResult, m: dict[str, float]) -> float: + return m["cagr"] + + dispatch: dict[ObjectiveKind, Objective] = { + ObjectiveKind.EXCESS_CAGR: excess_cagr, + ObjectiveKind.EXCESS_CAGR_DD_CAPPED: excess_cagr_dd_capped, + ObjectiveKind.SHARPE_VS_CASH: sharpe, + ObjectiveKind.CAGR: cagr, + } + return dispatch[kind] + + +def suggest_config( + trial: optuna.Trial, + space: SearchSpace, + base: CashDeployConfig, +) -> CashDeployConfig: + """Sample one candidate :class:`CashDeployConfig` from ``space``. + + ``base`` supplies the fixed context (symbols, capital, trading days, name); + only ``reserve_pct``, ``refill_rate_per_year`` and the deploy ``rule`` are + searched. The threshold encoding samples ``n`` grid indices and decodes the + *distinct* ones sorted, so the resulting rule always satisfies + :class:`DeployRule`'s constraints (a collision simply yields fewer tranches). + """ + reserve_pct = trial.suggest_float("reserve_pct", *space.reserve_range) + refill = trial.suggest_float("refill_rate_per_year", *space.refill_range) + + n = trial.suggest_int("n_buckets", 1, space.max_buckets) + grid = space.threshold_grid + indices = {trial.suggest_int(f"t{k}", 0, len(grid) - 1) for k in range(n)} + thresholds = tuple(sorted(grid[i] for i in indices)) + usages = tuple(trial.suggest_float(f"u{k}", *space.usage_range) for k in range(len(thresholds))) + + rule = DeployRule(name="search", thresholds=thresholds, usages=usages) + return base.model_copy( + update={"reserve_pct": reserve_pct, "refill_rate_per_year": refill, "rule": rule} + ) + + +@dataclass(slots=True) +class ScoredConfig: + """A candidate config with its objective score and headline metrics (in-sample).""" + + config: CashDeployConfig + score: float + metrics: dict[str, float] + + +@dataclass(slots=True) +class OptimizationResult: + """Result of a single train/test optimization. + + ``test_*`` fields are ``None`` when there is no holdout (``split >= 1.0``). + ``test_metrics`` β€” the *frozen* best config re-run as an independent + simulation on the unseen test slice β€” is the honest, out-of-sample headline. + """ + + best: CashDeployConfig + train_window: tuple[pd.Timestamp, pd.Timestamp] + test_window: tuple[pd.Timestamp, pd.Timestamp] | None + train_metrics: dict[str, float] + test_metrics: dict[str, float] | None + train_index_metrics: dict[str, float] + test_index_metrics: dict[str, float] | None + leaderboard: list[ScoredConfig] + history: pd.DataFrame + n_evaluated: int + + +@dataclass(slots=True) +class Fold: + """One walk-forward fold: optimize on ``train_window``, measure on ``test_window``.""" + + train_window: tuple[pd.Timestamp, pd.Timestamp] + test_window: tuple[pd.Timestamp, pd.Timestamp] + config: CashDeployConfig + train_excess_cagr: float + test_excess_cagr: float + result: OptimizationResult + + +@dataclass(slots=True) +class WalkForwardResult: + """Walk-forward validation plus the final full-history recommendation. + + ``mean_test_excess_cagr`` is the number to trust β€” the average out-of-sample + margin over the index across folds. ``final`` is the config optimized over the + entire window; it is what you would actually deploy. + """ + + folds: list[Fold] + mean_test_excess_cagr: float + final: OptimizationResult + n_beat_index_train: int = field(init=False) + n_beat_index_test: int = field(init=False) + + def __post_init__(self) -> None: + self.n_beat_index_train = sum(1 for f in self.folds if f.train_excess_cagr > 0) + self.n_beat_index_test = sum(1 for f in self.folds if f.test_excess_cagr > 0) + + +ProgressCallback = Callable[[int, int, float], None] + + +def _index_metrics( + prices: pd.DataFrame, base: CashDeployConfig +) -> tuple[dict[str, float], pd.Series]: + """Headline metrics and equity curve of a 100%-stocks buy-and-hold over ``prices``.""" + config = StrategyConfig.from_weights( + {base.stock_symbol: 1.0}, + name="100% stocks", + initial_capital=base.initial_capital, + trading_days_per_year=base.trading_days_per_year, + ) + equity = run_simulation(prices, config).equity + return metrics.summarize(equity, base.trading_days_per_year), equity + + +def index_equity(prices: pd.DataFrame, base: CashDeployConfig) -> pd.Series: + """The 100%-stocks buy-and-hold equity curve over ``prices`` (for charts).""" + return _index_metrics(prices, base)[1] + + +def _build_context(prices: pd.DataFrame, base: CashDeployConfig, dd_cap: float) -> ObjectiveContext: + index_m, _ = _index_metrics(prices, base) + return ObjectiveContext( + benchmark_cagr=index_m["cagr"], + cash_level=prices[base.cash_symbol], + periods_per_year=base.trading_days_per_year, + dd_cap=dd_cap, + ) + + +def optimize( + prices: pd.DataFrame, + *, + base: CashDeployConfig, + objective_kind: ObjectiveKind = ObjectiveKind.EXCESS_CAGR, + space: SearchSpace | None = None, + split: float = 0.60, + n_trials: int = 200, + seed: int = 0, + dd_cap: float = 0.35, + on_trial: ProgressCallback | None = None, +) -> OptimizationResult: + """Search ``prices`` for the best cash-deploy config, honest about out-of-sample. + + The rows are split chronologically at ``split`` (a fraction in ``(0, 1]``). + The Optuna study runs on the **train** slice only; the frozen best config is + then re-run as an independent simulation on the **test** slice and reported + separately. ``split >= 1.0`` disables the holdout (train = full window), used + for the final deployable fit. + + ``on_trial(done, total, best_value)`` is called after each trial for progress. + """ + if space is None: + space = SearchSpace() + if len(prices) < 2: + raise ValueError("need at least two price rows to optimize") + + # Keep Optuna's per-trial INFO logging quiet; done here rather than at import + # so `import portfolio_research_lab` has no global logging side effect. + optuna.logging.set_verbosity(optuna.logging.WARNING) + + cut = len(prices) if split >= 1.0 else round(len(prices) * split) + cut = max(2, min(cut, len(prices))) + train = prices.iloc[:cut] + test = prices.iloc[cut:] + has_test = len(test) >= 2 + if split < 1.0 and not has_test: + # A split below 1.0 promises a holdout; refuse to silently drop it. + raise ValueError( + f"not enough rows ({len(prices)}) for a holdout at split={split}; " + "use more data or split=1.0" + ) + + train_ctx = _build_context(train, base, dd_cap) + objective = make_objective(objective_kind, train_ctx) + + scored: list[ScoredConfig] = [] + + def objective_fn(trial: optuna.Trial) -> float: + config = suggest_config(trial, space, base) + result = run_cash_deploy(train, config) + m = result.metrics() + score = objective(result, m) + scored.append(ScoredConfig(config=config, score=score, metrics=m)) + return score + + def callback(study: optuna.Study, trial: optuna.trial.FrozenTrial) -> None: + if on_trial is None: + return + # study.best_value raises until at least one trial has completed (e.g. if + # early trials prune), so guard it β€” progress must never crash the search. + try: + best = study.best_value + except ValueError: + return + on_trial(trial.number + 1, n_trials, best) + + sampler = optuna.samplers.TPESampler(seed=seed) + study = optuna.create_study(direction="maximize", sampler=sampler) + study.optimize(objective_fn, n_trials=n_trials, callbacks=[callback]) + + if not scored: + raise RuntimeError("no configurations were successfully evaluated") + + leaderboard = sorted(scored, key=lambda s: s.score, reverse=True)[:LEADERBOARD_SIZE] + best = leaderboard[0] + + train_index_m, _ = _index_metrics(train, base) + test_metrics: dict[str, float] | None = None + test_index_metrics: dict[str, float] | None = None + test_window: tuple[pd.Timestamp, pd.Timestamp] | None = None + if has_test: + # Re-run the frozen best as an INDEPENDENT simulation on the unseen slice: + # the engine resets its peak/reserve to the slice's first row, so the test + # result must never be sliced out of the train equity curve. + test_metrics = run_cash_deploy(test, best.config).metrics() + test_index_metrics, _ = _index_metrics(test, base) + test_window = (test.index[0], test.index[-1]) + + history = pd.DataFrame({"trial": range(len(scored)), "value": [s.score for s in scored]}) + + return OptimizationResult( + best=best.config, + train_window=(train.index[0], train.index[-1]), + test_window=test_window, + train_metrics=best.metrics, + test_metrics=test_metrics, + train_index_metrics=train_index_m, + test_index_metrics=test_index_metrics, + leaderboard=leaderboard, + history=history, + n_evaluated=len(scored), + ) + + +def walk_forward( + prices: pd.DataFrame, + *, + base: CashDeployConfig, + objective_kind: ObjectiveKind = ObjectiveKind.EXCESS_CAGR, + space: SearchSpace | None = None, + n_folds: int = 5, + train_frac: float = 0.5, + n_trials: int = 200, + seed: int = 0, + dd_cap: float = 0.35, + on_trial: ProgressCallback | None = None, +) -> WalkForwardResult: + """Walk-forward validation: roll ``n_folds`` non-overlapping train->test windows. + + The timeline is cut into ``n_folds`` contiguous windows; each is split + internally at ``train_frac`` into a train part (optimized on) and a later test + part (measured on the frozen winner). This yields an honest out-of-sample + excess-CAGR per fold with no leakage β€” each fold's test rows are strictly later + than its own train rows, and folds do not overlap. + + ``final`` is one more optimization over the *entire* window (no holdout): the + single recommended parameter set to deploy. Progress is reported across all + ``(n_folds + 1) * n_trials`` trials. + """ + if space is None: + space = SearchSpace() + if n_folds < 1: + raise ValueError("n_folds must be at least 1") + + n = len(prices) + window = n // n_folds + if window < 4: + raise ValueError(f"not enough data for {n_folds} folds over {n} rows") + + total_trials = (n_folds + 1) * n_trials + completed = 0 + + def fold_progress(done: int, _total: int, best: float) -> None: + if on_trial is not None: + on_trial(completed + done, total_trials, best) + + folds: list[Fold] = [] + for i in range(n_folds): + start = i * window + end = n if i == n_folds - 1 else start + window + fold_prices = prices.iloc[start:end] + result = optimize( + fold_prices, + base=base, + objective_kind=objective_kind, + space=space, + split=train_frac, + n_trials=n_trials, + seed=seed + i, + dd_cap=dd_cap, + on_trial=fold_progress, + ) + completed += n_trials + if ( + result.test_metrics is None + or result.test_index_metrics is None + or result.test_window is None + ): + raise ValueError( + f"fold {i} produced no test slice; reduce n_folds or train_frac for {n} rows" + ) + train_excess = result.train_metrics["cagr"] - result.train_index_metrics["cagr"] + test_excess = result.test_metrics["cagr"] - result.test_index_metrics["cagr"] + folds.append( + Fold( + train_window=result.train_window, + test_window=result.test_window, + config=result.best, + train_excess_cagr=train_excess, + test_excess_cagr=test_excess, + result=result, + ) + ) + + final = optimize( + prices, + base=base, + objective_kind=objective_kind, + space=space, + split=1.0, + n_trials=n_trials, + seed=seed, + dd_cap=dd_cap, + on_trial=fold_progress, + ) + + mean_test_excess = sum(f.test_excess_cagr for f in folds) / len(folds) + return WalkForwardResult(folds=folds, mean_test_excess_cagr=mean_test_excess, final=final) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index bb0ea9d..4978e23 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -103,3 +103,19 @@ def test_sharpe_subtracts_risk_free_series(): # A risk-free series equal to the returns leaves zero excess => Sharpe 0. rf = pd.Series([0.02, 0.02, 0.02, 0.02], index=pd.RangeIndex(4)) assert metrics.sharpe_ratio(returns, risk_free=rf) == 0.0 + + +def test_sharpe_vs_cash_matches_manual(): + # Sharpe of the equity curve using the cash leg's own returns as risk-free; + # must equal calling sharpe_ratio with that risk-free series directly. + equity = _equity([100.0, 110.0, 105.0, 120.0]) + cash = _equity([100.0, 100.5, 101.0, 101.5]) + expected = metrics.sharpe_ratio(metrics.periodic_returns(equity), 252, cash.pct_change()) + assert metrics.sharpe_vs_cash(equity, cash, 252) == pytest.approx(expected) + + +def test_sharpe_vs_cash_zero_when_equity_tracks_cash(): + # Equity growing exactly like the cash leg => zero excess => Sharpe 0. + equity = _equity([100.0, 101.0, 102.01, 103.0301]) + cash = _equity([200.0, 202.0, 204.02, 206.0602]) + assert metrics.sharpe_vs_cash(equity, cash, 252) == pytest.approx(0.0) diff --git a/tests/test_optimize.py b/tests/test_optimize.py new file mode 100644 index 0000000..1912b3f --- /dev/null +++ b/tests/test_optimize.py @@ -0,0 +1,230 @@ +"""Tests for the cash-deploy strategy optimizer.""" + +from __future__ import annotations + +from itertools import pairwise + +import numpy as np +import optuna +import pandas as pd +import pytest + +from portfolio_research_lab import optimizer as opt +from portfolio_research_lab.cash_deploy import run_cash_deploy +from portfolio_research_lab.models import DeployRule +from portfolio_research_lab.optimizer import ( + ObjectiveContext, + ObjectiveKind, + SearchSpace, + make_objective, + suggest_config, +) + +STOCK = "S&P 500" +CASH = "Cash (Fed Funds)" + + +def _base() -> opt.CashDeployConfig: + from portfolio_research_lab.models import CashDeployConfig + + return CashDeployConfig( + name="Optimized", + rule=DeployRule(name="seed", thresholds=(0.10,), usages=(0.50,)), + stock_symbol=STOCK, + cash_symbol=CASH, + ) + + +@pytest.fixture +def recovery_prices() -> pd.DataFrame: + """~300 business days: steady drift, a deep V-shaped drawdown, then recovery. + + A rule that deploys cash into the drawdown and rides the recovery should beat + a 100%-stocks buy-and-hold that just sat through it. Cash is a flat index. + """ + idx = pd.bdate_range("2000-01-01", periods=300, name="date") + t = np.arange(300, dtype=float) + stock = 100.0 * (1.0 + 0.0003 * t) + stock[100:180] *= np.linspace(1.0, 0.6, 80) # ~40% drawdown + stock[180:] *= 0.6 # recover from the depressed level back up via the drift + return pd.DataFrame( + {STOCK: stock, CASH: np.full(300, 100.0)}, + index=idx, + ) + + +# --- Encoding ----------------------------------------------------------------- + + +def _sample_configs(space: SearchSpace, n: int, seed: int) -> list[opt.CashDeployConfig]: + configs: list[opt.CashDeployConfig] = [] + base = _base() + + def objective(trial: optuna.Trial) -> float: + configs.append(suggest_config(trial, space, base)) + return 0.0 + + study = optuna.create_study(sampler=optuna.samplers.TPESampler(seed=seed)) + study.optimize(objective, n_trials=n) + return configs + + +def test_suggest_config_is_always_valid(): + space = SearchSpace() + configs = _sample_configs(space, n=200, seed=1) + assert len(configs) == 200 + for cfg in configs: + rule = cfg.rule + # DeployRule construction already enforces most of this, but assert the + # invariants explicitly so the encoding contract is the thing under test. + assert 1 <= len(rule.thresholds) <= space.max_buckets + assert len(rule.thresholds) == len(rule.usages) + assert all(0.0 < t < 1.0 for t in rule.thresholds) + assert list(rule.thresholds) == sorted(rule.thresholds) + assert len(set(rule.thresholds)) == len(rule.thresholds) # strictly increasing + assert all(u > 0.0 for u in rule.usages) + assert space.reserve_range[0] <= cfg.reserve_pct <= space.reserve_range[1] + assert space.refill_range[0] <= cfg.refill_rate_per_year <= space.refill_range[1] + + +def test_max_buckets_is_respected(): + space = SearchSpace(max_buckets=3) + configs = _sample_configs(space, n=100, seed=2) + assert max(len(c.rule.thresholds) for c in configs) <= 3 + + +# --- Objectives --------------------------------------------------------------- + + +def _context(prices: pd.DataFrame, dd_cap: float = 0.35) -> ObjectiveContext: + base = _base() + index_m, _ = opt._index_metrics(prices, base) + return ObjectiveContext( + benchmark_cagr=index_m["cagr"], + cash_level=prices[CASH], + periods_per_year=base.trading_days_per_year, + dd_cap=dd_cap, + ) + + +def test_excess_cagr_objective(recovery_prices: pd.DataFrame): + ctx = _context(recovery_prices) + cfg = _base().model_copy(update={"reserve_pct": 0.3}) + result = run_cash_deploy(recovery_prices, cfg) + m = result.metrics() + score = make_objective(ObjectiveKind.EXCESS_CAGR, ctx)(result, m) + assert score == pytest.approx(m["cagr"] - ctx.benchmark_cagr) + + +def test_dd_capped_penalizes_only_when_breached(recovery_prices: pd.DataFrame): + cfg = _base().model_copy(update={"reserve_pct": 0.3}) + result = run_cash_deploy(recovery_prices, cfg) + m = result.metrics() + max_dd_mag = -m["max_drawdown"] # positive magnitude + + # Cap well above the actual drawdown => no penalty => equals plain excess CAGR. + loose = _context(recovery_prices, dd_cap=max_dd_mag + 0.10) + plain = make_objective(ObjectiveKind.EXCESS_CAGR, loose)(result, m) + capped_loose = make_objective(ObjectiveKind.EXCESS_CAGR_DD_CAPPED, loose)(result, m) + assert capped_loose == pytest.approx(plain) + + # Cap below the actual drawdown => penalty subtracts the breach. + tight = _context(recovery_prices, dd_cap=max_dd_mag - 0.05) + capped_tight = make_objective(ObjectiveKind.EXCESS_CAGR_DD_CAPPED, tight)(result, m) + assert capped_tight == pytest.approx(plain - 1.0 * 0.05) + + +# --- optimize ----------------------------------------------------------------- + + +def test_optimize_is_deterministic(recovery_prices: pd.DataFrame): + a = opt.optimize(recovery_prices, base=_base(), n_trials=12, seed=0, split=0.6) + b = opt.optimize(recovery_prices, base=_base(), n_trials=12, seed=0, split=0.6) + assert a.best == b.best + assert [s.score for s in a.leaderboard] == [s.score for s in b.leaderboard] + + +def test_optimize_split_has_no_leakage(recovery_prices: pd.DataFrame): + result = opt.optimize(recovery_prices, base=_base(), n_trials=10, seed=0, split=0.6) + assert result.test_window is not None + # Test window is strictly later than the train window (disjoint, contiguous). + assert result.train_window[1] < result.test_window[0] + assert result.test_metrics is not None + assert result.n_evaluated == 10 + assert 1 <= len(result.leaderboard) <= opt.LEADERBOARD_SIZE + + +def test_optimize_test_metrics_are_an_independent_test_run(recovery_prices: pd.DataFrame): + # The frozen best must be re-run on the test slice as its own simulation + # (engine resets peak/reserve to the slice start) β€” never sliced from train. + split = 0.6 + result = opt.optimize(recovery_prices, base=_base(), n_trials=10, seed=0, split=split) + cut = round(len(recovery_prices) * split) + test_slice = recovery_prices.iloc[cut:] + recomputed = run_cash_deploy(test_slice, result.best).metrics() + assert result.test_metrics is not None + for key, value in recomputed.items(): + assert result.test_metrics[key] == pytest.approx(value) + + +def test_optimize_full_window_has_no_holdout(recovery_prices: pd.DataFrame): + result = opt.optimize(recovery_prices, base=_base(), n_trials=8, seed=0, split=1.0) + assert result.test_window is None + assert result.test_metrics is None + assert result.test_index_metrics is None + + +def test_optimize_split_below_one_requires_a_holdout(): + # A split < 1.0 promises a train/test split; too little data must raise rather + # than silently drop the holdout. + idx = pd.bdate_range("2020-01-01", periods=3, name="date") + prices = pd.DataFrame({STOCK: [100.0, 101.0, 102.0], CASH: [100.0, 100.0, 100.0]}, index=idx) + with pytest.raises(ValueError, match="holdout"): + opt.optimize(prices, base=_base(), n_trials=3, seed=0, split=0.6) + + +def test_optimize_beats_index_and_a_dominated_config(recovery_prices: pd.DataFrame): + result = opt.optimize(recovery_prices, base=_base(), n_trials=30, seed=0, split=0.6) + train_excess = result.train_metrics["cagr"] - result.train_index_metrics["cagr"] + assert train_excess > 0.0 + + +# --- walk_forward ------------------------------------------------------------- + + +def test_walk_forward_shape_and_ordering(recovery_prices: pd.DataFrame): + wf = opt.walk_forward( + recovery_prices, base=_base(), n_folds=3, train_frac=0.5, n_trials=6, seed=0 + ) + assert len(wf.folds) == 3 + # Within each fold, test is strictly after train. + for fold in wf.folds: + assert fold.train_window[1] < fold.test_window[0] + # Folds do not overlap: each fold's test ends before the next fold's train. + for earlier, later in pairwise(wf.folds): + assert earlier.test_window[1] < later.train_window[0] + # The final fit spans the whole window with no holdout. + assert wf.final.test_window is None + assert 0 <= wf.n_beat_index_train <= 3 + assert 0 <= wf.n_beat_index_test <= 3 + assert np.isfinite(wf.mean_test_excess_cagr) + + +def test_walk_forward_is_deterministic(recovery_prices: pd.DataFrame): + a = opt.walk_forward( + recovery_prices, base=_base(), n_folds=3, train_frac=0.5, n_trials=6, seed=0 + ) + b = opt.walk_forward( + recovery_prices, base=_base(), n_folds=3, train_frac=0.5, n_trials=6, seed=0 + ) + assert a.final.best == b.final.best + assert a.mean_test_excess_cagr == pytest.approx(b.mean_test_excess_cagr) + + +def test_walk_forward_rejects_too_many_folds(): + idx = pd.bdate_range("2020-01-01", periods=10, name="date") + prices = pd.DataFrame( + {STOCK: np.linspace(100.0, 110.0, 10), CASH: np.full(10, 100.0)}, index=idx + ) + with pytest.raises(ValueError, match="not enough data"): + opt.walk_forward(prices, base=_base(), n_folds=5, n_trials=3) diff --git a/uv.lock b/uv.lock index 53dffbb..bd80c60 100644 --- a/uv.lock +++ b/uv.lock @@ -10,6 +10,20 @@ resolution-markers = [ "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] +[[package]] +name = "alembic" +version = "1.18.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" }, +] + [[package]] name = "altair" version = "6.2.2" @@ -166,6 +180,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "colorlog" +version = "6.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/61/f083b5ac52e505dfc1c624eafbf8c7589a0d7f32daa398d2e7590efa5fda/colorlog-6.10.1.tar.gz", hash = "sha256:eb4ae5cb65fe7fec7773c2306061a8e63e02efc2c72eba9d27b0fa23c94f1321", size = 17162, upload-time = "2025-10-16T16:14:11.978Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/c1/e419ef3723a074172b68aaa89c9f3de486ed4c2399e2dbd8113a4fdcaf9e/colorlog-6.10.1-py3-none-any.whl", hash = "sha256:2d7e8348291948af66122cff006c9f8da6255d224e7cf8e37d8de2df3bad8c9c", size = 11743, upload-time = "2025-10-16T16:14:10.512Z" }, +] + [[package]] name = "coverage" version = "7.15.0" @@ -259,6 +285,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, ] +[[package]] +name = "greenlet" +version = "3.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" }, + { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" }, + { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" }, + { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" }, + { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" }, + { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" }, + { url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" }, + { url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" }, + { url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" }, + { url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605", size = 239549, upload-time = "2026-06-26T18:22:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" }, + { url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" }, + { url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" }, + { url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" }, + { url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" }, + { url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" }, + { url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227", size = 240085, upload-time = "2026-06-26T18:23:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" }, + { url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" }, + { url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" }, + { url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31", size = 242586, upload-time = "2026-06-26T18:23:37.93Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -370,6 +451,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -493,6 +586,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, ] +[[package]] +name = "optuna" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alembic" }, + { name = "colorlog" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "sqlalchemy" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/aa/05f5e3f662cc96a4c478fc3446b8ed6359825a2b504ecb614a9ac84e4a4d/optuna-4.9.0.tar.gz", hash = "sha256:b322e5cbdf1655fb84c37646c4a7a1f391de1b47806bbe222e015825d0a82b87", size = 485834, upload-time = "2026-06-01T06:23:30.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/f3/e5fcd5d9b15771ed6dc10e3a7eeddc672e418f4f4c4653d216cc1d857e2d/optuna-4.9.0-py3-none-any.whl", hash = "sha256:f52f3be6148654850c92a5860d398fd88ec6b2c84ab68d9c3d07dcff02e7afee", size = 425553, upload-time = "2026-06-01T06:23:28.804Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -675,6 +786,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "numpy" }, + { name = "optuna" }, { name = "pandas" }, { name = "plotly" }, { name = "pyarrow" }, @@ -694,6 +806,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "numpy", specifier = ">=1.26" }, + { name = "optuna", specifier = ">=3.6" }, { name = "pandas", specifier = ">=2.2" }, { name = "plotly", specifier = ">=5.22" }, { name = "poethepoet", marker = "extra == 'dev'", specifier = ">=0.27" }, @@ -1142,6 +1255,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + [[package]] name = "starlette" version = "1.3.1" @@ -1208,6 +1362,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, ] +[[package]] +name = "tqdm" +version = "4.68.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/5f/57ff8b434839e70dab45601284ea413e947a63799891b7553e5960a793a8/tqdm-4.68.4.tar.gz", hash = "sha256:19829c9673638f2a0b8617da4cdcb927e831cd88bcfcb6e78d42a4d1af131520", size = 792418, upload-time = "2026-07-07T09:58:18.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/2a/5e5e750890ada51017d18d0d4c30da696e5b5bd3180947729927628fc3cb/tqdm-4.68.4-py3-none-any.whl", hash = "sha256:5168118b2368f48c561afda8020fd79195b1bdb0bdf8086b88442c267a315dc2", size = 676612, upload-time = "2026-07-07T09:58:16.256Z" }, +] + [[package]] name = "ty" version = "0.0.58"