Browse Source

feat: add local paper trading and sampled reports

lxy 1 tháng trước cách đây
mục cha
commit
2f6ccd2691

+ 12 - 2
README.md

@@ -1,6 +1,6 @@
 # okx-codex-trader
 
-Minimal project skeleton for the OKX Codex Trader demo CLI.
+Minimal project skeleton for an OKX public-data and local paper-trading CLI.
 
 ## CLI usage
 
@@ -8,13 +8,23 @@ Minimal project skeleton for the OKX Codex Trader demo CLI.
 python -m okx_codex_trader.cli fetch-history --symbol BTC-USDT-SWAP --bar 1H --limit 50
 python -m okx_codex_trader.cli backtest --symbol BTC-USDT-SWAP --bar 1H --limit 200 --leverage 2
 python -m okx_codex_trader.cli analyze --symbol BTC-USDT-SWAP --bar 1H --limit 50 --output-file signal.json
+python -m okx_codex_trader.cli backtest-report --symbol BTC-USDT-SWAP --bar 1H --limit 500 --leverage 2 --output-file backtest-report.html
+python -m okx_codex_trader.cli backtest-bbmr-report --symbol BTC-USDT-SWAP --bar 3m --history-limit 5000 --leverage 2 --segments 8 --window-size 300 --output-file bbmr-sampled-report.html
+python -m okx_codex_trader.cli backtest-bbsb-report --symbol BTC-USDT-SWAP --bar 3m --history-limit 5000 --leverage 2 --segments 8 --window-size 300 --output-file bbsb-sampled-report.html
+python -m okx_codex_trader.cli backtest-donchian-report --symbol BTC-USDT-SWAP --bar 3m --history-limit 5000 --leverage 2 --segments 8 --window-size 300 --entry-window 20 --exit-window 10 --stop-loss-pct 0.01 --output-file donchian-sampled-report.html
+python -m okx_codex_trader.cli backtest-rsi2-report --symbol BTC-USDT-SWAP --bar 3m --history-limit 5000 --leverage 2 --segments 8 --window-size 300 --trend-sma 50 --rsi-length 2 --rsi-long-threshold 10 --rsi-short-threshold 90 --exit-rsi 50 --output-file rsi2-sampled-report.html
+python -m okx_codex_trader.cli backtest-ema-pullback-report --symbol BTC-USDT-SWAP --bar 3m --history-limit 5000 --leverage 2 --segments 8 --window-size 300 --fast-ema 20 --slow-ema 50 --stop-buffer-pct 0.005 --output-file ema-pullback-sampled-report.html
 python -m okx_codex_trader.cli paper-order --symbol BTC-USDT-SWAP --signal-file signal.json --margin-usdt 100
 python -m okx_codex_trader.cli positions --symbol BTC-USDT-SWAP
 ```
 
 Supported symbols are `BTC-USDT-SWAP` and `ETH-USDT-SWAP`. Backtest leverage is restricted to `1`, `2`, or `3`.
 
+Sampled reports generate one self-contained HTML file with switchable sampled windows, trade journals, price/equity charts, and aggregate metrics.
+
+`fetch-history`, `backtest`, and `paper-order` use public OKX market data only. `paper-order` and `positions` persist local simulated state in `paper_state.json`.
+
 ## Verification notes
 
-- OKX demo credentials were not exercised in automated tests.
+- Public OKX market-data calls were exercised in automated tests through the client contract and CLI flow.
 - Local `codex` runtime behavior outside mocked subprocess tests still requires manual verification.

+ 241 - 0
okx_codex_trader/bbmr_report.py

@@ -0,0 +1,241 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from statistics import median
+
+import pandas as pd
+
+from okx_codex_trader.models import Candle
+from okx_codex_trader.sampled_report import (
+    SegmentResult,
+    generate_sampled_report,
+    mark_to_market as _mark_to_market,
+    trade_equity as _trade_equity,
+)
+
+BBMR_STRATEGY_DESCRIPTION = (
+    "Bollinger Band mean reversion, bandwidth filter against previous 50 completed values, "
+    "close-based return-to-middle exits at next open, intrabar 0.5% stop-loss."
+)
+
+
+@dataclass(frozen=True)
+class BBMRConfig:
+    band_length: int = 20
+    std_multiplier: float = 2.0
+    bandwidth_lookback: int = 50
+    stop_loss_pct: float = 0.005
+    initial_equity: float = 10_000.0
+
+
+def _format_ts(ts: int) -> str:
+    return pd.to_datetime(ts, unit="ms", utc=True).strftime("%Y-%m-%d %H:%M")
+
+
+def run_bbmr_segment(
+    *,
+    candles: list[Candle],
+    leverage: int,
+    warmup_bars: int,
+    config: BBMRConfig = BBMRConfig(),
+) -> SegmentResult:
+    closes = pd.Series([candle.close for candle in candles], dtype=float)
+    middle = closes.rolling(config.band_length).mean().tolist()
+    stdev = closes.rolling(config.band_length).std(ddof=0).tolist()
+    upper = [
+        None if middle_value != middle_value or std_value != std_value else middle_value + config.std_multiplier * std_value
+        for middle_value, std_value in zip(middle, stdev)
+    ]
+    lower = [
+        None if middle_value != middle_value or std_value != std_value else middle_value - config.std_multiplier * std_value
+        for middle_value, std_value in zip(middle, stdev)
+    ]
+    bandwidth = [
+        None
+        if upper_value is None or lower_value is None or middle_value in (None, 0)
+        else (upper_value - lower_value) / middle_value
+        for upper_value, lower_value, middle_value in zip(upper, lower, middle)
+    ]
+
+    equity = config.initial_equity
+    ending_equity = equity
+    peak_equity = equity
+    max_drawdown = 0.0
+    wins = 0
+    trades: list[dict[str, object]] = []
+    entries: list[dict[str, object]] = []
+    exits: list[dict[str, object]] = []
+    equity_curve: list[dict[str, float | int]] = []
+    position: dict[str, object] | None = None
+    pending_entry_side: str | None = None
+    pending_exit = False
+
+    for index in range(warmup_bars, len(candles)):
+        candle = candles[index]
+
+        if pending_exit and position is not None:
+            exit_price = candle.open
+            exit_equity = _trade_equity(
+                side=str(position["side"]),
+                margin_used=float(position["margin_used"]),
+                entry_price=float(position["entry_price"]),
+                exit_price=exit_price,
+                leverage=leverage,
+            )
+            trades.append(
+                {
+                    "side": "Long" if position["side"] == "long" else "Short",
+                    "entry_time": _format_ts(int(position["entry_time"])),
+                    "exit_time": _format_ts(candle.ts),
+                    "entry_price": round(float(position["entry_price"]), 4),
+                    "exit_price": round(exit_price, 4),
+                    "pnl": round(exit_equity - float(position["margin_used"]), 4),
+                    "return_pct": round((exit_equity - float(position["margin_used"])) / float(position["margin_used"]) * 100, 4),
+                }
+            )
+            exits.append({"ts": candle.ts, "price": exit_price, "side": position["side"]})
+            if exit_equity > float(position["margin_used"]):
+                wins += 1
+            equity = exit_equity
+            position = None
+            pending_exit = False
+
+        if pending_entry_side is not None and position is None:
+            entry_price = candle.open
+            margin_used = equity
+            stop_multiplier = 1 - config.stop_loss_pct if pending_entry_side == "long" else 1 + config.stop_loss_pct
+            position = {
+                "side": pending_entry_side,
+                "entry_time": candle.ts,
+                "entry_price": entry_price,
+                "entry_index": index,
+                "margin_used": margin_used,
+                "stop_price": entry_price * stop_multiplier,
+            }
+            entries.append({"ts": candle.ts, "price": entry_price, "side": pending_entry_side})
+            pending_entry_side = None
+
+        current_equity = equity
+        if position is not None and index > int(position["entry_index"]):
+            stop_hit = (
+                position["side"] == "long" and candle.low <= float(position["stop_price"])
+            ) or (
+                position["side"] == "short" and candle.high >= float(position["stop_price"])
+            )
+            if stop_hit:
+                exit_price = float(position["stop_price"])
+                exit_equity = _trade_equity(
+                    side=str(position["side"]),
+                    margin_used=float(position["margin_used"]),
+                    entry_price=float(position["entry_price"]),
+                    exit_price=exit_price,
+                    leverage=leverage,
+                )
+                trades.append(
+                    {
+                        "side": "Long" if position["side"] == "long" else "Short",
+                        "entry_time": _format_ts(int(position["entry_time"])),
+                        "exit_time": _format_ts(candle.ts),
+                        "entry_price": round(float(position["entry_price"]), 4),
+                        "exit_price": round(exit_price, 4),
+                        "pnl": round(exit_equity - float(position["margin_used"]), 4),
+                        "return_pct": round((exit_equity - float(position["margin_used"])) / float(position["margin_used"]) * 100, 4),
+                    }
+                )
+                exits.append({"ts": candle.ts, "price": exit_price, "side": position["side"]})
+                if exit_equity > float(position["margin_used"]):
+                    wins += 1
+                equity = exit_equity
+                current_equity = exit_equity
+                position = None
+                if current_equity > peak_equity:
+                    peak_equity = current_equity
+                max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
+                equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
+                ending_equity = current_equity
+                continue
+
+        if position is not None:
+            current_equity = _mark_to_market(
+                side=str(position["side"]),
+                margin_used=float(position["margin_used"]),
+                entry_price=float(position["entry_price"]),
+                mark_price=candle.close,
+                leverage=leverage,
+            )
+
+        if current_equity > peak_equity:
+            peak_equity = current_equity
+        max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
+        equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
+        ending_equity = current_equity
+
+        if index == len(candles) - 1:
+            continue
+
+        if position is not None:
+            exit_signal = (
+                position["side"] == "long" and middle[index] is not None and candle.close >= float(middle[index])
+            ) or (
+                position["side"] == "short" and middle[index] is not None and candle.close <= float(middle[index])
+            )
+            if exit_signal:
+                pending_exit = True
+            continue
+
+        previous_bandwidths = [value for value in bandwidth[max(0, index - config.bandwidth_lookback) : index] if value is not None]
+        if len(previous_bandwidths) < config.bandwidth_lookback:
+            continue
+        current_bandwidth = bandwidth[index]
+        if current_bandwidth is None or current_bandwidth > median(previous_bandwidths):
+            continue
+        if lower[index] is not None and candle.close < float(lower[index]):
+            pending_entry_side = "long"
+        elif upper[index] is not None and candle.close > float(upper[index]):
+            pending_entry_side = "short"
+
+    trade_count = len(trades)
+    return SegmentResult(
+        trade_count=trade_count,
+        total_return=(ending_equity - config.initial_equity) / config.initial_equity,
+        win_rate=(wins / trade_count) if trade_count else 0.0,
+        max_drawdown=max_drawdown,
+        trades=trades,
+        open_position=position,
+        candles=candles[warmup_bars:],
+        equity_curve=equity_curve,
+        entries=entries,
+        exits=exits,
+    )
+
+
+def generate_bbmr_sampled_report(
+    *,
+    candles: list[Candle],
+    leverage: int,
+    output_file: Path,
+    symbol: str,
+    bar: str,
+    segments: int,
+    window_size: int,
+) -> dict[str, object]:
+    return generate_sampled_report(
+        candles=candles,
+        leverage=leverage,
+        output_file=output_file,
+        symbol=symbol,
+        bar=bar,
+        segments=segments,
+        window_size=window_size,
+        report_title="BBMR Sampled Report",
+        strategy_label="BBMR",
+        strategy_description=BBMR_STRATEGY_DESCRIPTION,
+        strategy_params={
+            "band_length": BBMRConfig().band_length,
+            "std_multiplier": BBMRConfig().std_multiplier,
+            "bandwidth_lookback": BBMRConfig().bandwidth_lookback,
+            "stop_loss_pct": BBMRConfig().stop_loss_pct,
+        },
+        run_segment=run_bbmr_segment,
+    )

+ 248 - 0
okx_codex_trader/bbsb_report.py

@@ -0,0 +1,248 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from statistics import median
+
+import pandas as pd
+
+from okx_codex_trader.models import Candle
+from okx_codex_trader.sampled_report import (
+    SegmentResult,
+    generate_sampled_report,
+    mark_to_market as _mark_to_market,
+    trade_equity as _trade_equity,
+)
+
+
+WARMUP_BARS = 69
+
+
+@dataclass(frozen=True)
+class BBSBConfig:
+    band_length: int = 20
+    std_multiplier: float = 2.0
+    bandwidth_lookback: int = 50
+    take_profit_pct: float = 0.01
+    stop_loss_pct: float = 0.005
+    initial_equity: float = 10_000.0
+
+
+BBSB_STRATEGY_DESCRIPTION = (
+    "Bollinger Band squeeze breakout, bandwidth filter against previous 50 completed values, "
+    "next-open entries, intrabar 1.0% take-profit, intrabar 0.5% stop-loss."
+)
+
+
+def _format_ts(ts: int) -> str:
+    return pd.to_datetime(ts, unit="ms", utc=True).strftime("%Y-%m-%d %H:%M")
+
+
+def run_bbsb_segment(
+    *,
+    candles: list[Candle],
+    leverage: int,
+    warmup_bars: int,
+    config: BBSBConfig = BBSBConfig(),
+) -> SegmentResult:
+    closes = pd.Series([candle.close for candle in candles], dtype=float)
+    middle = closes.rolling(config.band_length).mean().tolist()
+    stdev = closes.rolling(config.band_length).std(ddof=0).tolist()
+    upper = [
+        None if middle_value != middle_value or std_value != std_value else middle_value + config.std_multiplier * std_value
+        for middle_value, std_value in zip(middle, stdev)
+    ]
+    lower = [
+        None if middle_value != middle_value or std_value != std_value else middle_value - config.std_multiplier * std_value
+        for middle_value, std_value in zip(middle, stdev)
+    ]
+    bandwidth = [
+        None
+        if upper_value is None or lower_value is None or middle_value in (None, 0)
+        else (upper_value - lower_value) / middle_value
+        for upper_value, lower_value, middle_value in zip(upper, lower, middle)
+    ]
+
+    equity = config.initial_equity
+    ending_equity = equity
+    peak_equity = equity
+    max_drawdown = 0.0
+    wins = 0
+    trades: list[dict[str, object]] = []
+    entries: list[dict[str, object]] = []
+    exits: list[dict[str, object]] = []
+    equity_curve: list[dict[str, float | int]] = []
+    position: dict[str, object] | None = None
+    pending_entry_side: str | None = None
+
+    for index in range(warmup_bars, len(candles)):
+        candle = candles[index]
+
+        if pending_entry_side is not None and position is None:
+            entry_price = candle.open
+            margin_used = equity
+            position = {
+                "side": pending_entry_side,
+                "entry_time": candle.ts,
+                "entry_price": entry_price,
+                "entry_index": index,
+                "margin_used": margin_used,
+                "stop_price": entry_price * (1 - config.stop_loss_pct if pending_entry_side == "long" else 1 + config.stop_loss_pct),
+                "take_profit_price": entry_price * (1 + config.take_profit_pct if pending_entry_side == "long" else 1 - config.take_profit_pct),
+            }
+            entries.append({"ts": candle.ts, "price": entry_price, "side": pending_entry_side})
+            pending_entry_side = None
+
+        current_equity = equity
+        if position is not None and index > int(position["entry_index"]):
+            stop_hit = (
+                position["side"] == "long" and candle.low <= float(position["stop_price"])
+            ) or (
+                position["side"] == "short" and candle.high >= float(position["stop_price"])
+            )
+            if stop_hit:
+                exit_price = float(position["stop_price"])
+                exit_equity = _trade_equity(
+                    side=str(position["side"]),
+                    margin_used=float(position["margin_used"]),
+                    entry_price=float(position["entry_price"]),
+                    exit_price=exit_price,
+                    leverage=leverage,
+                )
+                trades.append(
+                    {
+                        "side": "Long" if position["side"] == "long" else "Short",
+                        "entry_time": _format_ts(int(position["entry_time"])),
+                        "exit_time": _format_ts(candle.ts),
+                        "entry_price": round(float(position["entry_price"]), 4),
+                        "exit_price": round(exit_price, 4),
+                        "pnl": round(exit_equity - float(position["margin_used"]), 4),
+                        "return_pct": round((exit_equity - float(position["margin_used"])) / float(position["margin_used"]) * 100, 4),
+                    }
+                )
+                exits.append({"ts": candle.ts, "price": exit_price, "side": position["side"]})
+                if exit_equity > float(position["margin_used"]):
+                    wins += 1
+                equity = exit_equity
+                current_equity = exit_equity
+                position = None
+                if current_equity > peak_equity:
+                    peak_equity = current_equity
+                max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
+                equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
+                ending_equity = current_equity
+                continue
+
+            take_profit_hit = (
+                position["side"] == "long" and candle.high >= float(position["take_profit_price"])
+            ) or (
+                position["side"] == "short" and candle.low <= float(position["take_profit_price"])
+            )
+            if take_profit_hit:
+                exit_price = float(position["take_profit_price"])
+                exit_equity = _trade_equity(
+                    side=str(position["side"]),
+                    margin_used=float(position["margin_used"]),
+                    entry_price=float(position["entry_price"]),
+                    exit_price=exit_price,
+                    leverage=leverage,
+                )
+                trades.append(
+                    {
+                        "side": "Long" if position["side"] == "long" else "Short",
+                        "entry_time": _format_ts(int(position["entry_time"])),
+                        "exit_time": _format_ts(candle.ts),
+                        "entry_price": round(float(position["entry_price"]), 4),
+                        "exit_price": round(exit_price, 4),
+                        "pnl": round(exit_equity - float(position["margin_used"]), 4),
+                        "return_pct": round((exit_equity - float(position["margin_used"])) / float(position["margin_used"]) * 100, 4),
+                    }
+                )
+                exits.append({"ts": candle.ts, "price": exit_price, "side": position["side"]})
+                if exit_equity > float(position["margin_used"]):
+                    wins += 1
+                equity = exit_equity
+                current_equity = exit_equity
+                position = None
+                if current_equity > peak_equity:
+                    peak_equity = current_equity
+                max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
+                equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
+                ending_equity = current_equity
+                continue
+
+        if position is not None:
+            current_equity = _mark_to_market(
+                side=str(position["side"]),
+                margin_used=float(position["margin_used"]),
+                entry_price=float(position["entry_price"]),
+                mark_price=candle.close,
+                leverage=leverage,
+            )
+
+        if current_equity > peak_equity:
+            peak_equity = current_equity
+        max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
+        equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
+        ending_equity = current_equity
+
+        if index == len(candles) - 1 or position is not None:
+            continue
+
+        previous_bandwidths = [value for value in bandwidth[max(0, index - config.bandwidth_lookback) : index] if value is not None]
+        if len(previous_bandwidths) < config.bandwidth_lookback:
+            continue
+        current_bandwidth = bandwidth[index]
+        if current_bandwidth is None or current_bandwidth > median(previous_bandwidths):
+            continue
+        if upper[index] is not None and candle.close > float(upper[index]):
+            pending_entry_side = "long"
+        elif lower[index] is not None and candle.close < float(lower[index]):
+            pending_entry_side = "short"
+
+    trade_count = len(trades)
+    return SegmentResult(
+        trade_count=trade_count,
+        total_return=(ending_equity - config.initial_equity) / config.initial_equity,
+        win_rate=(wins / trade_count) if trade_count else 0.0,
+        max_drawdown=max_drawdown,
+        trades=trades,
+        open_position=position,
+        candles=candles[warmup_bars:],
+        equity_curve=equity_curve,
+        entries=entries,
+        exits=exits,
+    )
+
+
+def generate_bbsb_sampled_report(
+    *,
+    candles: list[Candle],
+    leverage: int,
+    output_file: Path,
+    symbol: str,
+    bar: str,
+    segments: int,
+    window_size: int,
+) -> dict[str, object]:
+    return generate_sampled_report(
+        candles=candles,
+        leverage=leverage,
+        output_file=output_file,
+        symbol=symbol,
+        bar=bar,
+        segments=segments,
+        window_size=window_size,
+        report_title="BBSB Sampled Report",
+        strategy_label="BBSB",
+        strategy_description=BBSB_STRATEGY_DESCRIPTION,
+        strategy_params={
+            "band_length": BBSBConfig().band_length,
+            "std_multiplier": BBSBConfig().std_multiplier,
+            "bandwidth_lookback": BBSBConfig().bandwidth_lookback,
+            "take_profit_pct": BBSBConfig().take_profit_pct,
+            "stop_loss_pct": BBSBConfig().stop_loss_pct,
+        },
+        run_segment=run_bbsb_segment,
+        warmup_bars=WARMUP_BARS,
+    )

+ 151 - 6
okx_codex_trader/cli.py

@@ -1,5 +1,6 @@
 import argparse
 import json
+from datetime import UTC, datetime
 from dataclasses import asdict
 from pathlib import Path
 from typing import Callable, Sequence
@@ -7,11 +8,85 @@ from typing import Callable, Sequence
 from okx_codex_trader.backtest import run_backtest
 from okx_codex_trader.codex_analyzer import analyze_with_codex
 from okx_codex_trader.config import Config, load_config
+from okx_codex_trader.bbmr_report import generate_bbmr_sampled_report
+from okx_codex_trader.bbsb_report import generate_bbsb_sampled_report
+from okx_codex_trader.donchian_report import DonchianConfig, generate_donchian_sampled_report
+from okx_codex_trader.ema_pullback_report import EMAPullbackConfig, generate_ema_pullback_sampled_report
+from okx_codex_trader.rsi2_report import RSI2Config, generate_rsi2_sampled_report
+from okx_codex_trader.paper_engine import apply_signal, load_state, save_state
 from okx_codex_trader.okx_client import OkxClient
+from okx_codex_trader.report import generate_backtest_report
 from okx_codex_trader.strategy import validate_signal
 
 
 SUPPORTED_SYMBOLS = ("BTC-USDT-SWAP", "ETH-USDT-SWAP")
+SAMPLED_REPORT_COMMANDS = {
+    "backtest-bbmr-report": {
+        "parser_args": (),
+        "strategy_params": lambda args: {},
+    },
+    "backtest-bbsb-report": {
+        "parser_args": (),
+        "strategy_params": lambda args: {},
+    },
+    "backtest-donchian-report": {
+        "parser_args": (
+            (("--entry-window",), {"type": int, "default": DonchianConfig.entry_window}),
+            (("--exit-window",), {"type": int, "default": DonchianConfig.exit_window}),
+            (("--stop-loss-pct",), {"type": float, "default": DonchianConfig.stop_loss_pct}),
+        ),
+        "strategy_params": lambda args: {
+            "entry_window": args.entry_window,
+            "exit_window": args.exit_window,
+            "stop_loss_pct": args.stop_loss_pct,
+        },
+    },
+    "backtest-rsi2-report": {
+        "parser_args": (
+            (("--trend-sma",), {"type": int, "default": RSI2Config.trend_sma}),
+            (("--rsi-length",), {"type": int, "default": RSI2Config.rsi_length}),
+            (("--rsi-long-threshold",), {"type": float, "default": RSI2Config.rsi_long_threshold}),
+            (("--rsi-short-threshold",), {"type": float, "default": RSI2Config.rsi_short_threshold}),
+            (("--exit-rsi",), {"type": float, "default": RSI2Config.exit_rsi}),
+        ),
+        "strategy_params": lambda args: {
+            "trend_sma": args.trend_sma,
+            "rsi_length": args.rsi_length,
+            "rsi_long_threshold": args.rsi_long_threshold,
+            "rsi_short_threshold": args.rsi_short_threshold,
+            "exit_rsi": args.exit_rsi,
+        },
+    },
+    "backtest-ema-pullback-report": {
+        "parser_args": (
+            (("--fast-ema",), {"type": int, "default": EMAPullbackConfig.fast_ema}),
+            (("--slow-ema",), {"type": int, "default": EMAPullbackConfig.slow_ema}),
+            (("--stop-buffer-pct",), {"type": float, "default": EMAPullbackConfig.stop_buffer_pct}),
+        ),
+        "strategy_params": lambda args: {
+            "fast_ema": args.fast_ema,
+            "slow_ema": args.slow_ema,
+            "stop_buffer_pct": args.stop_buffer_pct,
+        },
+    },
+}
+
+
+def _add_sampled_report_parser(
+    subparsers: argparse._SubParsersAction,
+    command: str,
+    parser_args: tuple[tuple[tuple[str, ...], dict[str, object]], ...],
+) -> None:
+    parser = subparsers.add_parser(command)
+    parser.add_argument("--symbol", choices=SUPPORTED_SYMBOLS, required=True)
+    parser.add_argument("--bar", required=True)
+    parser.add_argument("--history-limit", type=int, required=True)
+    parser.add_argument("--leverage", type=int, choices=(1, 2, 3), required=True)
+    parser.add_argument("--segments", type=int, required=True)
+    parser.add_argument("--window-size", type=int, required=True)
+    parser.add_argument("--output-file", required=True)
+    for flags, kwargs in parser_args:
+        parser.add_argument(*flags, **kwargs)
 
 
 def build_parser() -> argparse.ArgumentParser:
@@ -29,6 +104,16 @@ def build_parser() -> argparse.ArgumentParser:
     backtest.add_argument("--limit", type=int, required=True)
     backtest.add_argument("--leverage", type=int, choices=(1, 2, 3), required=True)
 
+    backtest_report = subparsers.add_parser("backtest-report")
+    backtest_report.add_argument("--symbol", choices=SUPPORTED_SYMBOLS, required=True)
+    backtest_report.add_argument("--bar", required=True)
+    backtest_report.add_argument("--limit", type=int, required=True)
+    backtest_report.add_argument("--leverage", type=int, choices=(1, 2, 3), required=True)
+    backtest_report.add_argument("--output-file", required=True)
+
+    for command, settings in SAMPLED_REPORT_COMMANDS.items():
+        _add_sampled_report_parser(subparsers, command, settings["parser_args"])
+
     analyze = subparsers.add_parser("analyze")
     analyze.add_argument("--symbol", choices=SUPPORTED_SYMBOLS, required=True)
     analyze.add_argument("--bar", required=True)
@@ -54,18 +139,37 @@ def _dump_json(payload: object) -> str:
     return json.dumps(payload, indent=2)
 
 
+def _now_iso() -> str:
+    return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
+
+
 def main_factory(
     *,
     load_config: Callable[[], Config] = load_config,
-    client_factory: Callable[[Config], OkxClient] = OkxClient,
+    client_factory: Callable[[], OkxClient] = OkxClient,
     analyze_fn: Callable = analyze_with_codex,
+    report_fn: Callable = generate_backtest_report,
+    bbmr_report_fn: Callable = generate_bbmr_sampled_report,
+    bbsb_report_fn: Callable = generate_bbsb_sampled_report,
+    donchian_report_fn: Callable = generate_donchian_sampled_report,
+    rsi2_report_fn: Callable = generate_rsi2_sampled_report,
+    ema_pullback_report_fn: Callable = generate_ema_pullback_sampled_report,
     write_text: Callable[[str, str], None] = _write_text,
+    state_path: Path = Path("paper_state.json"),
+    now_fn: Callable[[], str] = _now_iso,
 ):
+    sampled_report_generators = {
+        "backtest-bbmr-report": bbmr_report_fn,
+        "backtest-bbsb-report": bbsb_report_fn,
+        "backtest-donchian-report": donchian_report_fn,
+        "backtest-rsi2-report": rsi2_report_fn,
+        "backtest-ema-pullback-report": ema_pullback_report_fn,
+    }
+
     def main(argv: Sequence[str] | None = None) -> int:
         parser = build_parser()
         args = parser.parse_args(argv)
-        config = load_config()
-        client = client_factory(config)
+        client = client_factory()
 
         if args.command == "fetch-history":
             candles = client.get_candles(args.symbol, args.bar, args.limit)
@@ -77,6 +181,33 @@ def main_factory(
             print(_dump_json(run_backtest(candles=candles, leverage=args.leverage).to_dict()))
             return 0
 
+        if args.command == "backtest-report":
+            candles = client.get_candles(args.symbol, args.bar, args.limit)
+            report = report_fn(
+                candles=candles,
+                leverage=args.leverage,
+                output_file=Path(args.output_file),
+                symbol=args.symbol,
+                bar=args.bar,
+            )
+            print(_dump_json(report))
+            return 0
+
+        if args.command in SAMPLED_REPORT_COMMANDS:
+            candles = client.get_candles(args.symbol, args.bar, args.history_limit)
+            report = sampled_report_generators[args.command](
+                candles=candles,
+                leverage=args.leverage,
+                output_file=Path(args.output_file),
+                symbol=args.symbol,
+                bar=args.bar,
+                segments=args.segments,
+                window_size=args.window_size,
+                **SAMPLED_REPORT_COMMANDS[args.command]["strategy_params"](args),
+            )
+            print(_dump_json(report))
+            return 0
+
         if args.command == "analyze":
             candles = client.get_candles(args.symbol, args.bar, args.limit)
             signal = analyze_fn(candles=candles, symbol=args.symbol, bar=args.bar)
@@ -86,12 +217,26 @@ def main_factory(
             return 0
 
         if args.command == "paper-order":
+            state = load_state(state_path)
             signal = validate_signal(json.loads(Path(args.signal_file).read_text()))
-            print(_dump_json(asdict(client.place_demo_order(args.symbol, signal, args.margin_usdt))))
+            price = signal.entry_price if signal.entry_price is not None else client.get_last_price(args.symbol)
+            next_state, order = apply_signal(
+                state=state,
+                symbol=args.symbol,
+                signal=signal,
+                margin_usdt=args.margin_usdt,
+                price=price,
+                now=now_fn,
+            )
+            save_state(state_path, next_state)
+            print(_dump_json(asdict(order)))
             return 0
 
-        positions = client.get_positions(args.symbol)
-        print(_dump_json([asdict(position) for position in positions]))
+        state = load_state(state_path)
+        positions = [asdict(position) for position in state.positions if position.symbol == args.symbol]
+        if not state_path.exists():
+            save_state(state_path, state)
+        print(_dump_json(positions))
         return 0
 
     return main

+ 247 - 0
okx_codex_trader/donchian_report.py

@@ -0,0 +1,247 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+
+import pandas as pd
+
+from okx_codex_trader.models import Candle
+from okx_codex_trader.sampled_report import (
+    SegmentResult,
+    generate_sampled_report,
+    mark_to_market as _mark_to_market,
+    trade_equity as _trade_equity,
+)
+
+
+DONCHIAN_STRATEGY_DESCRIPTION = (
+    "Donchian channel breakout, previous-window close breakout entries at next open, "
+    "opposite-channel exits at next open, intrabar 1.0% stop-loss."
+)
+
+
+@dataclass(frozen=True)
+class DonchianConfig:
+    entry_window: int = 20
+    exit_window: int = 10
+    stop_loss_pct: float = 0.01
+    initial_equity: float = 10_000.0
+
+
+def _format_ts(ts: int) -> str:
+    return pd.to_datetime(ts, unit="ms", utc=True).strftime("%Y-%m-%d %H:%M")
+
+
+def run_donchian_segment(
+    *,
+    candles: list[Candle],
+    leverage: int,
+    warmup_bars: int,
+    config: DonchianConfig = DonchianConfig(),
+) -> SegmentResult:
+    highs = pd.Series([candle.high for candle in candles], dtype=float)
+    lows = pd.Series([candle.low for candle in candles], dtype=float)
+    entry_high = highs.shift(1).rolling(config.entry_window).max().tolist()
+    entry_low = lows.shift(1).rolling(config.entry_window).min().tolist()
+    exit_high = highs.shift(1).rolling(config.exit_window).max().tolist()
+    exit_low = lows.shift(1).rolling(config.exit_window).min().tolist()
+
+    equity = config.initial_equity
+    ending_equity = equity
+    peak_equity = equity
+    max_drawdown = 0.0
+    wins = 0
+    trades: list[dict[str, object]] = []
+    entries: list[dict[str, object]] = []
+    exits: list[dict[str, object]] = []
+    equity_curve: list[dict[str, float | int]] = []
+    position: dict[str, object] | None = None
+    pending_entry_side: str | None = None
+    pending_exit = False
+
+    for index in range(warmup_bars, len(candles)):
+        candle = candles[index]
+
+        if pending_exit and position is not None:
+            exit_price = candle.open
+            exit_equity = _trade_equity(
+                side=str(position["side"]),
+                margin_used=float(position["margin_used"]),
+                entry_price=float(position["entry_price"]),
+                exit_price=exit_price,
+                leverage=leverage,
+            )
+            trades.append(
+                {
+                    "side": "Long" if position["side"] == "long" else "Short",
+                    "entry_time": _format_ts(int(position["entry_time"])),
+                    "exit_time": _format_ts(candle.ts),
+                    "entry_price": round(float(position["entry_price"]), 4),
+                    "exit_price": round(exit_price, 4),
+                    "pnl": round(exit_equity - float(position["margin_used"]), 4),
+                    "return_pct": round(
+                        (exit_equity - float(position["margin_used"])) / float(position["margin_used"]) * 100,
+                        4,
+                    ),
+                }
+            )
+            exits.append({"ts": candle.ts, "price": exit_price, "side": position["side"]})
+            if exit_equity > float(position["margin_used"]):
+                wins += 1
+            equity = exit_equity
+            position = None
+            pending_exit = False
+
+        if pending_entry_side is not None and position is None:
+            entry_price = candle.open
+            margin_used = equity
+            stop_multiplier = 1 - config.stop_loss_pct if pending_entry_side == "long" else 1 + config.stop_loss_pct
+            position = {
+                "side": pending_entry_side,
+                "entry_time": candle.ts,
+                "entry_price": entry_price,
+                "entry_index": index,
+                "margin_used": margin_used,
+                "stop_price": entry_price * stop_multiplier,
+            }
+            entries.append({"ts": candle.ts, "price": entry_price, "side": pending_entry_side})
+            pending_entry_side = None
+
+        current_equity = equity
+        if position is not None:
+            stop_hit = (
+                position["side"] == "long" and candle.low <= float(position["stop_price"])
+            ) or (
+                position["side"] == "short" and candle.high >= float(position["stop_price"])
+            )
+            if stop_hit:
+                if position["side"] == "long" and candle.open < float(position["stop_price"]):
+                    exit_price = candle.open
+                elif position["side"] == "short" and candle.open > float(position["stop_price"]):
+                    exit_price = candle.open
+                else:
+                    exit_price = float(position["stop_price"])
+                exit_equity = _trade_equity(
+                    side=str(position["side"]),
+                    margin_used=float(position["margin_used"]),
+                    entry_price=float(position["entry_price"]),
+                    exit_price=exit_price,
+                    leverage=leverage,
+                )
+                trades.append(
+                    {
+                        "side": "Long" if position["side"] == "long" else "Short",
+                        "entry_time": _format_ts(int(position["entry_time"])),
+                        "exit_time": _format_ts(candle.ts),
+                        "entry_price": round(float(position["entry_price"]), 4),
+                        "exit_price": round(exit_price, 4),
+                        "pnl": round(exit_equity - float(position["margin_used"]), 4),
+                        "return_pct": round(
+                            (exit_equity - float(position["margin_used"])) / float(position["margin_used"]) * 100,
+                            4,
+                        ),
+                    }
+                )
+                exits.append({"ts": candle.ts, "price": exit_price, "side": position["side"]})
+                if exit_equity > float(position["margin_used"]):
+                    wins += 1
+                equity = exit_equity
+                current_equity = exit_equity
+                position = None
+                if current_equity > peak_equity:
+                    peak_equity = current_equity
+                max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
+                equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
+                ending_equity = current_equity
+                continue
+
+        if position is not None:
+            current_equity = _mark_to_market(
+                side=str(position["side"]),
+                margin_used=float(position["margin_used"]),
+                entry_price=float(position["entry_price"]),
+                mark_price=candle.close,
+                leverage=leverage,
+            )
+
+        if current_equity > peak_equity:
+            peak_equity = current_equity
+        max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
+        equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
+        ending_equity = current_equity
+
+        if index == len(candles) - 1:
+            continue
+
+        if position is not None:
+            exit_signal = (
+                position["side"] == "long" and exit_low[index] == exit_low[index] and candle.close < float(exit_low[index])
+            ) or (
+                position["side"] == "short" and exit_high[index] == exit_high[index] and candle.close > float(exit_high[index])
+            )
+            if exit_signal:
+                pending_exit = True
+            continue
+
+        if entry_high[index] == entry_high[index] and candle.close > float(entry_high[index]):
+            pending_entry_side = "long"
+        elif entry_low[index] == entry_low[index] and candle.close < float(entry_low[index]):
+            pending_entry_side = "short"
+
+    trade_count = len(trades)
+    return SegmentResult(
+        trade_count=trade_count,
+        total_return=(ending_equity - config.initial_equity) / config.initial_equity,
+        win_rate=(wins / trade_count) if trade_count else 0.0,
+        max_drawdown=max_drawdown,
+        trades=trades,
+        open_position=position,
+        candles=candles[warmup_bars:],
+        equity_curve=equity_curve,
+        entries=entries,
+        exits=exits,
+    )
+
+
+def generate_donchian_sampled_report(
+    *,
+    candles: list[Candle],
+    leverage: int,
+    output_file: Path,
+    symbol: str,
+    bar: str,
+    segments: int,
+    window_size: int,
+    entry_window: int = DonchianConfig.entry_window,
+    exit_window: int = DonchianConfig.exit_window,
+    stop_loss_pct: float = DonchianConfig.stop_loss_pct,
+) -> dict[str, object]:
+    config = DonchianConfig(
+        entry_window=entry_window,
+        exit_window=exit_window,
+        stop_loss_pct=stop_loss_pct,
+    )
+    return generate_sampled_report(
+        candles=candles,
+        leverage=leverage,
+        output_file=output_file,
+        symbol=symbol,
+        bar=bar,
+        segments=segments,
+        window_size=window_size,
+        report_title="Donchian Sampled Report",
+        strategy_label="Donchian",
+        strategy_description=DONCHIAN_STRATEGY_DESCRIPTION,
+        strategy_params={
+            "entry_window": config.entry_window,
+            "exit_window": config.exit_window,
+            "stop_loss_pct": config.stop_loss_pct,
+        },
+        run_segment=lambda *, candles, leverage, warmup_bars: run_donchian_segment(
+            candles=candles,
+            leverage=leverage,
+            warmup_bars=warmup_bars,
+            config=config,
+        ),
+        warmup_bars=max(config.entry_window, config.exit_window),
+    )

+ 251 - 0
okx_codex_trader/ema_pullback_report.py

@@ -0,0 +1,251 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+
+import pandas as pd
+
+from okx_codex_trader.models import Candle
+from okx_codex_trader.sampled_report import (
+    SegmentResult,
+    generate_sampled_report,
+    mark_to_market as _mark_to_market,
+    trade_equity as _trade_equity,
+)
+
+
+EMA_PULLBACK_STRATEGY_DESCRIPTION = (
+    "EMA pullback reclaim, fast-over-slow trend bias, next-open continuation entries after a fast-EMA reclaim, "
+    "opposite close-through-fast-EMA exits, stop beyond the signal candle."
+)
+
+
+@dataclass(frozen=True)
+class EMAPullbackConfig:
+    fast_ema: int = 20
+    slow_ema: int = 50
+    stop_buffer_pct: float = 0.005
+    initial_equity: float = 10_000.0
+
+
+def _format_ts(ts: int) -> str:
+    return pd.to_datetime(ts, unit="ms", utc=True).strftime("%Y-%m-%d %H:%M")
+
+
+def run_ema_pullback_segment(
+    *,
+    candles: list[Candle],
+    leverage: int,
+    warmup_bars: int,
+    config: EMAPullbackConfig = EMAPullbackConfig(),
+) -> SegmentResult:
+    closes = pd.Series([candle.close for candle in candles], dtype=float)
+    fast_ema = closes.ewm(span=config.fast_ema, adjust=False).mean().tolist()
+    slow_ema = closes.ewm(span=config.slow_ema, adjust=False).mean().tolist()
+
+    equity = config.initial_equity
+    ending_equity = equity
+    peak_equity = equity
+    max_drawdown = 0.0
+    wins = 0
+    trades: list[dict[str, object]] = []
+    entries: list[dict[str, object]] = []
+    exits: list[dict[str, object]] = []
+    equity_curve: list[dict[str, float | int]] = []
+    position: dict[str, object] | None = None
+    pending_entry: dict[str, object] | None = None
+    pending_exit = False
+
+    for index in range(warmup_bars, len(candles)):
+        candle = candles[index]
+
+        if pending_exit and position is not None:
+            exit_price = candle.open
+            exit_equity = _trade_equity(
+                side=str(position["side"]),
+                margin_used=float(position["margin_used"]),
+                entry_price=float(position["entry_price"]),
+                exit_price=exit_price,
+                leverage=leverage,
+            )
+            trades.append(
+                {
+                    "side": "Long" if position["side"] == "long" else "Short",
+                    "entry_time": _format_ts(int(position["entry_time"])),
+                    "exit_time": _format_ts(candle.ts),
+                    "entry_price": round(float(position["entry_price"]), 4),
+                    "exit_price": round(exit_price, 4),
+                    "pnl": round(exit_equity - float(position["margin_used"]), 4),
+                    "return_pct": round(
+                        (exit_equity - float(position["margin_used"])) / float(position["margin_used"]) * 100,
+                        4,
+                    ),
+                }
+            )
+            exits.append({"ts": candle.ts, "price": exit_price, "side": position["side"]})
+            if exit_equity > float(position["margin_used"]):
+                wins += 1
+            equity = exit_equity
+            position = None
+            pending_exit = False
+
+        if pending_entry is not None and position is None and equity > 0.0:
+            position = {
+                "side": str(pending_entry["side"]),
+                "entry_time": candle.ts,
+                "entry_price": candle.open,
+                "margin_used": equity,
+                "stop_price": float(pending_entry["stop_price"]),
+            }
+            entries.append({"ts": candle.ts, "price": candle.open, "side": str(pending_entry["side"])})
+            pending_entry = None
+
+        current_equity = equity
+        if position is not None:
+            stop_hit = (
+                position["side"] == "long" and candle.low <= float(position["stop_price"])
+            ) or (
+                position["side"] == "short" and candle.high >= float(position["stop_price"])
+            )
+            if stop_hit:
+                if position["side"] == "long" and candle.open < float(position["stop_price"]):
+                    exit_price = candle.open
+                elif position["side"] == "short" and candle.open > float(position["stop_price"]):
+                    exit_price = candle.open
+                else:
+                    exit_price = float(position["stop_price"])
+                exit_equity = _trade_equity(
+                    side=str(position["side"]),
+                    margin_used=float(position["margin_used"]),
+                    entry_price=float(position["entry_price"]),
+                    exit_price=exit_price,
+                    leverage=leverage,
+                )
+                trades.append(
+                    {
+                        "side": "Long" if position["side"] == "long" else "Short",
+                        "entry_time": _format_ts(int(position["entry_time"])),
+                        "exit_time": _format_ts(candle.ts),
+                        "entry_price": round(float(position["entry_price"]), 4),
+                        "exit_price": round(exit_price, 4),
+                        "pnl": round(exit_equity - float(position["margin_used"]), 4),
+                        "return_pct": round(
+                            (exit_equity - float(position["margin_used"])) / float(position["margin_used"]) * 100,
+                            4,
+                        ),
+                    }
+                )
+                exits.append({"ts": candle.ts, "price": exit_price, "side": position["side"]})
+                if exit_equity > float(position["margin_used"]):
+                    wins += 1
+                equity = exit_equity
+                current_equity = exit_equity
+                position = None
+                if current_equity > peak_equity:
+                    peak_equity = current_equity
+                max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
+                equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
+                ending_equity = current_equity
+                continue
+
+        if position is not None:
+            current_equity = _mark_to_market(
+                side=str(position["side"]),
+                margin_used=float(position["margin_used"]),
+                entry_price=float(position["entry_price"]),
+                mark_price=candle.close,
+                leverage=leverage,
+            )
+
+        if current_equity > peak_equity:
+            peak_equity = current_equity
+        max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
+        equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
+        ending_equity = current_equity
+
+        if index == len(candles) - 1 or equity <= 0.0:
+            continue
+
+        current_fast = fast_ema[index]
+        current_slow = slow_ema[index]
+        if current_fast != current_fast or current_slow != current_slow:
+            continue
+
+        if position is not None:
+            exit_signal = (
+                position["side"] == "long" and candle.close < float(current_fast)
+            ) or (
+                position["side"] == "short" and candle.close > float(current_fast)
+            )
+            if exit_signal:
+                pending_exit = True
+            continue
+
+        if float(current_fast) > float(current_slow) and candle.low <= float(current_fast) and candle.close > float(current_fast):
+            pending_entry = {
+                "side": "long",
+                "stop_price": candle.low * (1 - config.stop_buffer_pct),
+            }
+        elif float(current_fast) < float(current_slow) and candle.high >= float(current_fast) and candle.close < float(current_fast):
+            pending_entry = {
+                "side": "short",
+                "stop_price": candle.high * (1 + config.stop_buffer_pct),
+            }
+
+    trade_count = len(trades)
+    return SegmentResult(
+        trade_count=trade_count,
+        total_return=(ending_equity - config.initial_equity) / config.initial_equity,
+        win_rate=(wins / trade_count) if trade_count else 0.0,
+        max_drawdown=max_drawdown,
+        trades=trades,
+        open_position=position,
+        candles=candles[warmup_bars:],
+        equity_curve=equity_curve,
+        entries=entries,
+        exits=exits,
+    )
+
+
+def generate_ema_pullback_sampled_report(
+    *,
+    candles: list[Candle],
+    leverage: int,
+    output_file: Path,
+    symbol: str,
+    bar: str,
+    segments: int,
+    window_size: int,
+    fast_ema: int = EMAPullbackConfig.fast_ema,
+    slow_ema: int = EMAPullbackConfig.slow_ema,
+    stop_buffer_pct: float = EMAPullbackConfig.stop_buffer_pct,
+) -> dict[str, object]:
+    config = EMAPullbackConfig(
+        fast_ema=fast_ema,
+        slow_ema=slow_ema,
+        stop_buffer_pct=stop_buffer_pct,
+    )
+    return generate_sampled_report(
+        candles=candles,
+        leverage=leverage,
+        output_file=output_file,
+        symbol=symbol,
+        bar=bar,
+        segments=segments,
+        window_size=window_size,
+        report_title="EMA Pullback Sampled Report",
+        strategy_label="EMA Pullback",
+        strategy_description=EMA_PULLBACK_STRATEGY_DESCRIPTION,
+        strategy_params={
+            "fast_ema": config.fast_ema,
+            "slow_ema": config.slow_ema,
+            "stop_buffer_pct": config.stop_buffer_pct,
+        },
+        run_segment=lambda *, candles, leverage, warmup_bars: run_ema_pullback_segment(
+            candles=candles,
+            leverage=leverage,
+            warmup_bars=warmup_bars,
+            config=config,
+        ),
+        warmup_bars=max(config.fast_ema, config.slow_ema),
+    )

+ 28 - 0
okx_codex_trader/models.py

@@ -39,6 +39,23 @@ class Position:
     avg_price: float
 
 
+@dataclass(frozen=True)
+class PaperPosition:
+    symbol: str
+    side: str
+    quantity: float
+    avg_entry_price: float
+    margin_used: float
+
+
+@dataclass(frozen=True)
+class PaperState:
+    cash_usdt: float
+    realized_pnl: float
+    positions: list[PaperPosition]
+    updated_at: str
+
+
 @dataclass(frozen=True)
 class OrderResult:
     status: str
@@ -50,6 +67,17 @@ class OrderResult:
     size: float | None
 
 
+@dataclass(frozen=True)
+class PaperOrderResult:
+    status: str
+    symbol: str
+    side: str | None
+    price: float | None
+    quantity: float | None
+    margin_used: float | None
+    cash_usdt: float
+
+
 @dataclass(frozen=True)
 class BacktestTrade:
     direction: str

+ 53 - 36
okx_codex_trader/okx_client.py

@@ -70,7 +70,7 @@ class OkxClient:
     base_url = "https://www.okx.com"
     request_timeout = 10.0
 
-    def __init__(self, config: Config, session=None):
+    def __init__(self, config: Config | None = None, session=None):
         self.config = config
         if session is None:
             import requests
@@ -104,20 +104,22 @@ class OkxClient:
         query = urlencode(params or {})
         path_with_query = path if not query else f"{path}?{query}"
         body = "" if json_body is None else json.dumps(json_body, separators=(",", ":"))
-        signature = base64.b64encode(
-            hmac.new(
-                self.config.api_secret.encode(),
-                f"{timestamp}{method.upper()}{path_with_query}{body}".encode(),
-                hashlib.sha256,
-            ).digest()
-        ).decode()
-        headers = {
-            "OK-ACCESS-KEY": self.config.api_key,
-            "OK-ACCESS-SIGN": signature,
-            "OK-ACCESS-TIMESTAMP": timestamp,
-            "OK-ACCESS-PASSPHRASE": self.config.api_passphrase,
-            "x-simulated-trading": "1",
-        }
+        headers: dict[str, str] = {}
+        if self.config is not None:
+            signature = base64.b64encode(
+                hmac.new(
+                    self.config.api_secret.encode(),
+                    f"{timestamp}{method.upper()}{path_with_query}{body}".encode(),
+                    hashlib.sha256,
+                ).digest()
+            ).decode()
+            headers = {
+                "OK-ACCESS-KEY": self.config.api_key,
+                "OK-ACCESS-SIGN": signature,
+                "OK-ACCESS-TIMESTAMP": timestamp,
+                "OK-ACCESS-PASSPHRASE": self.config.api_passphrase,
+                "x-simulated-trading": "1",
+            }
         if json_body is not None:
             headers["Content-Type"] = "application/json"
         try:
@@ -147,27 +149,42 @@ class OkxClient:
         return data
 
     def get_candles(self, symbol: str, bar: str, limit: int) -> list[Candle]:
-        data = self._request(
-            "GET",
-            "/api/v5/market/history-candles",
-            params={"instId": symbol, "bar": bar, "limit": limit},
-        )
-        try:
-            candles = [
-                Candle(
-                    symbol=symbol,
-                    ts=int(entry[0]),
-                    open=_parse_finite_float(entry[1]),
-                    high=_parse_finite_float(entry[2]),
-                    low=_parse_finite_float(entry[3]),
-                    close=_parse_finite_float(entry[4]),
-                    volume=_parse_finite_float(entry[5]),
-                )
-                for entry in data
-            ]
-            return sorted(candles, key=lambda candle: candle.ts)
-        except (IndexError, KeyError, TypeError, ValueError):
-            raise self._invalid_payload() from None
+        remaining = limit
+        after: int | None = None
+        candles_by_ts: dict[int, Candle] = {}
+
+        while remaining > 0:
+            page_limit = min(remaining, 300)
+            params: dict[str, object] = {"instId": symbol, "bar": bar, "limit": page_limit}
+            if after is not None:
+                params["after"] = after
+            data = self._request("GET", "/api/v5/market/history-candles", params=params)
+            try:
+                page = [
+                    Candle(
+                        symbol=symbol,
+                        ts=int(entry[0]),
+                        open=_parse_finite_float(entry[1]),
+                        high=_parse_finite_float(entry[2]),
+                        low=_parse_finite_float(entry[3]),
+                        close=_parse_finite_float(entry[4]),
+                        volume=_parse_finite_float(entry[5]),
+                    )
+                    for entry in data
+                ]
+            except (IndexError, KeyError, TypeError, ValueError):
+                raise self._invalid_payload() from None
+            if not page:
+                break
+            for candle in page:
+                candles_by_ts[candle.ts] = candle
+            remaining = limit - len(candles_by_ts)
+            oldest_ts = min(candle.ts for candle in page)
+            after = oldest_ts - 1
+            if len(page) < page_limit:
+                break
+
+        return sorted(candles_by_ts.values(), key=lambda candle: candle.ts)[:limit]
 
     def get_instrument_meta(self, symbol: str) -> InstrumentMeta:
         data = self._request(

+ 135 - 0
okx_codex_trader/paper_engine.py

@@ -0,0 +1,135 @@
+import json
+from dataclasses import asdict
+from pathlib import Path
+from typing import Callable, Mapping
+
+from okx_codex_trader.models import PaperOrderResult, PaperPosition, PaperState, TradeSignal
+
+
+def default_state() -> PaperState:
+    return PaperState(
+        cash_usdt=10_000.0,
+        realized_pnl=0.0,
+        positions=[],
+        updated_at="1970-01-01T00:00:00Z",
+    )
+
+
+def load_state(path: Path) -> PaperState:
+    if not path.exists():
+        return default_state()
+    try:
+        payload = json.loads(path.read_text())
+    except json.JSONDecodeError as exc:
+        raise ValueError("paper state is invalid") from exc
+    return parse_state(payload)
+
+
+def parse_state(payload: Mapping[str, object]) -> PaperState:
+    try:
+        cash_usdt = float(payload["cash_usdt"])
+        realized_pnl = float(payload["realized_pnl"])
+        updated_at = payload["updated_at"]
+        positions_payload = payload["positions"]
+    except (KeyError, TypeError, ValueError) as exc:
+        raise ValueError("paper state is invalid") from exc
+    if not isinstance(updated_at, str) or not isinstance(positions_payload, list):
+        raise ValueError("paper state is invalid")
+    positions: list[PaperPosition] = []
+    for entry in positions_payload:
+        if not isinstance(entry, Mapping):
+            raise ValueError("paper state is invalid")
+        try:
+            symbol = entry["symbol"]
+            side = entry["side"]
+            quantity = float(entry["quantity"])
+            avg_entry_price = float(entry["avg_entry_price"])
+            margin_used = float(entry["margin_used"])
+        except (KeyError, TypeError, ValueError) as exc:
+            raise ValueError("paper state is invalid") from exc
+        if not isinstance(symbol, str) or side not in {"long", "short"}:
+            raise ValueError("paper state is invalid")
+        positions.append(
+            PaperPosition(
+                symbol=symbol,
+                side=side,
+                quantity=quantity,
+                avg_entry_price=avg_entry_price,
+                margin_used=margin_used,
+            )
+        )
+    return PaperState(
+        cash_usdt=cash_usdt,
+        realized_pnl=realized_pnl,
+        positions=positions,
+        updated_at=updated_at,
+    )
+
+
+def save_state(path: Path, state: PaperState) -> None:
+    path.write_text(json.dumps(asdict(state), indent=2))
+
+
+def apply_signal(
+    *,
+    state: PaperState,
+    symbol: str,
+    signal: TradeSignal,
+    margin_usdt: float,
+    price: float,
+    now: Callable[[], str],
+) -> tuple[PaperState, PaperOrderResult]:
+    if signal.action == "flat":
+        return state, PaperOrderResult(
+            status="noop",
+            symbol=symbol,
+            side=None,
+            price=None,
+            quantity=None,
+            margin_used=None,
+            cash_usdt=state.cash_usdt,
+        )
+    if state.cash_usdt < margin_usdt:
+        raise ValueError("insufficient local cash")
+    quantity = margin_usdt * signal.leverage / price
+    positions = list(state.positions)
+    for index, position in enumerate(positions):
+        if position.symbol != symbol or position.side != signal.action:
+            continue
+        total_quantity = position.quantity + quantity
+        avg_entry_price = (
+            position.quantity * position.avg_entry_price + quantity * price
+        ) / total_quantity
+        positions[index] = PaperPosition(
+            symbol=symbol,
+            side=signal.action,
+            quantity=total_quantity,
+            avg_entry_price=avg_entry_price,
+            margin_used=position.margin_used + margin_usdt,
+        )
+        break
+    else:
+        positions.append(
+            PaperPosition(
+                symbol=symbol,
+                side=signal.action,
+                quantity=quantity,
+                avg_entry_price=price,
+                margin_used=margin_usdt,
+            )
+        )
+    next_state = PaperState(
+        cash_usdt=state.cash_usdt - margin_usdt,
+        realized_pnl=state.realized_pnl,
+        positions=positions,
+        updated_at=now(),
+    )
+    return next_state, PaperOrderResult(
+        status="filled",
+        symbol=symbol,
+        side=signal.action,
+        price=price,
+        quantity=quantity,
+        margin_used=margin_usdt,
+        cash_usdt=next_state.cash_usdt,
+    )

+ 203 - 0
okx_codex_trader/report.py

@@ -0,0 +1,203 @@
+from html import escape
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+from backtesting import Strategy
+from backtesting.lib import FractionalBacktest, crossover
+
+from okx_codex_trader.models import Candle
+
+
+def render_report_html(
+    *,
+    symbol: str,
+    bar: str,
+    leverage: int,
+    stats: dict[str, object],
+    trades: list[dict[str, object]],
+    plot_filename: str,
+) -> str:
+    stat_cards = "".join(
+        f"""
+        <div class="card">
+          <div class="label">{escape(str(label))}</div>
+          <div class="value">{escape(str(value))}</div>
+        </div>
+        """
+        for label, value in stats.items()
+    )
+    trade_rows = "".join(
+        f"""
+        <tr>
+          <td>{escape(str(trade["side"]))}</td>
+          <td>{escape(str(trade["entry_time"]))}</td>
+          <td>{escape(str(trade["exit_time"]))}</td>
+          <td>{escape(str(trade["entry_price"]))}</td>
+          <td>{escape(str(trade["exit_price"]))}</td>
+          <td>{escape(str(trade["pnl"]))}</td>
+          <td>{escape(str(trade["return_pct"]))}</td>
+        </tr>
+        """
+        for trade in trades
+    )
+    return f"""<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>{escape(symbol)} Backtest Report</title>
+  <style>
+    body {{ font-family: Inter, system-ui, sans-serif; margin: 0; background: #f5f1e8; color: #1f1c18; }}
+    .page {{ max-width: 1280px; margin: 0 auto; padding: 32px 24px 48px; }}
+    .hero {{ display: flex; justify-content: space-between; gap: 24px; align-items: end; margin-bottom: 24px; }}
+    .hero h1 {{ margin: 0; font-size: 36px; }}
+    .meta {{ color: #5f564b; }}
+    .stats {{ display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 24px; }}
+    .card {{ background: #fffdf8; border: 1px solid #d8cdbd; border-radius: 16px; padding: 16px; }}
+    .label {{ font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: #7a6f62; margin-bottom: 8px; }}
+    .value {{ font-size: 28px; font-weight: 700; }}
+    .layout {{ display: grid; grid-template-columns: 1.1fr .9fr; gap: 16px; }}
+    .panel {{ background: #fffdf8; border: 1px solid #d8cdbd; border-radius: 18px; padding: 18px; }}
+    table {{ width: 100%; border-collapse: collapse; font-size: 14px; }}
+    th, td {{ text-align: left; padding: 10px 8px; border-bottom: 1px solid #ece3d6; }}
+    th {{ color: #6b6258; font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }}
+    iframe {{ width: 100%; height: 720px; border: 0; border-radius: 14px; background: white; }}
+    @media (max-width: 960px) {{
+      .stats, .layout {{ grid-template-columns: 1fr; }}
+      iframe {{ height: 520px; }}
+    }}
+  </style>
+</head>
+<body>
+  <div class="page">
+    <div class="hero">
+      <div>
+        <div class="meta">Journal-first backtest report</div>
+        <h1>{escape(symbol)}</h1>
+      </div>
+      <div class="meta">Bar: {escape(bar)} · Leverage: {escape(str(leverage))}x</div>
+    </div>
+    <div class="stats">{stat_cards}</div>
+    <div class="layout">
+      <section class="panel">
+        <h2>Trade Journal</h2>
+        <table>
+          <thead>
+            <tr>
+              <th>Side</th>
+              <th>Entry Time</th>
+              <th>Exit Time</th>
+              <th>Entry</th>
+              <th>Exit</th>
+              <th>PnL</th>
+              <th>Return %</th>
+            </tr>
+          </thead>
+          <tbody>{trade_rows}</tbody>
+        </table>
+      </section>
+      <section class="panel">
+        <h2>Chart</h2>
+        <iframe src="/files/{escape(plot_filename)}" title="Backtest Plot"></iframe>
+      </section>
+    </div>
+  </div>
+</body>
+</html>"""
+
+
+def generate_backtest_report(
+    *,
+    candles: list[Candle],
+    leverage: int,
+    output_file: Path,
+    symbol: str,
+    bar: str,
+) -> dict[str, object]:
+    output_file.parent.mkdir(parents=True, exist_ok=True)
+    plot_file = output_file.with_name(output_file.stem + ".plot.html")
+
+    frame = pd.DataFrame(
+        {
+            "Open": [candle.open for candle in candles],
+            "High": [candle.high for candle in candles],
+            "Low": [candle.low for candle in candles],
+            "Close": [candle.close for candle in candles],
+            "Volume": [candle.volume for candle in candles],
+        },
+        index=pd.to_datetime([candle.ts for candle in candles], unit="ms", utc=True),
+    )
+
+    class SmaCross(Strategy):
+        n1 = 10
+        n2 = 20
+
+        def init(self):
+            close = self.data.Close.s
+            self.sma1 = self.I(
+                lambda values: np.array(pd.Series(values).rolling(self.n1).mean().to_list(), dtype=float),
+                close,
+            )
+            self.sma2 = self.I(
+                lambda values: np.array(pd.Series(values).rolling(self.n2).mean().to_list(), dtype=float),
+                close,
+            )
+
+        def next(self):
+            if crossover(self.sma1, self.sma2):
+                self.position.close()
+                self.buy()
+            elif crossover(self.sma2, self.sma1):
+                self.position.close()
+                self.sell()
+
+    backtest = FractionalBacktest(
+        frame,
+        SmaCross,
+        cash=10_000,
+        commission=0.0,
+        margin=1 / leverage,
+        trade_on_close=False,
+        exclusive_orders=True,
+        hedging=False,
+        finalize_trades=False,
+    )
+    stats = backtest.run()
+    backtest.plot(filename=str(plot_file), open_browser=False)
+
+    summary = {
+        "Return [%]": round(float(stats["Return [%]"]), 2),
+        "Win Rate [%]": round(float(stats["Win Rate [%]"]), 2),
+        "Max. Drawdown [%]": round(float(stats["Max. Drawdown [%]"]), 2),
+        "# Trades": int(stats["# Trades"]),
+    }
+    trades_frame = stats["_trades"]
+    trades = [
+        {
+            "side": "Long" if float(row["Size"]) > 0 else "Short",
+            "entry_time": row["EntryTime"].strftime("%Y-%m-%d %H:%M"),
+            "exit_time": row["ExitTime"].strftime("%Y-%m-%d %H:%M"),
+            "entry_price": round(float(row["EntryPrice"]), 4),
+            "exit_price": round(float(row["ExitPrice"]), 4),
+            "pnl": round(float(row["PnL"]), 4),
+            "return_pct": round(float(row["ReturnPct"]) * 100, 2),
+        }
+        for _, row in trades_frame.iterrows()
+    ]
+    output_file.write_text(
+        render_report_html(
+            symbol=symbol,
+            bar=bar,
+            leverage=leverage,
+            stats=summary,
+            trades=trades,
+            plot_filename=plot_file.name,
+        )
+    )
+    return {
+        "report_file": str(output_file),
+        "plot_file": str(plot_file),
+        "trade_count": int(stats["# Trades"]),
+        "total_return": round(float(stats["Return [%]"]) / 100, 6),
+    }

+ 231 - 0
okx_codex_trader/rsi2_report.py

@@ -0,0 +1,231 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+
+import pandas as pd
+
+from okx_codex_trader.models import Candle
+from okx_codex_trader.sampled_report import (
+    SegmentResult,
+    generate_sampled_report,
+    mark_to_market as _mark_to_market,
+    trade_equity as _trade_equity,
+)
+
+
+RSI2_STRATEGY_DESCRIPTION = (
+    "Trend-filtered RSI2 mean reversion, close-vs-SMA long and short entries, "
+    "RSI reversion exits at next open."
+)
+
+
+@dataclass(frozen=True)
+class RSI2Config:
+    trend_sma: int = 50
+    rsi_length: int = 2
+    rsi_long_threshold: float = 10.0
+    rsi_short_threshold: float = 90.0
+    exit_rsi: float = 50.0
+    initial_equity: float = 10_000.0
+
+
+def _format_ts(ts: int) -> str:
+    return pd.to_datetime(ts, unit="ms", utc=True).strftime("%Y-%m-%d %H:%M")
+
+
+def _compute_rsi(closes: pd.Series, length: int) -> list[float]:
+    deltas = closes.diff()
+    gains = deltas.clip(lower=0.0)
+    losses = -deltas.clip(upper=0.0)
+
+    rsi = [float("nan")] * len(closes)
+    if len(closes) <= length:
+        return rsi
+
+    average_gain = float(gains.iloc[1 : length + 1].mean())
+    average_loss = float(losses.iloc[1 : length + 1].mean())
+    for index in range(length, len(closes)):
+        if index > length:
+            average_gain = ((average_gain * (length - 1)) + float(gains.iloc[index])) / length
+            average_loss = ((average_loss * (length - 1)) + float(losses.iloc[index])) / length
+        if average_gain != average_gain or average_loss != average_loss:
+            rsi[index] = float("nan")
+            continue
+        if average_loss == 0.0:
+            rsi[index] = 100.0 if average_gain > 0.0 else 50.0
+            continue
+        relative_strength = average_gain / average_loss
+        rsi[index] = 100.0 - (100.0 / (1.0 + relative_strength))
+    return rsi
+
+
+def run_rsi2_segment(
+    *,
+    candles: list[Candle],
+    leverage: int,
+    warmup_bars: int,
+    config: RSI2Config = RSI2Config(),
+) -> SegmentResult:
+    closes = pd.Series([candle.close for candle in candles], dtype=float)
+    trend = closes.rolling(config.trend_sma).mean().tolist()
+    rsi = _compute_rsi(closes, config.rsi_length)
+
+    equity = config.initial_equity
+    ending_equity = equity
+    peak_equity = equity
+    max_drawdown = 0.0
+    wins = 0
+    trades: list[dict[str, object]] = []
+    entries: list[dict[str, object]] = []
+    exits: list[dict[str, object]] = []
+    equity_curve: list[dict[str, float | int]] = []
+    position: dict[str, object] | None = None
+    pending_entry_side: str | None = None
+    pending_exit = False
+
+    for index in range(warmup_bars, len(candles)):
+        candle = candles[index]
+
+        if pending_exit and position is not None:
+            exit_price = candle.open
+            exit_equity = _trade_equity(
+                side=str(position["side"]),
+                margin_used=float(position["margin_used"]),
+                entry_price=float(position["entry_price"]),
+                exit_price=exit_price,
+                leverage=leverage,
+            )
+            trades.append(
+                {
+                    "side": "Long" if position["side"] == "long" else "Short",
+                    "entry_time": _format_ts(int(position["entry_time"])),
+                    "exit_time": _format_ts(candle.ts),
+                    "entry_price": round(float(position["entry_price"]), 4),
+                    "exit_price": round(exit_price, 4),
+                    "pnl": round(exit_equity - float(position["margin_used"]), 4),
+                    "return_pct": round(
+                        (exit_equity - float(position["margin_used"])) / float(position["margin_used"]) * 100,
+                        4,
+                    ),
+                }
+            )
+            exits.append({"ts": candle.ts, "price": exit_price, "side": position["side"]})
+            if exit_equity > float(position["margin_used"]):
+                wins += 1
+            equity = exit_equity
+            position = None
+            pending_exit = False
+
+        if pending_entry_side is not None and position is None and equity > 0.0:
+            position = {
+                "side": pending_entry_side,
+                "entry_time": candle.ts,
+                "entry_price": candle.open,
+                "margin_used": equity,
+            }
+            entries.append({"ts": candle.ts, "price": candle.open, "side": pending_entry_side})
+            pending_entry_side = None
+
+        current_equity = equity
+        if position is not None:
+            current_equity = _mark_to_market(
+                side=str(position["side"]),
+                margin_used=float(position["margin_used"]),
+                entry_price=float(position["entry_price"]),
+                mark_price=candle.close,
+                leverage=leverage,
+            )
+
+        if current_equity > peak_equity:
+            peak_equity = current_equity
+        max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
+        equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
+        ending_equity = current_equity
+
+        if index == len(candles) - 1 or equity <= 0.0:
+            continue
+
+        current_rsi = rsi[index]
+        current_trend = trend[index]
+        if current_rsi != current_rsi or current_trend != current_trend:
+            continue
+
+        if position is not None:
+            exit_signal = (
+                position["side"] == "long" and current_rsi >= config.exit_rsi
+            ) or (
+                position["side"] == "short" and current_rsi <= config.exit_rsi
+            )
+            if exit_signal:
+                pending_exit = True
+            continue
+
+        if candle.close > float(current_trend) and current_rsi <= config.rsi_long_threshold:
+            pending_entry_side = "long"
+        elif candle.close < float(current_trend) and current_rsi >= config.rsi_short_threshold:
+            pending_entry_side = "short"
+
+    trade_count = len(trades)
+    return SegmentResult(
+        trade_count=trade_count,
+        total_return=(ending_equity - config.initial_equity) / config.initial_equity,
+        win_rate=(wins / trade_count) if trade_count else 0.0,
+        max_drawdown=max_drawdown,
+        trades=trades,
+        open_position=position,
+        candles=candles[warmup_bars:],
+        equity_curve=equity_curve,
+        entries=entries,
+        exits=exits,
+    )
+
+
+def generate_rsi2_sampled_report(
+    *,
+    candles: list[Candle],
+    leverage: int,
+    output_file: Path,
+    symbol: str,
+    bar: str,
+    segments: int,
+    window_size: int,
+    trend_sma: int = RSI2Config.trend_sma,
+    rsi_length: int = RSI2Config.rsi_length,
+    rsi_long_threshold: float = RSI2Config.rsi_long_threshold,
+    rsi_short_threshold: float = RSI2Config.rsi_short_threshold,
+    exit_rsi: float = RSI2Config.exit_rsi,
+) -> dict[str, object]:
+    config = RSI2Config(
+        trend_sma=trend_sma,
+        rsi_length=rsi_length,
+        rsi_long_threshold=rsi_long_threshold,
+        rsi_short_threshold=rsi_short_threshold,
+        exit_rsi=exit_rsi,
+    )
+    return generate_sampled_report(
+        candles=candles,
+        leverage=leverage,
+        output_file=output_file,
+        symbol=symbol,
+        bar=bar,
+        segments=segments,
+        window_size=window_size,
+        report_title="RSI2 Sampled Report",
+        strategy_label="RSI2",
+        strategy_description=RSI2_STRATEGY_DESCRIPTION,
+        strategy_params={
+            "trend_sma": config.trend_sma,
+            "rsi_length": config.rsi_length,
+            "rsi_long_threshold": config.rsi_long_threshold,
+            "rsi_short_threshold": config.rsi_short_threshold,
+            "exit_rsi": config.exit_rsi,
+        },
+        run_segment=lambda *, candles, leverage, warmup_bars: run_rsi2_segment(
+            candles=candles,
+            leverage=leverage,
+            warmup_bars=warmup_bars,
+            config=config,
+        ),
+        warmup_bars=max(config.trend_sma, config.rsi_length + 1),
+    )

+ 392 - 0
okx_codex_trader/sampled_report.py

@@ -0,0 +1,392 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from html import escape
+from pathlib import Path
+from random import Random
+from statistics import median
+from typing import Callable
+
+import pandas as pd
+from bokeh.embed import components
+from bokeh.layouts import column
+from bokeh.plotting import figure
+from bokeh.resources import INLINE
+
+from okx_codex_trader.models import Candle
+
+
+WARMUP_BARS = 69
+SAMPLER_SEED = 7
+
+
+@dataclass(frozen=True)
+class SampledSegment:
+    context_start: int
+    report_start: int
+    report_end: int
+    start_ts: int
+    end_ts: int
+
+
+@dataclass(frozen=True)
+class SegmentResult:
+    trade_count: int
+    total_return: float
+    win_rate: float
+    max_drawdown: float
+    trades: list[dict[str, object]]
+    open_position: dict[str, object] | None
+    candles: list[Candle]
+    equity_curve: list[dict[str, float | int]]
+    entries: list[dict[str, object]]
+    exits: list[dict[str, object]]
+
+
+@dataclass(frozen=True)
+class ReportSegment:
+    index: int
+    start_time: str
+    end_time: str
+    result: SegmentResult
+    plot_div: str
+
+
+def _format_ts(ts: int) -> str:
+    return pd.to_datetime(ts, unit="ms", utc=True).strftime("%Y-%m-%d %H:%M")
+
+
+def sample_segments(
+    *,
+    candles: list[Candle],
+    segments: int,
+    window_size: int,
+    warmup_bars: int = WARMUP_BARS,
+    seed: int = SAMPLER_SEED,
+) -> list[SampledSegment]:
+    block_size = window_size + warmup_bars
+    if len(candles) < segments * block_size:
+        raise ValueError("history pool is too small")
+
+    context_starts = list(range(0, len(candles) - block_size + 1, block_size))
+    if len(context_starts) < segments:
+        raise ValueError("history pool is too small")
+
+    rng = Random(seed)
+    rng.shuffle(context_starts)
+    selected_context_starts = sorted(context_starts[:segments])
+    return [
+        SampledSegment(
+            context_start=context_start,
+            report_start=context_start + warmup_bars,
+            report_end=context_start + block_size,
+            start_ts=candles[context_start + warmup_bars].ts,
+            end_ts=candles[context_start + block_size - 1].ts,
+        )
+        for context_start in selected_context_starts
+    ]
+
+
+def trade_equity(
+    *,
+    side: str,
+    margin_used: float,
+    entry_price: float,
+    exit_price: float,
+    leverage: int,
+) -> float:
+    if side == "long":
+        price_return = (exit_price - entry_price) / entry_price
+    else:
+        price_return = (entry_price - exit_price) / entry_price
+    return margin_used + (margin_used * leverage * price_return)
+
+
+def mark_to_market(
+    *,
+    side: str,
+    margin_used: float,
+    entry_price: float,
+    mark_price: float,
+    leverage: int,
+) -> float:
+    return trade_equity(
+        side=side,
+        margin_used=margin_used,
+        entry_price=entry_price,
+        exit_price=mark_price,
+        leverage=leverage,
+    )
+
+
+def build_segment_plot(segment: SegmentResult):
+    timestamps = [pd.to_datetime(point["ts"], unit="ms", utc=True) for point in segment.equity_curve]
+    closes = [point["close"] for point in segment.equity_curve]
+    equities = [point["equity"] for point in segment.equity_curve]
+
+    price_fig = figure(height=320, sizing_mode="stretch_width", x_axis_type="datetime", title="Price")
+    price_fig.line(timestamps, closes, line_width=2, color="#1f6f78")
+    if segment.entries:
+        price_fig.scatter(
+            [pd.to_datetime(entry["ts"], unit="ms", utc=True) for entry in segment.entries],
+            [entry["price"] for entry in segment.entries],
+            marker="triangle",
+            size=12,
+            color="#1d7c44",
+        )
+    if segment.exits:
+        price_fig.scatter(
+            [pd.to_datetime(exit_point["ts"], unit="ms", utc=True) for exit_point in segment.exits],
+            [exit_point["price"] for exit_point in segment.exits],
+            marker="inverted_triangle",
+            size=12,
+            color="#a13d2d",
+        )
+
+    equity_fig = figure(height=220, sizing_mode="stretch_width", x_axis_type="datetime", title="Equity")
+    equity_fig.line(timestamps, equities, line_width=2, color="#7b4f9d")
+    return column(price_fig, equity_fig, sizing_mode="stretch_width")
+
+
+def render_sampled_report(
+    *,
+    symbol: str,
+    bar: str,
+    leverage: int,
+    history_limit: int,
+    segments: int,
+    window_size: int,
+    report_title: str,
+    strategy_label: str,
+    strategy_description: str,
+    strategy_params: dict[str, object],
+    aggregate_summary: dict[str, object],
+    segment_results: list[ReportSegment],
+    bokeh_script: str,
+) -> str:
+    summary_cards = "".join(
+        f"""
+        <div class="card">
+          <div class="label">{escape(label)}</div>
+          <div class="value">{escape(str(value))}</div>
+        </div>
+        """
+        for label, value in (
+            ("History Limit", history_limit),
+            ("Segment Count", segments),
+            ("Window Size", window_size),
+            ("Average Return Across Segments", aggregate_summary["average_return"]),
+            ("Median Return Across Segments", aggregate_summary["median_return"]),
+            ("Best Segment Return", aggregate_summary["best_segment_return"]),
+            ("Worst Segment Return", aggregate_summary["worst_segment_return"]),
+            ("Aggregate Trade Count", aggregate_summary["aggregate_trade_count"]),
+        )
+    )
+    params_markup = "".join(
+        f"""
+        <div class="card">
+          <div class="label">{escape(key.replace('_', ' ').title())}</div>
+          <div class="value">{escape(str(value))}</div>
+        </div>
+        """
+        for key, value in strategy_params.items()
+    )
+    selector = "".join(
+        f'<button class="segment-button{" active" if segment.index == 0 else ""}" data-segment-index="{segment.index}">Segment {segment.index + 1}</button>'
+        for segment in segment_results
+    )
+    panels = []
+    for segment in segment_results:
+        rows = "".join(
+            f"""
+            <tr>
+              <td>{escape(str(trade["side"]))}</td>
+              <td>{escape(str(trade["entry_time"]))}</td>
+              <td>{escape(str(trade["exit_time"]))}</td>
+              <td>{escape(str(trade["entry_price"]))}</td>
+              <td>{escape(str(trade["exit_price"]))}</td>
+              <td>{escape(str(trade["pnl"]))}</td>
+              <td>{escape(str(trade["return_pct"]))}</td>
+            </tr>
+            """
+            for trade in segment.result.trades
+        )
+        panels.append(
+            f"""
+            <section class="segment-panel{' active' if segment.index == 0 else ''}" data-segment-index="{segment.index}">
+              <div class="segment-metrics">
+                <div class="metric"><span>Sampled Range Start Time</span><strong>{escape(segment.start_time)}</strong></div>
+                <div class="metric"><span>Sampled Range End Time</span><strong>{escape(segment.end_time)}</strong></div>
+                <div class="metric"><span>Trade Count</span><strong>{escape(str(segment.result.trade_count))}</strong></div>
+                <div class="metric"><span>Total Return</span><strong>{escape(str(round(segment.result.total_return, 6)))}</strong></div>
+                <div class="metric"><span>Win Rate</span><strong>{escape(str(round(segment.result.win_rate, 6)))}</strong></div>
+                <div class="metric"><span>Max Drawdown</span><strong>{escape(str(round(segment.result.max_drawdown, 6)))}</strong></div>
+              </div>
+              <div class="layout">
+                <section class="panel">
+                  <h3>Trade Journal</h3>
+                  <table>
+                    <thead>
+                      <tr>
+                        <th>Side</th>
+                        <th>Entry Time</th>
+                        <th>Exit Time</th>
+                        <th>Entry</th>
+                        <th>Exit</th>
+                        <th>PnL</th>
+                        <th>Return %</th>
+                      </tr>
+                    </thead>
+                    <tbody>{rows}</tbody>
+                  </table>
+                </section>
+                <section class="panel">{segment.plot_div}</section>
+              </div>
+            </section>
+            """
+        )
+    return f"""<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>{escape(symbol)} {escape(report_title)}</title>
+  <style>
+    body {{ font-family: Inter, system-ui, sans-serif; margin: 0; background: #f5f1e8; color: #1f1c18; }}
+    .page {{ max-width: 1440px; margin: 0 auto; padding: 28px 24px 48px; }}
+    .hero {{ display:flex; justify-content:space-between; gap:24px; align-items:end; margin-bottom:20px; }}
+    .hero h1 {{ margin:0; font-size:36px; }}
+    .meta {{ color:#5f564b; }}
+    .strategy {{ background:#fffdf8; border:1px solid #d8cdbd; border-radius:18px; padding:18px; margin-bottom:18px; }}
+    .stats {{ display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:12px; margin-bottom:18px; }}
+    .card {{ background:#fffdf8; border:1px solid #d8cdbd; border-radius:16px; padding:16px; }}
+    .label {{ font-size:12px; letter-spacing:.08em; text-transform:uppercase; color:#7a6f62; margin-bottom:8px; }}
+    .value {{ font-size:24px; font-weight:700; }}
+    .segment-selector {{ display:flex; flex-wrap:wrap; gap:10px; margin-bottom:18px; }}
+    .segment-button {{ border:1px solid #c6b7a2; background:#fffdf8; border-radius:999px; padding:10px 14px; cursor:pointer; }}
+    .segment-button.active {{ background:#1f1c18; color:#fffdf8; }}
+    .segment-panel {{ display:none; }}
+    .segment-panel.active {{ display:block; }}
+    .segment-metrics {{ display:grid; grid-template-columns:repeat(3, minmax(0,1fr)); gap:12px; margin-bottom:16px; }}
+    .metric {{ background:#fffdf8; border:1px solid #d8cdbd; border-radius:14px; padding:14px; }}
+    .metric span {{ display:block; font-size:12px; color:#6b6258; text-transform:uppercase; letter-spacing:.08em; margin-bottom:6px; }}
+    .metric strong {{ font-size:20px; }}
+    .layout {{ display:grid; grid-template-columns:1fr 1fr; gap:16px; }}
+    .panel {{ background:#fffdf8; border:1px solid #d8cdbd; border-radius:18px; padding:18px; overflow:auto; }}
+    table {{ width:100%; border-collapse:collapse; font-size:14px; }}
+    th, td {{ text-align:left; padding:10px 8px; border-bottom:1px solid #ece3d6; }}
+    th {{ color:#6b6258; font-size:12px; text-transform:uppercase; letter-spacing:.08em; }}
+    @media (max-width: 1100px) {{
+      .stats, .segment-metrics, .layout {{ grid-template-columns:1fr; }}
+    }}
+  </style>
+</head>
+<body>
+  <div class="page">
+    <div class="hero">
+      <div>
+        <div class="meta">{escape(strategy_label)} sampled report</div>
+        <h1>{escape(symbol)}</h1>
+      </div>
+      <div class="meta">Bar: {escape(bar)} · Leverage: {escape(str(leverage))}x</div>
+    </div>
+    <section class="strategy">
+      <strong>{escape(report_title)}</strong>
+      <p>{escape(strategy_description)}</p>
+      <section class="stats">{params_markup}</section>
+    </section>
+    <section class="stats">{summary_cards}</section>
+    <div class="segment-selector" id="segment-selector">{selector}</div>
+    {''.join(panels)}
+  </div>
+  {bokeh_script}
+  <script>
+    const buttons = Array.from(document.querySelectorAll('.segment-button'));
+    const panels = Array.from(document.querySelectorAll('.segment-panel'));
+    buttons.forEach((button) => {{
+      button.addEventListener('click', () => {{
+        const target = button.dataset.segmentIndex;
+        buttons.forEach((item) => item.classList.toggle('active', item === button));
+        panels.forEach((panel) => panel.classList.toggle('active', panel.dataset.segmentIndex === target));
+      }});
+    }});
+  </script>
+</body>
+</html>"""
+
+
+def generate_sampled_report(
+    *,
+    candles: list[Candle],
+    leverage: int,
+    output_file: Path,
+    symbol: str,
+    bar: str,
+    segments: int,
+    window_size: int,
+    report_title: str,
+    strategy_label: str,
+    strategy_description: str,
+    strategy_params: dict[str, object],
+    run_segment: Callable[..., SegmentResult],
+    warmup_bars: int = WARMUP_BARS,
+) -> dict[str, object]:
+    sampled = sample_segments(candles=candles, segments=segments, window_size=window_size, warmup_bars=warmup_bars)
+    if len(sampled) != segments:
+        raise ValueError("invalid sampling result")
+
+    output_file.parent.mkdir(parents=True, exist_ok=True)
+    segment_results: list[SegmentResult] = []
+    plots = {}
+    for index, segment in enumerate(sampled):
+        result = run_segment(
+            candles=candles[segment.context_start : segment.report_end],
+            leverage=leverage,
+            warmup_bars=warmup_bars,
+        )
+        segment_results.append(result)
+        plots[f"segment_{index}"] = build_segment_plot(result)
+
+    plot_script, plot_divs = components(plots)
+    report_segments = [
+        ReportSegment(
+            index=index,
+            start_time=_format_ts(segment.start_ts),
+            end_time=_format_ts(segment.end_ts),
+            result=result,
+            plot_div=plot_divs[f"segment_{index}"],
+        )
+        for index, (segment, result) in enumerate(zip(sampled, segment_results, strict=True))
+    ]
+    returns = [result.total_return for result in segment_results]
+    aggregate_summary = {
+        "aggregate_trade_count": sum(result.trade_count for result in segment_results),
+        "average_return": round(sum(returns) / len(returns), 6),
+        "median_return": round(float(median(returns)), 6),
+        "best_segment_return": round(max(returns), 6),
+        "worst_segment_return": round(min(returns), 6),
+    }
+    output_file.write_text(
+        render_sampled_report(
+            symbol=symbol,
+            bar=bar,
+            leverage=leverage,
+            history_limit=len(candles),
+            segments=segments,
+            window_size=window_size,
+            report_title=report_title,
+            strategy_label=strategy_label,
+            strategy_description=strategy_description,
+            strategy_params=strategy_params,
+            aggregate_summary=aggregate_summary,
+            segment_results=report_segments,
+            bokeh_script=INLINE.render_js() + plot_script,
+        )
+    )
+    return {
+        "report_file": str(output_file),
+        "segment_count": segments,
+        "window_size": window_size,
+        "aggregate_trade_count": aggregate_summary["aggregate_trade_count"],
+        "average_return": aggregate_summary["average_return"],
+    }

+ 1 - 1
pyproject.toml

@@ -1,7 +1,7 @@
 [project]
 name = "okx-codex-trader"
 version = "0.1.0"
-dependencies = ["requests>=2.32,<3"]
+dependencies = ["backtesting>=0.6,<0.7", "requests>=2.32,<3"]
 
 [project.scripts]
 okx-codex-trader = "okx_codex_trader.cli:main"

+ 137 - 0
tests/test_bbmr_report.py

@@ -0,0 +1,137 @@
+import pytest
+
+from okx_codex_trader import bbmr_report
+from okx_codex_trader.bbmr_report import (
+    BBMRConfig,
+    generate_bbmr_sampled_report,
+    run_bbmr_segment,
+)
+from okx_codex_trader.models import Candle
+from okx_codex_trader.sampled_report import SegmentResult
+
+
+def build_candles_from_closes(closes: list[float]) -> list[Candle]:
+    candles: list[Candle] = []
+    for index, close in enumerate(closes):
+        candles.append(
+            Candle(
+                symbol="BTC-USDT-SWAP",
+                ts=index * 60_000,
+                open=close,
+                high=close + 1.0,
+                low=close - 1.0,
+                close=close,
+                volume=1_000.0 + index,
+            )
+        )
+    return candles
+
+
+def build_linear_candles(count: int) -> list[Candle]:
+    return build_candles_from_closes([100.0 + index for index in range(count)])
+
+
+def test_run_bbmr_segment_can_enter_on_final_bar_from_prior_signal():
+    config = BBMRConfig(band_length=2, std_multiplier=0.1, bandwidth_lookback=1, stop_loss_pct=0.005)
+    candles = build_candles_from_closes([100.0, 110.0, 106.0, 96.0])
+
+    result = run_bbmr_segment(candles=candles, leverage=2, warmup_bars=2, config=config)
+
+    assert isinstance(result, SegmentResult)
+    assert result.trade_count == 0
+    assert result.trades == []
+    assert result.entries == [{"ts": 180_000, "price": 96.0, "side": "long"}]
+    assert result.open_position is not None
+
+
+def test_run_bbmr_segment_stop_loss_takes_precedence_and_no_reverse_entry():
+    config = BBMRConfig(band_length=2, std_multiplier=0.1, bandwidth_lookback=1, stop_loss_pct=0.01)
+    candles = [
+        Candle(symbol="BTC-USDT-SWAP", ts=0, open=100.0, high=101.0, low=99.0, close=100.0, volume=1000.0),
+        Candle(symbol="BTC-USDT-SWAP", ts=60_000, open=110.0, high=111.0, low=109.0, close=110.0, volume=1001.0),
+        Candle(symbol="BTC-USDT-SWAP", ts=120_000, open=104.0, high=105.0, low=103.0, close=104.0, volume=1002.0),
+        Candle(symbol="BTC-USDT-SWAP", ts=180_000, open=105.0, high=105.2, low=103.7, close=103.8, volume=1003.0),
+        Candle(symbol="BTC-USDT-SWAP", ts=240_000, open=106.0, high=109.0, low=103.5, close=108.0, volume=1004.0),
+        Candle(symbol="BTC-USDT-SWAP", ts=300_000, open=107.0, high=108.0, low=106.0, close=107.0, volume=1005.0),
+    ]
+
+    result = run_bbmr_segment(candles=candles, leverage=2, warmup_bars=2, config=config)
+
+    assert result.trade_count == 1
+    assert len(result.trades) == 1
+    assert result.trades[0]["side"] == "Long"
+    assert result.trades[0]["exit_price"] == pytest.approx(103.95)
+    assert result.open_position is None
+
+
+def test_run_bbmr_segment_marks_open_position_to_market_but_keeps_journal_realized_only():
+    config = BBMRConfig(band_length=2, std_multiplier=0.1, bandwidth_lookback=1, stop_loss_pct=0.2)
+    candles = build_candles_from_closes([100.0, 110.0, 104.0, 103.0, 102.0])
+
+    result = run_bbmr_segment(candles=candles, leverage=2, warmup_bars=2, config=config)
+
+    assert result.trade_count == 0
+    assert result.trades == []
+    assert isinstance(result, SegmentResult)
+    assert result.total_return == pytest.approx((9805.825242718447 - 10_000.0) / 10_000.0)
+    assert result.open_position is not None
+
+
+def test_generate_bbmr_sampled_report_rejects_insufficient_history_pool(tmp_path):
+    candles = build_linear_candles(1_000)
+
+    with pytest.raises(ValueError, match="history pool is too small"):
+        generate_bbmr_sampled_report(
+            candles=candles,
+            leverage=2,
+            output_file=tmp_path / "bbmr.html",
+            symbol="BTC-USDT-SWAP",
+            bar="3m",
+            segments=8,
+            window_size=300,
+        )
+
+
+def test_generate_bbmr_sampled_report_uses_shared_shell(monkeypatch, tmp_path):
+    candles = build_linear_candles(500)
+    output_file = tmp_path / "bbmr.html"
+    recorded: dict[str, object] = {}
+    sentinel = {"report_file": str(output_file), "segment_count": 2, "window_size": 50, "aggregate_trade_count": 3, "average_return": 0.25}
+
+    def fake_generate_sampled_report(**kwargs):
+        recorded.update(kwargs)
+        return sentinel
+
+    monkeypatch.setattr(bbmr_report, "generate_sampled_report", fake_generate_sampled_report)
+
+    result = generate_bbmr_sampled_report(
+        candles=candles,
+        leverage=3,
+        output_file=output_file,
+        symbol="BTC-USDT-SWAP",
+        bar="3m",
+        segments=2,
+        window_size=50,
+    )
+
+    assert result == sentinel
+    assert recorded["candles"] == candles
+    assert recorded["leverage"] == 3
+    assert recorded["output_file"] == output_file
+    assert recorded["symbol"] == "BTC-USDT-SWAP"
+    assert recorded["bar"] == "3m"
+    assert recorded["segments"] == 2
+    assert recorded["window_size"] == 50
+    assert recorded["report_title"] == "BBMR Sampled Report"
+    assert recorded["strategy_label"] == "BBMR"
+    assert recorded["strategy_description"] == (
+        "Bollinger Band mean reversion, bandwidth filter against previous 50 completed values, "
+        "close-based return-to-middle exits at next open, intrabar 0.5% stop-loss."
+    )
+    assert recorded["strategy_params"] == {
+        "band_length": 20,
+        "std_multiplier": 2.0,
+        "bandwidth_lookback": 50,
+        "stop_loss_pct": 0.005,
+    }
+    assert recorded["run_segment"] is run_bbmr_segment

+ 195 - 0
tests/test_bbsb_report.py

@@ -0,0 +1,195 @@
+import pytest
+
+from okx_codex_trader import bbsb_report
+from okx_codex_trader.bbsb_report import generate_bbsb_sampled_report, run_bbsb_segment
+from okx_codex_trader.models import Candle
+from okx_codex_trader.sampled_report import SegmentResult
+
+
+def build_candles_from_closes(closes: list[float]) -> list[Candle]:
+    candles: list[Candle] = []
+    for index, close in enumerate(closes):
+        candles.append(
+            Candle(
+                symbol="BTC-USDT-SWAP",
+                ts=index * 60_000,
+                open=close,
+                high=close + 1.0,
+                low=close - 1.0,
+                close=close,
+                volume=1_000.0 + index,
+            )
+        )
+    return candles
+
+
+def make_candle(index: int, open_price: float, high: float, low: float, close: float) -> Candle:
+    return Candle(
+        symbol="BTC-USDT-SWAP",
+        ts=index * 60_000,
+        open=open_price,
+        high=high,
+        low=low,
+        close=close,
+        volume=1_000.0 + index,
+    )
+
+
+def build_warmup() -> list[Candle]:
+    candles: list[Candle] = []
+    for index in range(bbsb_report.WARMUP_BARS):
+        close = 100.5 if index % 2 else 99.5
+        candles.append(make_candle(index, close, close + 0.2, close - 0.2, close))
+    return candles
+
+
+def build_long_breakout_fixture() -> list[Candle]:
+    candles = build_warmup()
+    base = len(candles)
+    for offset in range(19):
+        candles.append(make_candle(base + offset, 100.0, 100.02, 99.98, 100.0))
+    candles.append(make_candle(base + 19, 100.0, 100.25, 99.98, 100.2))
+    candles.append(make_candle(base + 20, 100.2, 100.3, 100.15, 100.25))
+    candles.append(make_candle(base + 21, 100.25, 101.3, 100.2, 101.0))
+    return candles
+
+
+def build_short_breakout_fixture() -> list[Candle]:
+    candles = build_warmup()
+    base = len(candles)
+    for offset in range(19):
+        candles.append(make_candle(base + offset, 100.0, 100.02, 99.98, 100.0))
+    candles.append(make_candle(base + 19, 100.0, 100.02, 99.75, 99.8))
+    candles.append(make_candle(base + 20, 99.8, 99.85, 99.7, 99.75))
+    candles.append(make_candle(base + 21, 99.75, 99.8, 98.7, 98.9))
+    return candles
+
+
+def build_ambiguous_exit_fixture() -> list[Candle]:
+    candles = build_long_breakout_fixture()[:-1]
+    base = len(candles)
+    candles.append(make_candle(base, 100.25, 101.4, 99.6, 100.8))
+    return candles
+
+
+def build_final_bar_breakout_fixture() -> list[Candle]:
+    candles = build_warmup()
+    base = len(candles)
+    candles.append(make_candle(base, 100.0, 100.02, 99.98, 100.0))
+    candles.append(make_candle(base + 1, 100.0, 100.25, 99.98, 100.2))
+    return candles
+
+
+def build_open_tail_fixture() -> list[Candle]:
+    candles = build_warmup()
+    base = len(candles)
+    for offset in range(19):
+        candles.append(make_candle(base + offset, 100.0, 100.02, 99.98, 100.0))
+    candles.append(make_candle(base + 19, 100.0, 100.25, 99.98, 100.2))
+    candles.append(make_candle(base + 20, 100.2, 100.3, 100.15, 100.25))
+    candles.append(make_candle(base + 21, 100.25, 100.9, 100.2, 100.6))
+    return candles
+
+
+def build_entry_bar_tp_sl_fixture() -> list[Candle]:
+    candles = build_warmup()
+    base = len(candles)
+    for offset in range(19):
+        candles.append(make_candle(base + offset, 100.0, 100.02, 99.98, 100.0))
+    candles.append(make_candle(base + 19, 100.0, 100.25, 99.98, 100.2))
+    candles.append(make_candle(base + 20, 100.2, 101.4, 99.6, 100.5))
+    return candles
+
+
+def build_same_bar_reentry_fixture() -> list[Candle]:
+    candles = build_long_breakout_fixture()[:-1]
+    base = len(candles)
+    candles.append(make_candle(base, 100.25, 101.3, 100.2, 100.8))
+    candles.append(make_candle(base + 1, 100.8, 101.0, 99.7, 99.8))
+    return candles
+
+
+def test_run_bbsb_segment_produces_long_breakout_trade():
+    result = run_bbsb_segment(candles=build_long_breakout_fixture(), leverage=2, warmup_bars=bbsb_report.WARMUP_BARS)
+
+    assert isinstance(result, SegmentResult)
+    assert result.trade_count == 1
+    assert result.trades[0]["side"] == "Long"
+
+
+def test_run_bbsb_segment_produces_short_breakout_trade():
+    result = run_bbsb_segment(candles=build_short_breakout_fixture(), leverage=2, warmup_bars=bbsb_report.WARMUP_BARS)
+
+    assert isinstance(result, SegmentResult)
+    assert result.trade_count == 1
+    assert result.trades[0]["side"] == "Short"
+
+
+def test_run_bbsb_segment_stop_loss_takes_precedence_over_take_profit():
+    result = run_bbsb_segment(candles=build_ambiguous_exit_fixture(), leverage=2, warmup_bars=bbsb_report.WARMUP_BARS)
+
+    assert result.trade_count == 1
+    assert result.trades[0]["exit_price"] == pytest.approx(99.699)
+
+
+def test_run_bbsb_segment_does_not_generate_entry_from_final_reported_candle():
+    result = run_bbsb_segment(candles=build_final_bar_breakout_fixture(), leverage=2, warmup_bars=bbsb_report.WARMUP_BARS)
+
+    assert result.trade_count == 0
+    assert result.trades == []
+
+
+def test_run_bbsb_segment_marks_open_position_to_market_but_keeps_journal_realized_only():
+    result = run_bbsb_segment(candles=build_open_tail_fixture(), leverage=2, warmup_bars=bbsb_report.WARMUP_BARS)
+
+    assert result.trade_count == 0
+    assert result.trades == []
+    assert result.total_return != 0
+    assert result.open_position is not None
+
+
+def test_run_bbsb_segment_does_not_allow_tp_or_sl_on_entry_candle():
+    result = run_bbsb_segment(candles=build_entry_bar_tp_sl_fixture(), leverage=2, warmup_bars=bbsb_report.WARMUP_BARS)
+
+    assert result.trade_count == 0
+    assert result.open_position is not None
+
+
+def test_run_bbsb_segment_exit_exhausts_bar_without_same_bar_reentry():
+    result = run_bbsb_segment(candles=build_same_bar_reentry_fixture(), leverage=2, warmup_bars=bbsb_report.WARMUP_BARS)
+
+    assert result.trade_count == 1
+    assert len(result.entries) == 1
+
+
+def test_generate_bbsb_sampled_report_forwards_shared_report_arguments(monkeypatch, tmp_path):
+    expected = {"report_file": str(tmp_path / "bbsb.html"), "segment_count": 2}
+    captured: dict[str, object] = {}
+
+    def fake_generate_sampled_report(**kwargs):
+        captured.update(kwargs)
+        return expected
+
+    monkeypatch.setattr(bbsb_report, "generate_sampled_report", fake_generate_sampled_report)
+
+    result = generate_bbsb_sampled_report(
+        candles=build_candles_from_closes([100.0 + index for index in range(5_000)]),
+        leverage=2,
+        output_file=tmp_path / "bbsb.html",
+        symbol="BTC-USDT-SWAP",
+        bar="3m",
+        segments=2,
+        window_size=300,
+    )
+
+    assert result == expected
+    assert captured["report_title"] == "BBSB Sampled Report"
+    assert captured["strategy_label"] == "BBSB"
+    assert captured["run_segment"] is run_bbsb_segment
+    assert captured["strategy_params"] == {
+        "band_length": 20,
+        "std_multiplier": 2.0,
+        "bandwidth_lookback": 50,
+        "take_profit_pct": 0.01,
+        "stop_loss_pct": 0.005,
+    }

+ 627 - 47
tests/test_cli.py

@@ -7,7 +7,7 @@ import pytest
 from okx_codex_trader.backtest import run_backtest
 from okx_codex_trader.cli import main_factory
 from okx_codex_trader.config import Config
-from okx_codex_trader.models import Candle, OrderResult, Position, TradeSignal
+from okx_codex_trader.models import Candle, TradeSignal
 
 
 def sample_config() -> Config:
@@ -58,49 +58,138 @@ def real_write_text(path: str, text: str) -> None:
 class FakeClient:
     def __init__(self):
         self.get_candles_called_with: tuple[str, str, int] | None = None
-        self.get_positions_called_with: str | None = None
-        self.place_demo_order_called = False
-        self.place_demo_order_called_with: tuple[str, TradeSignal, float] | None = None
+        self.get_last_price_called_with: str | None = None
 
     def get_candles(self, symbol: str, bar: str, limit: int) -> list[Candle]:
         self.get_candles_called_with = (symbol, bar, limit)
         return sample_candles(limit=limit, symbol=symbol)
 
-    def place_demo_order(self, symbol: str, signal: TradeSignal, margin_usdt: float) -> OrderResult:
-        self.place_demo_order_called = True
-        self.place_demo_order_called_with = (symbol, signal, margin_usdt)
-        return OrderResult(
-            status="placed",
-            order_id="demo-order-1",
-            symbol=symbol,
-            side="buy",
-            pos_side="long",
-            order_type="limit",
-            size=1.0,
-        )
-
-    def get_positions(self, symbol: str) -> list[Position]:
-        self.get_positions_called_with = symbol
-        return [Position(symbol=symbol, pos_side="long", size=2.0, avg_price=123.5)]
+    def get_last_price(self, symbol: str) -> float:
+        self.get_last_price_called_with = symbol
+        return 250.0
 
 
 def fake_client() -> FakeClient:
     return FakeClient()
 
 
-def build_main_with_stubs():
+def build_main_with_stubs(*, state_path: Path | None = None):
     client = fake_client()
+    report_calls: list[dict[str, object]] = []
+    bbmr_report_calls: list[dict[str, object]] = []
+    bbsb_report_calls: list[dict[str, object]] = []
+    donchian_report_calls: list[dict[str, object]] = []
+
+    def fake_report(*, candles, leverage, output_file, symbol, bar):
+        report_calls.append(
+            {
+                "candles": candles,
+                "leverage": leverage,
+                "output_file": output_file,
+                "symbol": symbol,
+                "bar": bar,
+            }
+        )
+        return {
+            "report_file": str(output_file),
+            "plot_file": str(output_file).replace(".html", ".plot.html"),
+            "trade_count": 3,
+            "total_return": 0.12,
+        }
+
+    def fake_bbmr_report(*, candles, leverage, output_file, symbol, bar, segments, window_size):
+        bbmr_report_calls.append(
+            {
+                "candles": candles,
+                "leverage": leverage,
+                "output_file": output_file,
+                "symbol": symbol,
+                "bar": bar,
+                "segments": segments,
+                "window_size": window_size,
+            }
+        )
+        return {
+            "report_file": str(output_file),
+            "segment_count": segments,
+            "window_size": window_size,
+            "aggregate_trade_count": 11,
+            "average_return": 0.031,
+        }
+
+    def fake_bbsb_report(*, candles, leverage, output_file, symbol, bar, segments, window_size):
+        bbsb_report_calls.append(
+            {
+                "candles": candles,
+                "leverage": leverage,
+                "output_file": output_file,
+                "symbol": symbol,
+                "bar": bar,
+                "segments": segments,
+                "window_size": window_size,
+            }
+        )
+        return {
+            "report_file": str(output_file),
+            "segment_count": segments,
+            "window_size": window_size,
+            "aggregate_trade_count": 11,
+            "average_return": 0.031,
+        }
+
+    def fake_donchian_report(
+        *,
+        candles,
+        leverage,
+        output_file,
+        symbol,
+        bar,
+        segments,
+        window_size,
+        entry_window,
+        exit_window,
+        stop_loss_pct,
+    ):
+        donchian_report_calls.append(
+            {
+                "candles": candles,
+                "leverage": leverage,
+                "output_file": output_file,
+                "symbol": symbol,
+                "bar": bar,
+                "segments": segments,
+                "window_size": window_size,
+                "entry_window": entry_window,
+                "exit_window": exit_window,
+                "stop_loss_pct": stop_loss_pct,
+            }
+        )
+        return {
+            "report_file": str(output_file),
+            "segment_count": segments,
+            "window_size": window_size,
+            "aggregate_trade_count": 7,
+            "average_return": 0.024,
+        }
+
     main = main_factory(
         load_config=lambda: sample_config(),
-        client_factory=lambda config: client,
+        client_factory=lambda: client,
         analyze_fn=fake_analyze_with_codex,
         write_text=real_write_text,
+        state_path=Path("paper_state.json") if state_path is None else state_path,
+        now_fn=lambda: "1970-01-01T00:00:00Z",
+        report_fn=fake_report,
+        bbmr_report_fn=fake_bbmr_report,
+        bbsb_report_fn=fake_bbsb_report,
+        donchian_report_fn=fake_donchian_report,
+        ema_pullback_report_fn=lambda **kwargs: {},
     )
-    return main, client
+    return main, client, report_calls, bbmr_report_calls, bbsb_report_calls, donchian_report_calls
 
 
 def test_fetch_history_prints_candle_json(capsys):
-    main, client = build_main_with_stubs()
+    main, client, _, _, _, _ = build_main_with_stubs()
     expected = [asdict(candle) for candle in sample_candles(limit=20)]
 
     exit_code = main(["fetch-history", "--symbol", "BTC-USDT-SWAP", "--bar", "1H", "--limit", "20"])
@@ -111,7 +200,7 @@ def test_fetch_history_prints_candle_json(capsys):
 
 
 def test_backtest_prints_summary_json(capsys):
-    main, client = build_main_with_stubs()
+    main, client, _, _, _, _ = build_main_with_stubs()
     expected = run_backtest(candles=sample_candles(limit=50), leverage=2).to_dict()
 
     exit_code = main(["backtest", "--symbol", "BTC-USDT-SWAP", "--bar", "1H", "--limit", "50", "--leverage", "2"])
@@ -122,7 +211,7 @@ def test_backtest_prints_summary_json(capsys):
 
 
 def test_analyze_writes_output_file_and_stdout(tmp_path, capsys):
-    main, client = build_main_with_stubs()
+    main, client, _, _, _, _ = build_main_with_stubs()
     output_file = tmp_path / "signal.json"
 
     exit_code = main(
@@ -148,19 +237,55 @@ def test_analyze_writes_output_file_and_stdout(tmp_path, capsys):
     assert json.loads(stdout) == valid_signal()
 
 
-def test_paper_order_reads_signal_file_and_outputs_order_json(tmp_path, capsys):
-    main, client = build_main_with_stubs()
+def test_paper_order_initializes_local_state_and_outputs_local_order_json(tmp_path, capsys):
+    state_path = tmp_path / "paper_state.json"
+    main, client, _, _, _, _ = build_main_with_stubs(state_path=state_path)
     signal_file = tmp_path / "signal.json"
     signal_file.write_text(json.dumps(valid_signal()))
-    expected = {
-        "status": "placed",
-        "order_id": "demo-order-1",
+
+    exit_code = main(
+        [
+            "paper-order",
+            "--symbol",
+            "BTC-USDT-SWAP",
+            "--signal-file",
+            str(signal_file),
+            "--margin-usdt",
+            "100",
+        ]
+    )
+
+    assert exit_code == 0
+    assert client.get_last_price_called_with is None
+    payload = json.loads(capsys.readouterr().out)
+    assert payload == {
+        "status": "filled",
         "symbol": "BTC-USDT-SWAP",
-        "side": "buy",
-        "pos_side": "long",
-        "order_type": "limit",
-        "size": 1.0,
+        "side": "long",
+        "price": 123.5,
+        "quantity": pytest.approx((100.0 * 2) / 123.5),
+        "margin_used": 100.0,
+        "cash_usdt": 9900.0,
     }
+    state = json.loads(state_path.read_text())
+    assert state["cash_usdt"] == 9900.0
+    assert state["realized_pnl"] == 0.0
+    assert state["updated_at"] == "1970-01-01T00:00:00Z"
+    assert len(state["positions"]) == 1
+    assert state["positions"][0]["symbol"] == "BTC-USDT-SWAP"
+    assert state["positions"][0]["side"] == "long"
+    assert state["positions"][0]["quantity"] == pytest.approx((100.0 * 2) / 123.5)
+    assert state["positions"][0]["avg_entry_price"] == 123.5
+    assert state["positions"][0]["margin_used"] == 100.0
+
+
+def test_paper_order_uses_latest_price_when_entry_price_is_null(tmp_path, capsys):
+    state_path = tmp_path / "paper_state.json"
+    main, client, _, _, _, _ = build_main_with_stubs(state_path=state_path)
+    signal_file = tmp_path / "signal.json"
+    payload = valid_signal()
+    payload["entry_price"] = None
+    signal_file.write_text(json.dumps(payload))
 
     exit_code = main(
         [
@@ -175,34 +300,489 @@ def test_paper_order_reads_signal_file_and_outputs_order_json(tmp_path, capsys):
     )
 
     assert exit_code == 0
-    assert client.place_demo_order_called
-    assert client.place_demo_order_called_with is not None
-    assert client.place_demo_order_called_with[0] == "BTC-USDT-SWAP"
-    assert asdict(client.place_demo_order_called_with[1]) == valid_signal()
-    assert client.place_demo_order_called_with[2] == 100.0
-    assert json.loads(capsys.readouterr().out) == expected
+    assert client.get_last_price_called_with == "BTC-USDT-SWAP"
+    payload = json.loads(capsys.readouterr().out)
+    assert payload["price"] == 250.0
+    assert payload["quantity"] == 0.8
+
+
+def test_paper_order_rejects_when_local_cash_is_insufficient(tmp_path):
+    state_path = tmp_path / "paper_state.json"
+    state_path.write_text(
+        json.dumps(
+            {
+                "cash_usdt": 50.0,
+                "realized_pnl": 0.0,
+                "positions": [],
+                "updated_at": "1970-01-01T00:00:00Z",
+            }
+        )
+    )
+    main, _, _, _, _, _ = build_main_with_stubs(state_path=state_path)
+    signal_file = tmp_path / "signal.json"
+    signal_file.write_text(json.dumps(valid_signal()))
 
+    with pytest.raises(ValueError, match="insufficient local cash"):
+        main(
+            [
+                "paper-order",
+                "--symbol",
+                "BTC-USDT-SWAP",
+                "--signal-file",
+                str(signal_file),
+                "--margin-usdt",
+                "100",
+            ]
+        )
 
-def test_positions_prints_position_json(capsys):
-    main, client = build_main_with_stubs()
-    expected = [{"symbol": "BTC-USDT-SWAP", "pos_side": "long", "size": 2.0, "avg_price": 123.5}]
+
+def test_positions_prints_local_state_positions(tmp_path, capsys):
+    state_path = tmp_path / "paper_state.json"
+    state_path.write_text(
+        json.dumps(
+            {
+                "cash_usdt": 9800.0,
+                "realized_pnl": 0.0,
+                "positions": [
+                    {
+                        "symbol": "BTC-USDT-SWAP",
+                        "side": "long",
+                        "quantity": 2.0,
+                        "avg_entry_price": 123.5,
+                        "margin_used": 200.0,
+                    },
+                    {
+                        "symbol": "ETH-USDT-SWAP",
+                        "side": "short",
+                        "quantity": 1.0,
+                        "avg_entry_price": 3000.0,
+                        "margin_used": 150.0,
+                    },
+                ],
+                "updated_at": "1970-01-01T00:00:00Z",
+            }
+        )
+    )
+    main, client, _, _, _, _ = build_main_with_stubs(state_path=state_path)
+    expected = [
+        {
+            "symbol": "BTC-USDT-SWAP",
+            "side": "long",
+            "quantity": 2.0,
+            "avg_entry_price": 123.5,
+            "margin_used": 200.0,
+        }
+    ]
 
     exit_code = main(["positions", "--symbol", "BTC-USDT-SWAP"])
 
     assert exit_code == 0
-    assert client.get_positions_called_with == "BTC-USDT-SWAP"
+    assert client.get_last_price_called_with is None
     assert json.loads(capsys.readouterr().out) == expected
 
 
+def test_fetch_history_does_not_require_credentials(capsys):
+    client = fake_client()
+    main = main_factory(
+        load_config=lambda: (_ for _ in ()).throw(AssertionError("should not load config")),
+        client_factory=lambda: client,
+        analyze_fn=fake_analyze_with_codex,
+        write_text=real_write_text,
+        state_path=Path("paper_state.json"),
+        now_fn=lambda: "1970-01-01T00:00:00Z",
+        report_fn=lambda **kwargs: {},
+        bbmr_report_fn=lambda **kwargs: {},
+    )
+
+    exit_code = main(["fetch-history", "--symbol", "BTC-USDT-SWAP", "--bar", "1H", "--limit", "2"])
+
+    assert exit_code == 0
+    assert client.get_candles_called_with == ("BTC-USDT-SWAP", "1H", 2)
+    assert json.loads(capsys.readouterr().out) == [asdict(candle) for candle in sample_candles(limit=2)]
+
+
 def test_cli_rejects_unsupported_symbol():
-    main, _ = build_main_with_stubs()
+    main, _, _, _, _, _ = build_main_with_stubs()
 
     with pytest.raises(SystemExit):
         main(["fetch-history", "--symbol", "SOL-USDT-SWAP", "--bar", "1H", "--limit", "20"])
 
 
 def test_cli_rejects_leverage_out_of_range():
-    main, _ = build_main_with_stubs()
+    main, _, _, _, _, _ = build_main_with_stubs()
 
     with pytest.raises(SystemExit):
         main(["backtest", "--symbol", "BTC-USDT-SWAP", "--bar", "1H", "--limit", "50", "--leverage", "4"])
+
+
+def test_backtest_report_generates_html_report(capsys, tmp_path):
+    main, client, report_calls, _, _, _ = build_main_with_stubs()
+    output_file = tmp_path / "report.html"
+
+    exit_code = main(
+        [
+            "backtest-report",
+            "--symbol",
+            "BTC-USDT-SWAP",
+            "--bar",
+            "1H",
+            "--limit",
+            "50",
+            "--leverage",
+            "2",
+            "--output-file",
+            str(output_file),
+        ]
+    )
+
+    assert exit_code == 0
+    assert client.get_candles_called_with == ("BTC-USDT-SWAP", "1H", 50)
+    assert len(report_calls) == 1
+    assert report_calls[0]["leverage"] == 2
+    assert report_calls[0]["output_file"] == output_file
+    assert report_calls[0]["symbol"] == "BTC-USDT-SWAP"
+    assert report_calls[0]["bar"] == "1H"
+    assert json.loads(capsys.readouterr().out) == {
+        "report_file": str(output_file),
+        "plot_file": str(output_file).replace(".html", ".plot.html"),
+        "trade_count": 3,
+        "total_return": 0.12,
+    }
+
+
+def test_backtest_bbmr_report_generates_single_page_report(capsys, tmp_path):
+    main, client, _, bbmr_report_calls, _, _ = build_main_with_stubs()
+    output_file = tmp_path / "bbmr.html"
+
+    exit_code = main(
+        [
+            "backtest-bbmr-report",
+            "--symbol",
+            "BTC-USDT-SWAP",
+            "--bar",
+            "3m",
+            "--history-limit",
+            "5000",
+            "--leverage",
+            "2",
+            "--segments",
+            "8",
+            "--window-size",
+            "300",
+            "--output-file",
+            str(output_file),
+        ]
+    )
+
+    assert exit_code == 0
+    assert client.get_candles_called_with == ("BTC-USDT-SWAP", "3m", 5000)
+    assert len(bbmr_report_calls) == 1
+    assert bbmr_report_calls[0]["symbol"] == "BTC-USDT-SWAP"
+    assert bbmr_report_calls[0]["bar"] == "3m"
+    assert bbmr_report_calls[0]["segments"] == 8
+    assert bbmr_report_calls[0]["window_size"] == 300
+    assert json.loads(capsys.readouterr().out) == {
+        "report_file": str(output_file),
+        "segment_count": 8,
+        "window_size": 300,
+        "aggregate_trade_count": 11,
+        "average_return": 0.031,
+    }
+
+
+def test_backtest_bbsb_report_generates_single_page_report(capsys, tmp_path):
+    main, client, _, _, bbsb_report_calls, _ = build_main_with_stubs()
+    output_file = tmp_path / "bbsb.html"
+
+    exit_code = main(
+        [
+            "backtest-bbsb-report",
+            "--symbol",
+            "BTC-USDT-SWAP",
+            "--bar",
+            "3m",
+            "--history-limit",
+            "5000",
+            "--leverage",
+            "2",
+            "--segments",
+            "8",
+            "--window-size",
+            "300",
+            "--output-file",
+            str(output_file),
+        ]
+    )
+
+    assert exit_code == 0
+    assert client.get_candles_called_with == ("BTC-USDT-SWAP", "3m", 5000)
+    assert len(bbsb_report_calls) == 1
+    assert bbsb_report_calls[0]["symbol"] == "BTC-USDT-SWAP"
+    assert bbsb_report_calls[0]["bar"] == "3m"
+    assert bbsb_report_calls[0]["segments"] == 8
+    assert bbsb_report_calls[0]["window_size"] == 300
+    assert json.loads(capsys.readouterr().out) == {
+        "report_file": str(output_file),
+        "segment_count": 8,
+        "window_size": 300,
+        "aggregate_trade_count": 11,
+        "average_return": 0.031,
+    }
+
+
+def test_backtest_donchian_report_dispatches_generator(capsys, tmp_path):
+    main, client, _, _, _, donchian_report_calls = build_main_with_stubs()
+    output_file = tmp_path / "donchian.html"
+
+    exit_code = main(
+        [
+            "backtest-donchian-report",
+            "--symbol",
+            "BTC-USDT-SWAP",
+            "--bar",
+            "3m",
+            "--history-limit",
+            "5000",
+            "--leverage",
+            "2",
+            "--segments",
+            "8",
+            "--window-size",
+            "300",
+            "--entry-window",
+            "30",
+            "--exit-window",
+            "12",
+            "--stop-loss-pct",
+            "0.02",
+            "--output-file",
+            str(output_file),
+        ]
+    )
+
+    assert exit_code == 0
+    assert client.get_candles_called_with == ("BTC-USDT-SWAP", "3m", 5000)
+    assert len(donchian_report_calls) == 1
+    assert donchian_report_calls[0]["symbol"] == "BTC-USDT-SWAP"
+    assert donchian_report_calls[0]["bar"] == "3m"
+    assert donchian_report_calls[0]["segments"] == 8
+    assert donchian_report_calls[0]["window_size"] == 300
+    assert donchian_report_calls[0]["entry_window"] == 30
+    assert donchian_report_calls[0]["exit_window"] == 12
+    assert donchian_report_calls[0]["stop_loss_pct"] == pytest.approx(0.02)
+    assert json.loads(capsys.readouterr().out) == {
+        "report_file": str(output_file),
+        "segment_count": 8,
+        "window_size": 300,
+        "aggregate_trade_count": 7,
+        "average_return": 0.024,
+    }
+
+
+def test_backtest_rsi2_report_dispatches_generator(capsys, tmp_path):
+    client = fake_client()
+    rsi2_report_calls: list[dict[str, object]] = []
+
+    def fake_rsi2_report(
+        *,
+        candles,
+        leverage,
+        output_file,
+        symbol,
+        bar,
+        segments,
+        window_size,
+        trend_sma,
+        rsi_length,
+        rsi_long_threshold,
+        rsi_short_threshold,
+        exit_rsi,
+    ):
+        rsi2_report_calls.append(
+            {
+                "candles": candles,
+                "leverage": leverage,
+                "output_file": output_file,
+                "symbol": symbol,
+                "bar": bar,
+                "segments": segments,
+                "window_size": window_size,
+                "trend_sma": trend_sma,
+                "rsi_length": rsi_length,
+                "rsi_long_threshold": rsi_long_threshold,
+                "rsi_short_threshold": rsi_short_threshold,
+                "exit_rsi": exit_rsi,
+            }
+        )
+        return {
+            "report_file": str(output_file),
+            "segment_count": segments,
+            "window_size": window_size,
+            "aggregate_trade_count": 5,
+            "average_return": 0.019,
+        }
+
+    main = main_factory(
+        load_config=lambda: sample_config(),
+        client_factory=lambda: client,
+        analyze_fn=fake_analyze_with_codex,
+        write_text=real_write_text,
+        state_path=Path("paper_state.json"),
+        now_fn=lambda: "1970-01-01T00:00:00Z",
+        report_fn=lambda **kwargs: {},
+        bbmr_report_fn=lambda **kwargs: {},
+        bbsb_report_fn=lambda **kwargs: {},
+        donchian_report_fn=lambda **kwargs: {},
+        rsi2_report_fn=fake_rsi2_report,
+    )
+    output_file = tmp_path / "rsi2.html"
+
+    exit_code = main(
+        [
+            "backtest-rsi2-report",
+            "--symbol",
+            "BTC-USDT-SWAP",
+            "--bar",
+            "3m",
+            "--history-limit",
+            "5000",
+            "--leverage",
+            "2",
+            "--segments",
+            "8",
+            "--window-size",
+            "300",
+            "--trend-sma",
+            "30",
+            "--rsi-length",
+            "3",
+            "--rsi-long-threshold",
+            "15",
+            "--rsi-short-threshold",
+            "85",
+            "--exit-rsi",
+            "55",
+            "--output-file",
+            str(output_file),
+        ]
+    )
+
+    assert exit_code == 0
+    assert client.get_candles_called_with == ("BTC-USDT-SWAP", "3m", 5000)
+    assert len(rsi2_report_calls) == 1
+    assert rsi2_report_calls[0]["symbol"] == "BTC-USDT-SWAP"
+    assert rsi2_report_calls[0]["bar"] == "3m"
+    assert rsi2_report_calls[0]["segments"] == 8
+    assert rsi2_report_calls[0]["window_size"] == 300
+    assert rsi2_report_calls[0]["trend_sma"] == 30
+    assert rsi2_report_calls[0]["rsi_length"] == 3
+    assert rsi2_report_calls[0]["rsi_long_threshold"] == pytest.approx(15.0)
+    assert rsi2_report_calls[0]["rsi_short_threshold"] == pytest.approx(85.0)
+    assert rsi2_report_calls[0]["exit_rsi"] == pytest.approx(55.0)
+    assert json.loads(capsys.readouterr().out) == {
+        "report_file": str(output_file),
+        "segment_count": 8,
+        "window_size": 300,
+        "aggregate_trade_count": 5,
+        "average_return": 0.019,
+    }
+
+
+def test_backtest_ema_pullback_report_dispatches_generator(capsys, tmp_path):
+    client = fake_client()
+    ema_pullback_report_calls: list[dict[str, object]] = []
+
+    def fake_ema_pullback_report(
+        *,
+        candles,
+        leverage,
+        output_file,
+        symbol,
+        bar,
+        segments,
+        window_size,
+        fast_ema,
+        slow_ema,
+        stop_buffer_pct,
+    ):
+        ema_pullback_report_calls.append(
+            {
+                "candles": candles,
+                "leverage": leverage,
+                "output_file": output_file,
+                "symbol": symbol,
+                "bar": bar,
+                "segments": segments,
+                "window_size": window_size,
+                "fast_ema": fast_ema,
+                "slow_ema": slow_ema,
+                "stop_buffer_pct": stop_buffer_pct,
+            }
+        )
+        return {
+            "report_file": str(output_file),
+            "segment_count": segments,
+            "window_size": window_size,
+            "aggregate_trade_count": 6,
+            "average_return": 0.021,
+        }
+
+    main = main_factory(
+        load_config=lambda: sample_config(),
+        client_factory=lambda: client,
+        analyze_fn=fake_analyze_with_codex,
+        write_text=real_write_text,
+        state_path=Path("paper_state.json"),
+        now_fn=lambda: "1970-01-01T00:00:00Z",
+        report_fn=lambda **kwargs: {},
+        bbmr_report_fn=lambda **kwargs: {},
+        bbsb_report_fn=lambda **kwargs: {},
+        donchian_report_fn=lambda **kwargs: {},
+        rsi2_report_fn=lambda **kwargs: {},
+        ema_pullback_report_fn=fake_ema_pullback_report,
+    )
+    output_file = tmp_path / "ema-pullback.html"
+
+    exit_code = main(
+        [
+            "backtest-ema-pullback-report",
+            "--symbol",
+            "BTC-USDT-SWAP",
+            "--bar",
+            "3m",
+            "--history-limit",
+            "5000",
+            "--leverage",
+            "2",
+            "--segments",
+            "8",
+            "--window-size",
+            "300",
+            "--fast-ema",
+            "30",
+            "--slow-ema",
+            "80",
+            "--stop-buffer-pct",
+            "0.01",
+            "--output-file",
+            str(output_file),
+        ]
+    )
+
+    assert exit_code == 0
+    assert client.get_candles_called_with == ("BTC-USDT-SWAP", "3m", 5000)
+    assert len(ema_pullback_report_calls) == 1
+    assert ema_pullback_report_calls[0]["symbol"] == "BTC-USDT-SWAP"
+    assert ema_pullback_report_calls[0]["bar"] == "3m"
+    assert ema_pullback_report_calls[0]["segments"] == 8
+    assert ema_pullback_report_calls[0]["window_size"] == 300
+    assert ema_pullback_report_calls[0]["fast_ema"] == 30
+    assert ema_pullback_report_calls[0]["slow_ema"] == 80
+    assert ema_pullback_report_calls[0]["stop_buffer_pct"] == pytest.approx(0.01)
+    assert json.loads(capsys.readouterr().out) == {
+        "report_file": str(output_file),
+        "segment_count": 8,
+        "window_size": 300,
+        "aggregate_trade_count": 6,
+        "average_return": 0.021,
+    }

+ 240 - 0
tests/test_donchian_report.py

@@ -0,0 +1,240 @@
+import pytest
+
+from okx_codex_trader import donchian_report
+from okx_codex_trader.donchian_report import (
+    DonchianConfig,
+    generate_donchian_sampled_report,
+    run_donchian_segment,
+)
+from okx_codex_trader.models import Candle
+from okx_codex_trader.sampled_report import SegmentResult
+
+
+def make_candle(index: int, open_price: float, high: float, low: float, close: float) -> Candle:
+    return Candle(
+        symbol="BTC-USDT-SWAP",
+        ts=index * 60_000,
+        open=open_price,
+        high=high,
+        low=low,
+        close=close,
+        volume=1_000.0 + index,
+    )
+
+
+def build_linear_candles(count: int) -> list[Candle]:
+    candles: list[Candle] = []
+    for index in range(count):
+        price = 100.0 + index
+        candles.append(make_candle(index, price, price + 1.0, price - 1.0, price))
+    return candles
+
+
+def build_long_trade_fixture() -> list[Candle]:
+    return [
+        make_candle(0, 100.0, 101.0, 99.0, 100.0),
+        make_candle(1, 100.0, 101.0, 99.0, 100.0),
+        make_candle(2, 100.0, 101.0, 99.0, 100.0),
+        make_candle(3, 101.0, 102.5, 100.5, 102.0),
+        make_candle(4, 103.0, 103.5, 102.0, 103.0),
+        make_candle(5, 102.5, 102.8, 99.8, 100.0),
+        make_candle(6, 99.9, 100.2, 99.2, 99.7),
+    ]
+
+
+def build_short_trade_fixture() -> list[Candle]:
+    return [
+        make_candle(0, 100.0, 101.0, 99.0, 100.0),
+        make_candle(1, 100.0, 101.0, 99.0, 100.0),
+        make_candle(2, 100.0, 101.0, 99.0, 100.0),
+        make_candle(3, 99.0, 99.5, 97.5, 98.0),
+        make_candle(4, 97.0, 97.5, 96.5, 97.0),
+        make_candle(5, 97.2, 100.5, 96.8, 100.0),
+        make_candle(6, 100.1, 100.6, 99.5, 100.3),
+    ]
+
+
+def build_stop_loss_fixture() -> list[Candle]:
+    return [
+        make_candle(0, 100.0, 101.0, 99.0, 100.0),
+        make_candle(1, 100.0, 101.0, 99.0, 100.0),
+        make_candle(2, 100.0, 101.0, 99.0, 100.0),
+        make_candle(3, 101.0, 102.5, 100.5, 102.0),
+        make_candle(4, 103.0, 103.5, 102.5, 103.0),
+        make_candle(5, 103.0, 103.2, 99.8, 100.0),
+    ]
+
+
+def build_entry_bar_stop_loss_fixture() -> list[Candle]:
+    return [
+        make_candle(0, 100.0, 101.0, 99.0, 100.0),
+        make_candle(1, 100.0, 101.0, 99.0, 100.0),
+        make_candle(2, 100.0, 101.0, 99.0, 100.0),
+        make_candle(3, 101.0, 102.5, 100.5, 102.0),
+        make_candle(4, 103.0, 103.5, 101.0, 102.5),
+    ]
+
+
+def build_gap_through_stop_fixture() -> list[Candle]:
+    return [
+        make_candle(0, 100.0, 101.0, 99.0, 100.0),
+        make_candle(1, 100.0, 101.0, 99.0, 100.0),
+        make_candle(2, 100.0, 101.0, 99.0, 100.0),
+        make_candle(3, 101.0, 102.5, 100.5, 102.0),
+        make_candle(4, 103.0, 103.5, 102.5, 103.0),
+        make_candle(5, 101.0, 101.2, 99.8, 100.0),
+    ]
+
+
+def build_final_bar_breakout_fixture() -> list[Candle]:
+    return [
+        make_candle(0, 100.0, 101.0, 99.0, 100.0),
+        make_candle(1, 100.0, 101.0, 99.0, 100.0),
+        make_candle(2, 100.0, 101.0, 99.0, 100.0),
+        make_candle(3, 100.0, 101.0, 99.0, 100.0),
+        make_candle(4, 101.0, 102.5, 100.5, 102.0),
+    ]
+
+
+def build_open_tail_fixture() -> list[Candle]:
+    return [
+        make_candle(0, 100.0, 101.0, 99.0, 100.0),
+        make_candle(1, 100.0, 101.0, 99.0, 100.0),
+        make_candle(2, 100.0, 101.0, 99.0, 100.0),
+        make_candle(3, 101.0, 102.5, 100.5, 102.0),
+        make_candle(4, 103.0, 104.0, 102.5, 104.0),
+        make_candle(5, 104.0, 105.5, 103.5, 105.0),
+    ]
+
+
+def test_run_donchian_segment_produces_long_trade():
+    result = run_donchian_segment(
+        candles=build_long_trade_fixture(),
+        leverage=2,
+        warmup_bars=3,
+        config=DonchianConfig(entry_window=3, exit_window=2, stop_loss_pct=0.2),
+    )
+
+    assert isinstance(result, SegmentResult)
+    assert result.trade_count == 1
+    assert result.trades[0]["side"] == "Long"
+    assert result.open_position is None
+
+
+def test_run_donchian_segment_produces_short_trade():
+    result = run_donchian_segment(
+        candles=build_short_trade_fixture(),
+        leverage=2,
+        warmup_bars=3,
+        config=DonchianConfig(entry_window=3, exit_window=2, stop_loss_pct=0.2),
+    )
+
+    assert isinstance(result, SegmentResult)
+    assert result.trade_count == 1
+    assert result.trades[0]["side"] == "Short"
+    assert result.open_position is None
+
+
+def test_run_donchian_segment_stop_loss_takes_precedence():
+    result = run_donchian_segment(
+        candles=build_stop_loss_fixture(),
+        leverage=2,
+        warmup_bars=3,
+        config=DonchianConfig(entry_window=3, exit_window=2, stop_loss_pct=0.01),
+    )
+
+    assert result.trade_count == 1
+    assert result.trades[0]["side"] == "Long"
+    assert result.trades[0]["exit_price"] == pytest.approx(101.97)
+
+
+def test_run_donchian_segment_allows_entry_bar_stop_loss():
+    result = run_donchian_segment(
+        candles=build_entry_bar_stop_loss_fixture(),
+        leverage=2,
+        warmup_bars=3,
+        config=DonchianConfig(entry_window=3, exit_window=2, stop_loss_pct=0.01),
+    )
+
+    assert result.trade_count == 1
+    assert result.open_position is None
+    assert result.trades[0]["exit_price"] == pytest.approx(101.97)
+
+
+def test_run_donchian_segment_exits_gap_through_stop_at_open():
+    result = run_donchian_segment(
+        candles=build_gap_through_stop_fixture(),
+        leverage=2,
+        warmup_bars=3,
+        config=DonchianConfig(entry_window=3, exit_window=2, stop_loss_pct=0.01),
+    )
+
+    assert result.trade_count == 1
+    assert result.trades[0]["exit_price"] == pytest.approx(101.0)
+
+
+def test_run_donchian_segment_does_not_generate_entry_from_final_bar():
+    result = run_donchian_segment(
+        candles=build_final_bar_breakout_fixture(),
+        leverage=2,
+        warmup_bars=3,
+        config=DonchianConfig(entry_window=3, exit_window=2, stop_loss_pct=0.01),
+    )
+
+    assert result.trade_count == 0
+    assert result.entries == []
+    assert result.open_position is None
+
+
+def test_run_donchian_segment_marks_open_position_to_market():
+    result = run_donchian_segment(
+        candles=build_open_tail_fixture(),
+        leverage=2,
+        warmup_bars=3,
+        config=DonchianConfig(entry_window=3, exit_window=2, stop_loss_pct=0.2),
+    )
+
+    assert result.trade_count == 0
+    assert result.trades == []
+    assert result.total_return == pytest.approx((10_388.349514563106 - 10_000.0) / 10_000.0)
+    assert result.open_position is not None
+
+
+def test_generate_donchian_sampled_report_uses_shared_shell_defaults(monkeypatch, tmp_path):
+    candles = build_linear_candles(5_000)
+    output_file = tmp_path / "donchian.html"
+    recorded: dict[str, object] = {}
+    sentinel = {
+        "report_file": str(output_file),
+        "segment_count": 2,
+        "window_size": 300,
+        "aggregate_trade_count": 4,
+        "average_return": 0.12,
+    }
+
+    def fake_generate_sampled_report(**kwargs):
+        recorded.update(kwargs)
+        return sentinel
+
+    monkeypatch.setattr(donchian_report, "generate_sampled_report", fake_generate_sampled_report)
+
+    result = generate_donchian_sampled_report(
+        candles=candles,
+        leverage=2,
+        output_file=output_file,
+        symbol="BTC-USDT-SWAP",
+        bar="3m",
+        segments=2,
+        window_size=300,
+    )
+
+    assert result == sentinel
+    assert recorded["report_title"] == "Donchian Sampled Report"
+    assert recorded["strategy_label"] == "Donchian"
+    assert recorded["strategy_params"] == {
+        "entry_window": 20,
+        "exit_window": 10,
+        "stop_loss_pct": 0.01,
+    }
+    assert recorded["warmup_bars"] == 20
+    assert callable(recorded["run_segment"])

+ 254 - 0
tests/test_ema_pullback_report.py

@@ -0,0 +1,254 @@
+import pytest
+
+from okx_codex_trader import ema_pullback_report
+from okx_codex_trader.ema_pullback_report import (
+    EMAPullbackConfig,
+    generate_ema_pullback_sampled_report,
+    run_ema_pullback_segment,
+)
+from okx_codex_trader.models import Candle
+from okx_codex_trader.sampled_report import SegmentResult
+
+
+def make_candle(index: int, open_price: float, high: float, low: float, close: float) -> Candle:
+    return Candle(
+        symbol="BTC-USDT-SWAP",
+        ts=index * 60_000,
+        open=open_price,
+        high=high,
+        low=low,
+        close=close,
+        volume=1_000.0 + index,
+    )
+
+
+def build_linear_candles(count: int) -> list[Candle]:
+    candles: list[Candle] = []
+    for index in range(count):
+        price = 100.0 + index
+        candles.append(make_candle(index, price, price + 1.0, price - 1.0, price))
+    return candles
+
+
+def build_long_trade_fixture() -> list[Candle]:
+    return [
+        make_candle(0, 100.0, 101.0, 99.0, 100.0),
+        make_candle(1, 102.0, 103.0, 101.0, 102.0),
+        make_candle(2, 104.0, 105.0, 103.0, 104.0),
+        make_candle(3, 103.0, 104.0, 102.0, 103.0),
+        make_candle(4, 104.0, 106.0, 103.0, 105.0),
+        make_candle(5, 104.0, 105.0, 103.2, 104.5),
+        make_candle(6, 104.2, 104.6, 102.8, 103.0),
+        make_candle(7, 104.5, 105.0, 104.0, 104.5),
+    ]
+
+
+def build_short_trade_fixture() -> list[Candle]:
+    return [
+        make_candle(0, 100.0, 101.0, 99.0, 100.0),
+        make_candle(1, 98.0, 99.0, 97.0, 98.0),
+        make_candle(2, 96.0, 97.0, 95.0, 96.0),
+        make_candle(3, 97.0, 98.0, 96.0, 97.0),
+        make_candle(4, 96.0, 97.0, 94.5, 95.0),
+        make_candle(5, 96.5, 96.6, 95.0, 95.5),
+        make_candle(6, 95.8, 97.4, 95.6, 97.0),
+        make_candle(7, 96.0, 96.2, 95.5, 96.0),
+    ]
+
+
+def build_stop_priority_fixture() -> list[Candle]:
+    return [
+        make_candle(0, 100.0, 101.0, 99.0, 100.0),
+        make_candle(1, 102.0, 103.0, 101.0, 102.0),
+        make_candle(2, 104.0, 105.0, 103.0, 104.0),
+        make_candle(3, 103.0, 104.0, 102.0, 103.0),
+        make_candle(4, 104.0, 106.0, 103.0, 105.0),
+        make_candle(5, 104.0, 104.3, 101.0, 102.0),
+        make_candle(6, 105.0, 106.0, 104.5, 105.0),
+    ]
+
+
+def build_gap_through_stop_fixture() -> list[Candle]:
+    return [
+        make_candle(0, 100.0, 101.0, 99.0, 100.0),
+        make_candle(1, 102.0, 103.0, 101.0, 102.0),
+        make_candle(2, 104.0, 105.0, 103.0, 104.0),
+        make_candle(3, 103.0, 104.0, 102.0, 103.0),
+        make_candle(4, 104.0, 106.0, 103.0, 105.0),
+        make_candle(5, 102.0, 103.0, 101.0, 102.5),
+    ]
+
+
+def build_final_bar_signal_fixture() -> list[Candle]:
+    return [
+        make_candle(0, 100.0, 101.0, 99.0, 100.0),
+        make_candle(1, 102.0, 103.0, 101.0, 102.0),
+        make_candle(2, 104.0, 105.0, 103.0, 104.0),
+        make_candle(3, 103.0, 104.0, 102.0, 103.0),
+        make_candle(4, 104.0, 106.0, 103.0, 105.0),
+    ]
+
+
+def build_open_tail_fixture() -> list[Candle]:
+    return [
+        make_candle(0, 100.0, 101.0, 99.0, 100.0),
+        make_candle(1, 102.0, 103.0, 101.0, 102.0),
+        make_candle(2, 104.0, 105.0, 103.0, 104.0),
+        make_candle(3, 103.0, 104.0, 102.0, 103.0),
+        make_candle(4, 104.0, 106.0, 103.0, 105.0),
+        make_candle(5, 104.0, 106.5, 103.5, 106.0),
+    ]
+
+
+def build_depleted_equity_fixture() -> list[Candle]:
+    return [
+        make_candle(0, 100.0, 101.0, 99.0, 100.0),
+        make_candle(1, 102.0, 103.0, 101.0, 102.0),
+        make_candle(2, 104.0, 105.0, 103.0, 104.0),
+        make_candle(3, 103.0, 104.0, 102.0, 103.0),
+        make_candle(4, 104.0, 106.0, 103.0, 105.0),
+        make_candle(5, 104.0, 104.3, 101.0, 102.0),
+        make_candle(6, 106.0, 108.0, 105.0, 107.0),
+        make_candle(7, 106.5, 107.0, 106.0, 106.5),
+    ]
+
+
+def test_run_ema_pullback_segment_produces_long_trade():
+    result = run_ema_pullback_segment(
+        candles=build_long_trade_fixture(),
+        leverage=2,
+        warmup_bars=4,
+        config=EMAPullbackConfig(fast_ema=2, slow_ema=4, stop_buffer_pct=0.005),
+    )
+
+    assert isinstance(result, SegmentResult)
+    assert result.trade_count == 1
+    assert result.trades[0]["side"] == "Long"
+    assert result.trades[0]["entry_price"] == pytest.approx(104.0)
+    assert result.trades[0]["exit_price"] == pytest.approx(104.5)
+    assert result.open_position is None
+
+
+def test_run_ema_pullback_segment_produces_short_trade():
+    result = run_ema_pullback_segment(
+        candles=build_short_trade_fixture(),
+        leverage=2,
+        warmup_bars=4,
+        config=EMAPullbackConfig(fast_ema=2, slow_ema=4, stop_buffer_pct=0.005),
+    )
+
+    assert isinstance(result, SegmentResult)
+    assert result.trade_count == 1
+    assert result.trades[0]["side"] == "Short"
+    assert result.trades[0]["entry_price"] == pytest.approx(96.5)
+    assert result.trades[0]["exit_price"] == pytest.approx(96.0)
+    assert result.open_position is None
+
+
+def test_run_ema_pullback_segment_stop_priority_is_correct():
+    result = run_ema_pullback_segment(
+        candles=build_stop_priority_fixture(),
+        leverage=2,
+        warmup_bars=4,
+        config=EMAPullbackConfig(fast_ema=2, slow_ema=4, stop_buffer_pct=0.005),
+    )
+
+    assert result.trade_count == 1
+    assert len(result.entries) == 1
+    assert result.trades[0]["exit_price"] == pytest.approx(102.485)
+    assert result.open_position is None
+
+
+def test_run_ema_pullback_segment_exits_gap_through_stop_at_open():
+    result = run_ema_pullback_segment(
+        candles=build_gap_through_stop_fixture(),
+        leverage=2,
+        warmup_bars=4,
+        config=EMAPullbackConfig(fast_ema=2, slow_ema=4, stop_buffer_pct=0.005),
+    )
+
+    assert result.trade_count == 1
+    assert result.trades[0]["exit_price"] == pytest.approx(102.0)
+    assert result.open_position is None
+
+
+def test_run_ema_pullback_segment_does_not_generate_entry_from_final_bar():
+    result = run_ema_pullback_segment(
+        candles=build_final_bar_signal_fixture(),
+        leverage=2,
+        warmup_bars=4,
+        config=EMAPullbackConfig(fast_ema=2, slow_ema=4, stop_buffer_pct=0.005),
+    )
+
+    assert result.trade_count == 0
+    assert result.entries == []
+    assert result.open_position is None
+
+
+def test_run_ema_pullback_segment_marks_open_position_to_market():
+    result = run_ema_pullback_segment(
+        candles=build_open_tail_fixture(),
+        leverage=2,
+        warmup_bars=4,
+        config=EMAPullbackConfig(fast_ema=2, slow_ema=4, stop_buffer_pct=0.005),
+    )
+
+    assert result.trade_count == 0
+    assert result.trades == []
+    assert result.total_return == pytest.approx((10_384.615384615385 - 10_000.0) / 10_000.0)
+    assert result.open_position is not None
+    assert result.open_position["side"] == "long"
+
+
+def test_run_ema_pullback_segment_does_not_reenter_after_equity_is_depleted():
+    result = run_ema_pullback_segment(
+        candles=build_depleted_equity_fixture(),
+        leverage=100,
+        warmup_bars=4,
+        config=EMAPullbackConfig(fast_ema=2, slow_ema=4, stop_buffer_pct=0.005),
+    )
+
+    assert result.trade_count == 1
+    assert len(result.entries) == 1
+    assert result.open_position is None
+    assert result.total_return <= -1.0
+
+
+def test_generate_ema_pullback_sampled_report_uses_shared_shell_defaults(monkeypatch, tmp_path):
+    candles = build_linear_candles(5_000)
+    output_file = tmp_path / "ema-pullback.html"
+    recorded: dict[str, object] = {}
+    sentinel = {
+        "report_file": str(output_file),
+        "segment_count": 2,
+        "window_size": 300,
+        "aggregate_trade_count": 4,
+        "average_return": 0.12,
+    }
+
+    def fake_generate_sampled_report(**kwargs):
+        recorded.update(kwargs)
+        return sentinel
+
+    monkeypatch.setattr(ema_pullback_report, "generate_sampled_report", fake_generate_sampled_report)
+
+    result = generate_ema_pullback_sampled_report(
+        candles=candles,
+        leverage=2,
+        output_file=output_file,
+        symbol="BTC-USDT-SWAP",
+        bar="3m",
+        segments=2,
+        window_size=300,
+    )
+
+    assert result == sentinel
+    assert recorded["report_title"] == "EMA Pullback Sampled Report"
+    assert recorded["strategy_label"] == "EMA Pullback"
+    assert recorded["strategy_params"] == {
+        "fast_ema": 20,
+        "slow_ema": 50,
+        "stop_buffer_pct": 0.005,
+    }
+    assert recorded["warmup_bars"] == 50
+    assert callable(recorded["run_segment"])

+ 34 - 0
tests/test_okx_client.py

@@ -112,6 +112,28 @@ def descending_candles_response() -> DummyResponse:
     )
 
 
+def older_candles_response() -> DummyResponse:
+    return DummyResponse(
+        {
+            "code": "0",
+            "msg": "",
+            "data": [
+                ["1709999701000", "24699", "24799", "24649", "24749", "90", "900", "900", "1"],
+                ["1709999700000", "24698", "24798", "24648", "24748", "80", "800", "800", "1"],
+            ],
+        }
+    )
+
+
+def full_page_candles_response() -> DummyResponse:
+    data = []
+    for offset in range(300):
+        ts = 1710000001000 - (offset * 1000)
+        close = 25050 - offset
+        data.append([str(ts), str(close - 50), str(close + 50), str(close - 100), str(close), "100", "1000", "1000", "1"])
+    return DummyResponse({"code": "0", "msg": "", "data": data})
+
+
 def instrument_response(symbol: str = "BTC-USDT-SWAP") -> DummyResponse:
     return DummyResponse(
         {
@@ -478,6 +500,18 @@ def test_get_candles_returns_chronological_ascending_order():
     assert [candle.ts for candle in candles] == [1710000000000, 1710000001000]
 
 
+def test_get_candles_paginates_when_limit_exceeds_single_page():
+    session = DummySession([full_page_candles_response(), older_candles_response()])
+    client = OkxClient(config=sample_config(), session=session)
+
+    candles = client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=302)
+
+    assert len(candles) == 302
+    assert candles[0].ts == 1709999700000
+    assert candles[1].ts == 1709999701000
+    assert len(session.request_paths) == 2
+
+
 def test_build_contract_size_rounds_down_to_lot_size():
     metadata = InstrumentMeta(ct_val=0.01, lot_sz=0.1, min_sz=0.1)
     assert build_contract_size(notional=251, price=25_000, metadata=metadata) == 1.0

+ 38 - 0
tests/test_report.py

@@ -0,0 +1,38 @@
+from pathlib import Path
+
+from okx_codex_trader.report import render_report_html
+
+
+def test_render_report_html_contains_metrics_trade_table_and_plot_frame():
+    html = render_report_html(
+        symbol="BTC-USDT-SWAP",
+        bar="1H",
+        leverage=2,
+        stats={
+            "Return [%]": 12.5,
+            "Win Rate [%]": 66.7,
+            "Max. Drawdown [%]": -8.2,
+            "# Trades": 3,
+        },
+        trades=[
+            {
+                "side": "Long",
+                "entry_time": "2026-04-01 00:00",
+                "exit_time": "2026-04-02 00:00",
+                "entry_price": 70000.0,
+                "exit_price": 72000.0,
+                "pnl": 200.0,
+                "return_pct": 2.8,
+            }
+        ],
+        plot_filename="report.plot.html",
+    )
+
+    assert "BTC-USDT-SWAP" in html
+    assert "1H" in html
+    assert "Leverage" in html
+    assert "Return [%]" in html
+    assert "Trade Journal" in html
+    assert "report.plot.html" in html
+    assert "2026-04-01 00:00" in html
+    assert "Long" in html

+ 238 - 0
tests/test_rsi2_report.py

@@ -0,0 +1,238 @@
+import pytest
+
+from okx_codex_trader import rsi2_report
+from okx_codex_trader.models import Candle
+from okx_codex_trader.rsi2_report import RSI2Config, generate_rsi2_sampled_report, run_rsi2_segment
+from okx_codex_trader.sampled_report import SegmentResult
+
+
+def make_candle(index: int, open_price: float, close: float) -> Candle:
+    high = max(open_price, close) + 1.0
+    low = min(open_price, close) - 1.0
+    return Candle(
+        symbol="BTC-USDT-SWAP",
+        ts=index * 60_000,
+        open=open_price,
+        high=high,
+        low=low,
+        close=close,
+        volume=1_000.0 + index,
+    )
+
+
+def build_linear_candles(count: int) -> list[Candle]:
+    candles: list[Candle] = []
+    for index in range(count):
+        price = 100.0 + index
+        candles.append(make_candle(index, price, price))
+    return candles
+
+
+def build_long_trade_fixture() -> list[Candle]:
+    return [
+        make_candle(0, 90.0, 90.0),
+        make_candle(1, 100.0, 100.0),
+        make_candle(2, 150.0, 150.0),
+        make_candle(3, 149.0, 149.0),
+        make_candle(4, 148.0, 148.0),
+        make_candle(5, 149.0, 149.0),
+        make_candle(6, 150.0, 150.0),
+    ]
+
+
+def build_short_trade_fixture() -> list[Candle]:
+    return [
+        make_candle(0, 160.0, 160.0),
+        make_candle(1, 150.0, 150.0),
+        make_candle(2, 100.0, 100.0),
+        make_candle(3, 101.0, 101.0),
+        make_candle(4, 102.0, 102.0),
+        make_candle(5, 101.0, 101.0),
+        make_candle(6, 100.0, 100.0),
+    ]
+
+
+def build_exit_priority_fixture() -> list[Candle]:
+    return [
+        make_candle(0, 90.0, 90.0),
+        make_candle(1, 100.0, 100.0),
+        make_candle(2, 150.0, 150.0),
+        make_candle(3, 149.1, 149.1),
+        make_candle(4, 149.0, 149.0),
+        make_candle(5, 149.1, 149.1),
+        make_candle(6, 149.2, 149.2),
+    ]
+
+
+def build_final_bar_signal_fixture() -> list[Candle]:
+    return [
+        make_candle(0, 90.0, 90.0),
+        make_candle(1, 100.0, 100.0),
+        make_candle(2, 150.0, 150.0),
+        make_candle(3, 149.0, 149.0),
+        make_candle(4, 148.0, 148.0),
+    ]
+
+
+def build_open_tail_fixture() -> list[Candle]:
+    return [
+        make_candle(0, 90.0, 90.0),
+        make_candle(1, 100.0, 100.0),
+        make_candle(2, 150.0, 150.0),
+        make_candle(3, 149.0, 149.0),
+        make_candle(4, 148.0, 148.0),
+        make_candle(5, 149.0, 151.0),
+    ]
+
+
+def build_depleted_equity_fixture() -> list[Candle]:
+    return [
+        make_candle(0, 90.0, 90.0),
+        make_candle(1, 100.0, 100.0),
+        make_candle(2, 150.0, 150.0),
+        make_candle(3, 149.0, 149.0),
+        make_candle(4, 148.0, 148.0),
+        make_candle(5, 149.0, 151.0),
+        make_candle(6, 0.0, 100.0),
+        make_candle(7, 150.0, 150.0),
+        make_candle(8, 149.0, 149.0),
+        make_candle(9, 150.0, 150.0),
+    ]
+
+
+def test_compute_rsi_uses_wilder_smoothing():
+    closes = rsi2_report.pd.Series([100.0, 102.07, 103.62, 103.14, 101.69], dtype=float)
+
+    rsi = rsi2_report._compute_rsi(closes, 2)
+
+    assert rsi[4] == pytest.approx(34.8747591522)
+
+
+def test_run_rsi2_segment_produces_long_trade():
+    result = run_rsi2_segment(
+        candles=build_long_trade_fixture(),
+        leverage=2,
+        warmup_bars=4,
+        config=RSI2Config(trend_sma=4, rsi_length=2, rsi_long_threshold=95.0),
+    )
+
+    assert isinstance(result, SegmentResult)
+    assert result.trade_count == 1
+    assert result.trades[0]["side"] == "Long"
+    assert result.trades[0]["entry_price"] == pytest.approx(149.0)
+    assert result.trades[0]["exit_price"] == pytest.approx(150.0)
+    assert result.open_position is None
+
+
+def test_run_rsi2_segment_produces_short_trade():
+    result = run_rsi2_segment(
+        candles=build_short_trade_fixture(),
+        leverage=2,
+        warmup_bars=4,
+        config=RSI2Config(trend_sma=4, rsi_length=2, rsi_short_threshold=5.0),
+    )
+
+    assert isinstance(result, SegmentResult)
+    assert result.trade_count == 1
+    assert result.trades[0]["side"] == "Short"
+    assert result.trades[0]["entry_price"] == pytest.approx(101.0)
+    assert result.trades[0]["exit_price"] == pytest.approx(100.0)
+    assert result.open_position is None
+
+
+def test_run_rsi2_segment_exit_priority_is_correct():
+    result = run_rsi2_segment(
+        candles=build_exit_priority_fixture(),
+        leverage=2,
+        warmup_bars=4,
+        config=RSI2Config(trend_sma=4, rsi_length=2, rsi_long_threshold=98.0, rsi_short_threshold=50.0, exit_rsi=96.5),
+    )
+
+    assert result.trade_count == 1
+    assert len(result.entries) == 1
+    assert result.trades[0]["side"] == "Long"
+    assert result.open_position is None
+
+
+def test_run_rsi2_segment_does_not_generate_entry_from_final_bar():
+    result = run_rsi2_segment(
+        candles=build_final_bar_signal_fixture(),
+        leverage=2,
+        warmup_bars=4,
+        config=RSI2Config(trend_sma=4, rsi_length=2, rsi_long_threshold=95.0),
+    )
+
+    assert result.trade_count == 0
+    assert result.entries == []
+    assert result.open_position is None
+
+
+def test_run_rsi2_segment_marks_open_position_to_market():
+    result = run_rsi2_segment(
+        candles=build_open_tail_fixture(),
+        leverage=2,
+        warmup_bars=4,
+        config=RSI2Config(trend_sma=4, rsi_length=2, rsi_long_threshold=95.0),
+    )
+
+    assert result.trade_count == 0
+    assert result.trades == []
+    assert result.total_return == pytest.approx((10_268.456375838927 - 10_000.0) / 10_000.0)
+    assert result.open_position is not None
+    assert result.open_position["side"] == "long"
+
+
+def test_run_rsi2_segment_stops_after_equity_is_depleted():
+    result = run_rsi2_segment(
+        candles=build_depleted_equity_fixture(),
+        leverage=2,
+        warmup_bars=4,
+        config=RSI2Config(trend_sma=4, rsi_length=2, rsi_long_threshold=95.0),
+    )
+
+    assert result.trade_count == 1
+    assert result.open_position is None
+    assert len(result.entries) == 1
+    assert result.total_return <= -1.0
+
+
+def test_generate_rsi2_sampled_report_uses_shared_shell_defaults(monkeypatch, tmp_path):
+    candles = build_linear_candles(5_000)
+    output_file = tmp_path / "rsi2.html"
+    recorded: dict[str, object] = {}
+    sentinel = {
+        "report_file": str(output_file),
+        "segment_count": 2,
+        "window_size": 300,
+        "aggregate_trade_count": 4,
+        "average_return": 0.12,
+    }
+
+    def fake_generate_sampled_report(**kwargs):
+        recorded.update(kwargs)
+        return sentinel
+
+    monkeypatch.setattr(rsi2_report, "generate_sampled_report", fake_generate_sampled_report)
+
+    result = generate_rsi2_sampled_report(
+        candles=candles,
+        leverage=2,
+        output_file=output_file,
+        symbol="BTC-USDT-SWAP",
+        bar="3m",
+        segments=2,
+        window_size=300,
+    )
+
+    assert result == sentinel
+    assert recorded["report_title"] == "RSI2 Sampled Report"
+    assert recorded["strategy_label"] == "RSI2"
+    assert recorded["strategy_params"] == {
+        "trend_sma": 50,
+        "rsi_length": 2,
+        "rsi_long_threshold": 10.0,
+        "rsi_short_threshold": 90.0,
+        "exit_rsi": 50.0,
+    }
+    assert recorded["warmup_bars"] == 50
+    assert callable(recorded["run_segment"])

+ 283 - 0
tests/test_sampled_report.py

@@ -0,0 +1,283 @@
+import importlib
+
+import pytest
+
+from okx_codex_trader.models import Candle
+
+
+def load_sampled_report_module():
+    try:
+        return importlib.import_module("okx_codex_trader.sampled_report")
+    except ModuleNotFoundError as exc:
+        pytest.fail(f"missing shared sampled-report module: {exc}")
+
+
+def build_linear_candles(count: int) -> list[Candle]:
+    candles: list[Candle] = []
+    for index in range(count):
+        close = 100.0 + index
+        candles.append(
+            Candle(
+                symbol="BTC-USDT-SWAP",
+                ts=index * 60_000,
+                open=close,
+                high=close + 1.0,
+                low=close - 1.0,
+                close=close,
+                volume=1_000.0 + index,
+            )
+        )
+    return candles
+
+
+def build_segment_result(module, *, total_return: float, trade_count: int, win_rate: float, max_drawdown: float):
+    return module.SegmentResult(
+        trade_count=trade_count,
+        total_return=total_return,
+        win_rate=win_rate,
+        max_drawdown=max_drawdown,
+        trades=[
+            {
+                "side": "Long",
+                "entry_time": "2026-04-01 00:00",
+                "exit_time": "2026-04-01 01:00",
+                "entry_price": 100.0,
+                "exit_price": 101.0,
+                "pnl": 10.0,
+                "return_pct": 1.0,
+            }
+        ],
+        open_position=None,
+        candles=build_linear_candles(2),
+        equity_curve=[
+            {"ts": 0, "equity": 10_000.0, "close": 100.0},
+            {"ts": 60_000, "equity": 10_000.0 * (1 + total_return), "close": 101.0},
+        ],
+        entries=[{"ts": 0, "price": 100.0, "side": "long"}],
+        exits=[{"ts": 60_000, "price": 101.0, "side": "long"}],
+    )
+
+
+def build_report_segment(module, *, result, index: int = 0, start_time: str = "2026-04-01 00:00", end_time: str = "2026-04-01 15:00"):
+    return module.ReportSegment(
+        index=index,
+        start_time=start_time,
+        end_time=end_time,
+        result=result,
+        plot_div="<div>plot0</div>",
+    )
+
+
+def test_sample_segments_is_deterministic():
+    module = load_sampled_report_module()
+    candles = build_linear_candles(5_000)
+
+    first = module.sample_segments(candles=candles, segments=4, window_size=300, warmup_bars=69, seed=7)
+    second = module.sample_segments(candles=candles, segments=4, window_size=300, warmup_bars=69, seed=7)
+
+    assert first == second
+    assert [segment.context_start for segment in first] == sorted(segment.context_start for segment in first)
+
+
+def test_sample_segments_rejects_undersized_history_pool():
+    module = load_sampled_report_module()
+
+    with pytest.raises(ValueError, match="history pool is too small"):
+        module.sample_segments(candles=build_linear_candles(1_000), segments=8, window_size=300, warmup_bars=69, seed=7)
+
+
+def test_sample_segments_returns_exact_non_overlapping_block_ranges():
+    module = load_sampled_report_module()
+
+    sampled = module.sample_segments(candles=build_linear_candles(1_300), segments=3, window_size=300, warmup_bars=69, seed=7)
+
+    assert [(segment.context_start, segment.report_start, segment.report_end) for segment in sampled] == [
+        (0, 69, 369),
+        (369, 438, 738),
+        (738, 807, 1107),
+    ]
+
+
+def test_sample_segments_rejects_invalid_sampling_result(tmp_path, monkeypatch):
+    module = load_sampled_report_module()
+    candles = build_linear_candles(5_000)
+
+    monkeypatch.setattr(
+        module,
+        "sample_segments",
+        lambda **_: [
+            module.SampledSegment(
+                context_start=0,
+                report_start=69,
+                report_end=369,
+                start_ts=candles[69].ts,
+                end_ts=candles[368].ts,
+            )
+        ],
+    )
+
+    with pytest.raises(ValueError, match="invalid sampling result"):
+        module.generate_sampled_report(
+            candles=candles,
+            leverage=2,
+            output_file=tmp_path / "sampled-report.html",
+            symbol="BTC-USDT-SWAP",
+            bar="3m",
+            segments=2,
+            window_size=300,
+            report_title="Sampled Report",
+            strategy_label="Test Strategy",
+            strategy_description="Strategy description",
+            strategy_params={"entry_window": 20},
+            run_segment=lambda **_: pytest.fail("run_segment should not be called for invalid samples"),
+        )
+
+
+def test_generate_sampled_report_passes_sliced_window_and_warmup_bars(tmp_path, monkeypatch):
+    module = load_sampled_report_module()
+    candles = build_linear_candles(100)
+    sampled = [
+        module.SampledSegment(context_start=5, report_start=7, report_end=17, start_ts=candles[7].ts, end_ts=candles[16].ts),
+        module.SampledSegment(context_start=20, report_start=22, report_end=32, start_ts=candles[22].ts, end_ts=candles[31].ts),
+    ]
+    captured_calls: list[dict[str, object]] = []
+
+    monkeypatch.setattr(module, "sample_segments", lambda **_: sampled)
+
+    def run_segment(*, candles, leverage, warmup_bars):
+        captured_calls.append(
+            {
+                "ts": [candle.ts for candle in candles],
+                "leverage": leverage,
+                "warmup_bars": warmup_bars,
+            }
+        )
+        return build_segment_result(module, total_return=0.1, trade_count=1, win_rate=1.0, max_drawdown=0.05)
+
+    module.generate_sampled_report(
+        candles=candles,
+        leverage=3,
+        output_file=tmp_path / "sampled-report.html",
+        symbol="BTC-USDT-SWAP",
+        bar="3m",
+        segments=2,
+        window_size=10,
+        warmup_bars=2,
+        report_title="Shared Sampled Report",
+        strategy_label="Test Strategy",
+        strategy_description="Strategy description",
+        strategy_params={"entry_window": 20},
+        run_segment=run_segment,
+    )
+
+    assert captured_calls == [
+        {
+            "ts": [candle.ts for candle in candles[5:17]],
+            "leverage": 3,
+            "warmup_bars": 2,
+        },
+        {
+            "ts": [candle.ts for candle in candles[20:32]],
+            "leverage": 3,
+            "warmup_bars": 2,
+        },
+    ]
+
+
+def test_generate_sampled_report_aggregates_metrics(tmp_path, monkeypatch):
+    module = load_sampled_report_module()
+    calls = iter(
+        [
+            build_segment_result(module, total_return=0.1, trade_count=2, win_rate=0.5, max_drawdown=0.05),
+            build_segment_result(module, total_return=-0.2, trade_count=3, win_rate=1 / 3, max_drawdown=0.12),
+        ]
+    )
+    captured_render: dict[str, object] = {}
+
+    def render_sampled_report(**kwargs):
+        captured_render.update(kwargs)
+        return "<html>report</html>"
+
+    monkeypatch.setattr(module, "render_sampled_report", render_sampled_report)
+
+    report = module.generate_sampled_report(
+        candles=build_linear_candles(400),
+        leverage=2,
+        output_file=tmp_path / "sampled-report.html",
+        symbol="BTC-USDT-SWAP",
+        bar="3m",
+        segments=2,
+        window_size=10,
+        warmup_bars=2,
+        report_title="Shared Sampled Report",
+        strategy_label="Test Strategy",
+        strategy_description="Strategy description",
+        strategy_params={"entry_window": 20, "stop_loss_pct": 0.01},
+        run_segment=lambda **_: next(calls),
+    )
+
+    assert report == {
+        "report_file": str(tmp_path / "sampled-report.html"),
+        "segment_count": 2,
+        "window_size": 10,
+        "aggregate_trade_count": 5,
+        "average_return": -0.05,
+    }
+    assert (tmp_path / "sampled-report.html").exists()
+    assert (tmp_path / "sampled-report.html").read_text() == "<html>report</html>"
+    assert captured_render["aggregate_summary"] == {
+        "aggregate_trade_count": 5,
+        "average_return": -0.05,
+        "median_return": -0.05,
+        "best_segment_return": 0.1,
+        "worst_segment_return": -0.2,
+    }
+    assert all(isinstance(segment, module.ReportSegment) for segment in captured_render["segment_results"])
+    assert captured_render["segment_results"][0].result.trade_count == 2
+    assert captured_render["segment_results"][1].result.trade_count == 3
+
+
+def test_render_sampled_report_includes_strategy_params():
+    module = load_sampled_report_module()
+    result = build_segment_result(module, total_return=0.1, trade_count=3, win_rate=0.66, max_drawdown=0.05)
+
+    html = module.render_sampled_report(
+        symbol="BTC-USDT-SWAP",
+        bar="3m",
+        leverage=2,
+        history_limit=5_000,
+        segments=2,
+        window_size=300,
+        report_title="Shared Sampled Report",
+        strategy_label="Donchian",
+        strategy_description="Price breakout strategy.",
+        strategy_params={"entry_window": 20, "stop_loss_pct": 0.01},
+        aggregate_summary={
+            "aggregate_trade_count": 12,
+            "average_return": 0.1,
+            "median_return": 0.05,
+            "best_segment_return": 0.3,
+            "worst_segment_return": -0.2,
+        },
+        segment_results=[build_report_segment(module, result=result)],
+        bokeh_script="<script>plots</script>",
+    )
+
+    assert "Donchian sampled report" in html
+    assert "Price breakout strategy." in html
+    assert "Entry Window" in html
+    assert "20" in html
+    assert "Stop Loss Pct" in html
+    assert "0.01" in html
+
+
+def test_build_segment_plot_embeds_entry_exit_markers():
+    module = load_sampled_report_module()
+    segment = build_segment_result(module, total_return=0.1, trade_count=1, win_rate=1.0, max_drawdown=0.05)
+
+    plot = module.build_segment_plot(segment)
+    price_plot = plot.children[0]
+    markers = [getattr(renderer.glyph, "marker", None) for renderer in price_plot.renderers if hasattr(renderer, "glyph")]
+
+    assert "triangle" in markers
+    assert "inverted_triangle" in markers

+ 714 - 0
uv.lock

@@ -0,0 +1,714 @@
+version = 1
+revision = 3
+requires-python = ">=3.11"
+resolution-markers = [
+    "python_full_version >= '3.14' and sys_platform == 'win32'",
+    "python_full_version >= '3.14' and sys_platform == 'emscripten'",
+    "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+    "python_full_version < '3.14' and sys_platform == 'win32'",
+    "python_full_version < '3.14' and sys_platform == 'emscripten'",
+    "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+]
+
+[[package]]
+name = "backtesting"
+version = "0.6.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "bokeh" },
+    { name = "numpy" },
+    { name = "pandas" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/db/cc/a3bf58f45e1a58c28681fe1f173cdf748bd91e7cde60e3dcc29c8e9aa194/backtesting-0.6.5.tar.gz", hash = "sha256:738a1dee28fc53df2eda35ea2f2d1a1c37ddba01df14223fc9e87d80a1efbc2e", size = 194025, upload-time = "2025-07-30T05:57:05.265Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b3/b6/cf57538b968c5caa60ee626ec8be1c31e420067d2a4cf710d81605356f8c/backtesting-0.6.5-py3-none-any.whl", hash = "sha256:8ac2fa500c8fd83dc783b72957b600653a72687986fe3ca86d6ef6c8b8d74363", size = 192105, upload-time = "2025-07-30T05:57:03.322Z" },
+]
+
+[[package]]
+name = "bokeh"
+version = "3.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "contourpy" },
+    { name = "jinja2" },
+    { name = "narwhals" },
+    { name = "numpy" },
+    { name = "packaging" },
+    { name = "pillow" },
+    { name = "pyyaml" },
+    { name = "tornado", marker = "sys_platform != 'emscripten'" },
+    { name = "xyzservices" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bf/0d/fabb70707646217e4b0e3943e05730eab8c1f7b7e7485145f8594b52e606/bokeh-3.9.0.tar.gz", hash = "sha256:775219714a8496973ddbae16b1861606ba19fe670a421e4d43267b41148e07a3", size = 5740345, upload-time = "2026-03-11T17:58:34.062Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/47/0b/bdf449df87be3f07b23091ceafee8c3ef569cf6d2fb7edec6e3b12b3faa4/bokeh-3.9.0-py3-none-any.whl", hash = "sha256:b252bfb16a505f0e0c57d532d0df308ae1667235bafc622aa9441fe9e7c5ce4a", size = 6396068, upload-time = "2026-03-11T17:58:31.645Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.4.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" },
+    { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" },
+    { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" },
+    { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" },
+    { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" },
+    { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" },
+    { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" },
+    { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" },
+    { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" },
+    { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
+    { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
+    { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
+    { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
+    { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
+    { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
+    { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
+    { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
+    { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
+    { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
+    { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
+    { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
+    { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
+    { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
+    { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
+    { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
+    { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
+    { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
+    { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
+    { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
+    { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
+    { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
+    { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
+    { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
+    { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
+    { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
+    { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
+    { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
+    { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
+    { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
+    { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
+    { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
+]
+
+[[package]]
+name = "contourpy"
+version = "1.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" },
+    { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" },
+    { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" },
+    { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" },
+    { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" },
+    { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" },
+    { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" },
+    { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" },
+    { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" },
+    { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" },
+    { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" },
+    { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" },
+    { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" },
+    { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" },
+    { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" },
+    { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" },
+    { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" },
+    { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" },
+    { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" },
+    { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" },
+    { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" },
+    { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" },
+    { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" },
+    { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" },
+    { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" },
+    { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" },
+    { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" },
+    { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" },
+    { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" },
+    { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" },
+    { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" },
+    { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" },
+    { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" },
+    { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" },
+    { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" },
+    { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" },
+    { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" },
+    { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" },
+    { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" },
+    { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" },
+    { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" },
+    { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" },
+    { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" },
+    { url = "https://files.pythonhosted.org/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 = "idna"
+version = "3.13"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
+    { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
+    { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
+    { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
+    { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
+    { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
+    { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
+    { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
+    { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
+    { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
+    { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
+    { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
+    { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
+    { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
+    { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
+    { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
+    { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
+    { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
+    { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
+    { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
+    { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
+    { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
+    { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
+    { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
+    { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
+    { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
+    { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
+    { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
+    { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
+    { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
+    { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
+    { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
+    { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
+    { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
+    { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
+    { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
+    { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
+]
+
+[[package]]
+name = "narwhals"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e9/f3/257adc69a71011b4c8cda321b00f02c5bf1980ae38ffd05a58d9632d4de8/narwhals-2.20.0.tar.gz", hash = "sha256:c10994975fa7dc5a68c2cffcddbd5908fc8ebb2d463c5bab085309c0ee1f551e", size = 627848, upload-time = "2026-04-20T12:11:45.427Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d0/69/f24d3d1c38ad69e256138b4ec2452a8c7cf66be49dc214771ae99dd4f0a0/narwhals-2.20.0-py3-none-any.whl", hash = "sha256:16e750ea5507d4ba6e8d03455b5f93a535e0405976561baea235bca5dc9f475d", size = 449373, upload-time = "2026-04-20T12:11:43.596Z" },
+]
+
+[[package]]
+name = "numpy"
+version = "2.4.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" },
+    { url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" },
+    { url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" },
+    { url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" },
+    { url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" },
+    { url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" },
+    { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" },
+    { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" },
+    { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" },
+    { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" },
+    { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" },
+    { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" },
+    { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" },
+    { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" },
+    { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" },
+    { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" },
+    { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" },
+    { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" },
+    { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" },
+    { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" },
+    { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" },
+    { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" },
+    { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" },
+    { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" },
+    { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" },
+    { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" },
+    { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" },
+    { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" },
+    { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" },
+    { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" },
+    { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" },
+    { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" },
+    { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" },
+    { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" },
+    { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" },
+    { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" },
+    { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" },
+    { url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" },
+    { url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" },
+    { url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" },
+    { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" },
+]
+
+[[package]]
+name = "okx-codex-trader"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+    { name = "backtesting" },
+    { name = "requests" },
+]
+
+[package.metadata]
+requires-dist = [
+    { name = "backtesting", specifier = ">=0.6,<0.7" },
+    { name = "requests", specifier = ">=2.32,<3" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+]
+
+[[package]]
+name = "pandas"
+version = "3.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "numpy" },
+    { name = "python-dateutil" },
+    { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855, upload-time = "2026-03-31T06:48:30.816Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/97/35/6411db530c618e0e0005187e35aa02ce60ae4c4c4d206964a2f978217c27/pandas-3.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a727a73cbdba2f7458dc82449e2315899d5140b449015d822f515749a46cbbe0", size = 10326926, upload-time = "2026-03-31T06:46:08.29Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/d3/b7da1d5d7dbdc5ef52ed7debd2b484313b832982266905315dad5a0bf0b1/pandas-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dbbd4aa20ca51e63b53bbde6a0fa4254b1aaabb74d2f542df7a7959feb1d760c", size = 9926987, upload-time = "2026-03-31T06:46:11.724Z" },
+    { url = "https://files.pythonhosted.org/packages/52/77/9b1c2d6070b5dbe239a7bc889e21bfa58720793fb902d1e070695d87c6d0/pandas-3.0.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:339dda302bd8369dedeae979cb750e484d549b563c3f54f3922cb8ff4978c5eb", size = 10757067, upload-time = "2026-03-31T06:46:14.903Z" },
+    { url = "https://files.pythonhosted.org/packages/20/17/ec40d981705654853726e7ac9aea9ddbb4a5d9cf54d8472222f4f3de06c2/pandas-3.0.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:61c2fd96d72b983a9891b2598f286befd4ad262161a609c92dc1652544b46b76", size = 11258787, upload-time = "2026-03-31T06:46:17.683Z" },
+    { url = "https://files.pythonhosted.org/packages/90/e3/3f1126d43d3702ca8773871a81c9f15122a1f412342cc56284ffda5b1f70/pandas-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c934008c733b8bbea273ea308b73b3156f0181e5b72960790b09c18a2794fe1e", size = 11771616, upload-time = "2026-03-31T06:46:20.532Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/cf/0f4e268e1f5062e44a6bda9f925806721cd4c95c2b808a4c82ebe914f96b/pandas-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:60a80bb4feacbef5e1447a3f82c33209c8b7e07f28d805cfd1fb951e5cb443aa", size = 12337623, upload-time = "2026-03-31T06:46:23.754Z" },
+    { url = "https://files.pythonhosted.org/packages/44/a0/97a6339859d4acb2536efb24feb6708e82f7d33b2ed7e036f2983fcced82/pandas-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:ed72cb3f45190874eb579c64fa92d9df74e98fd63e2be7f62bce5ace0ade61df", size = 9897372, upload-time = "2026-03-31T06:46:26.703Z" },
+    { url = "https://files.pythonhosted.org/packages/8f/eb/781516b808a99ddf288143cec46b342b3016c3414d137da1fdc3290d8860/pandas-3.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:f12b1a9e332c01e09510586f8ca9b108fd631fd656af82e452d7315ef6df5f9f", size = 9154922, upload-time = "2026-03-31T06:46:30.284Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/b0/c20bd4d6d3f736e6bd6b55794e9cd0a617b858eaad27c8f410ea05d953b7/pandas-3.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:232a70ebb568c0c4d2db4584f338c1577d81e3af63292208d615907b698a0f18", size = 10347921, upload-time = "2026-03-31T06:46:33.36Z" },
+    { url = "https://files.pythonhosted.org/packages/35/d0/4831af68ce30cc2d03c697bea8450e3225a835ef497d0d70f31b8cdde965/pandas-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:970762605cff1ca0d3f71ed4f3a769ea8f85fc8e6348f6e110b8fea7e6eb5a14", size = 9888127, upload-time = "2026-03-31T06:46:36.253Z" },
+    { url = "https://files.pythonhosted.org/packages/61/a9/16ea9346e1fc4a96e2896242d9bc674764fb9049b0044c0132502f7a771e/pandas-3.0.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aff4e6f4d722e0652707d7bcb190c445fe58428500c6d16005b02401764b1b3d", size = 10399577, upload-time = "2026-03-31T06:46:39.224Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/a8/3a61a721472959ab0ce865ef05d10b0d6bfe27ce8801c99f33d4fa996e65/pandas-3.0.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef8b27695c3d3dc78403c9a7d5e59a62d5464a7e1123b4e0042763f7104dc74f", size = 10880030, upload-time = "2026-03-31T06:46:42.412Z" },
+    { url = "https://files.pythonhosted.org/packages/da/65/7225c0ea4d6ce9cb2160a7fb7f39804871049f016e74782e5dade4d14109/pandas-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f8d68083e49e16b84734eb1a4dcae4259a75c90fb6e2251ab9a00b61120c06ab", size = 11409468, upload-time = "2026-03-31T06:46:45.2Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/5b/46e7c76032639f2132359b5cf4c785dd8cf9aea5ea64699eac752f02b9db/pandas-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:32cc41f310ebd4a296d93515fcac312216adfedb1894e879303987b8f1e2b97d", size = 11936381, upload-time = "2026-03-31T06:46:48.293Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/8b/721a9cff6fa6a91b162eb51019c6243b82b3226c71bb6c8ef4a9bd65cbc6/pandas-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:a4785e1d6547d8427c5208b748ae2efb64659a21bd82bf440d4262d02bfa02a4", size = 9744993, upload-time = "2026-03-31T06:46:51.488Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/18/7f0bd34ae27b28159aa80f2a6799f47fda34f7fb938a76e20c7b7fe3b200/pandas-3.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:08504503f7101300107ecdc8df73658e4347586db5cfdadabc1592e9d7e7a0fd", size = 9056118, upload-time = "2026-03-31T06:46:54.548Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/ca/3e639a1ea6fcd0617ca4e8ca45f62a74de33a56ae6cd552735470b22c8d3/pandas-3.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5918ba197c951dec132b0c5929a00c0bf05d5942f590d3c10a807f6e15a57d3", size = 10321105, upload-time = "2026-03-31T06:46:57.327Z" },
+    { url = "https://files.pythonhosted.org/packages/0b/77/dbc82ff2fb0e63c6564356682bf201edff0ba16c98630d21a1fb312a8182/pandas-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d606a041c89c0a474a4702d532ab7e73a14fe35c8d427b972a625c8e46373668", size = 9864088, upload-time = "2026-03-31T06:46:59.935Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/2b/341f1b04bbca2e17e13cd3f08c215b70ef2c60c5356ef1e8c6857449edc7/pandas-3.0.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:710246ba0616e86891b58ab95f2495143bb2bc83ab6b06747c74216f583a6ac9", size = 10369066, upload-time = "2026-03-31T06:47:02.792Z" },
+    { url = "https://files.pythonhosted.org/packages/12/c5/cbb1ffefb20a93d3f0e1fdcda699fb84976210d411b008f97f48bf6ce27e/pandas-3.0.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d3cfe227c725b1f3dff4278b43d8c784656a42a9325b63af6b1492a8232209e", size = 10876780, upload-time = "2026-03-31T06:47:06.205Z" },
+    { url = "https://files.pythonhosted.org/packages/98/fe/2249ae5e0a69bd0ddf17353d0a5d26611d70970111f5b3600cdc8be883e7/pandas-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c3b723df9087a9a9a840e263ebd9f88b64a12075d1bf2ea401a5a42f254f084d", size = 11375181, upload-time = "2026-03-31T06:47:09.383Z" },
+    { url = "https://files.pythonhosted.org/packages/de/64/77a38b09e70b6464883b8d7584ab543e748e42c1b5d337a2ee088e0df741/pandas-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3096110bf9eac0070b7208465f2740e2d8a670d5cb6530b5bb884eca495fd39", size = 11928899, upload-time = "2026-03-31T06:47:12.686Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/52/42855bf626868413f761addd574acc6195880ae247a5346477a4361c3acb/pandas-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:07a10f5c36512eead51bc578eb3354ad17578b22c013d89a796ab5eee90cd991", size = 9746574, upload-time = "2026-03-31T06:47:15.64Z" },
+    { url = "https://files.pythonhosted.org/packages/88/39/21304ae06a25e8bf9fc820d69b29b2c495b2ae580d1e143146c309941760/pandas-3.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:5fdbfa05931071aba28b408e59226186b01eb5e92bea2ab78b65863ca3228d84", size = 9047156, upload-time = "2026-03-31T06:47:18.595Z" },
+    { url = "https://files.pythonhosted.org/packages/72/20/7defa8b27d4f330a903bb68eea33be07d839c5ea6bdda54174efcec0e1d2/pandas-3.0.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:dbc20dea3b9e27d0e66d74c42b2d0c1bed9c2ffe92adea33633e3bedeb5ac235", size = 10756238, upload-time = "2026-03-31T06:47:22.012Z" },
+    { url = "https://files.pythonhosted.org/packages/e9/95/49433c14862c636afc0e9b2db83ff16b3ad92959364e52b2955e44c8e94c/pandas-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b75c347eff42497452116ce05ef461822d97ce5b9ff8df6edacb8076092c855d", size = 10408520, upload-time = "2026-03-31T06:47:25.197Z" },
+    { url = "https://files.pythonhosted.org/packages/3b/f8/462ad2b5881d6b8ec8e5f7ed2ea1893faa02290d13870a1600fe72ad8efc/pandas-3.0.2-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1478075142e83a5571782ad007fb201ed074bdeac7ebcc8890c71442e96adf7", size = 10324154, upload-time = "2026-03-31T06:47:28.097Z" },
+    { url = "https://files.pythonhosted.org/packages/0a/65/d1e69b649cbcddda23ad6e4c40ef935340f6f652a006e5cbc3555ac8adb3/pandas-3.0.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5880314e69e763d4c8b27937090de570f1fb8d027059a7ada3f7f8e98bdcb677", size = 10714449, upload-time = "2026-03-31T06:47:30.85Z" },
+    { url = "https://files.pythonhosted.org/packages/47/a4/85b59bc65b8190ea3689882db6cdf32a5003c0ccd5a586c30fdcc3ffc4fc/pandas-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b5329e26898896f06035241a626d7c335daa479b9bbc82be7c2742d048e41172", size = 11338475, upload-time = "2026-03-31T06:47:34.026Z" },
+    { url = "https://files.pythonhosted.org/packages/1e/c4/bc6966c6e38e5d9478b935272d124d80a589511ed1612a5d21d36f664c68/pandas-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:81526c4afd31971f8b62671442a4b2b51e0aa9acc3819c9f0f12a28b6fcf85f1", size = 11786568, upload-time = "2026-03-31T06:47:36.941Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/74/09298ca9740beed1d3504e073d67e128aa07e5ca5ca2824b0c674c0b8676/pandas-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:7cadd7e9a44ec13b621aec60f9150e744cfc7a3dd32924a7e2f45edff31823b0", size = 10488652, upload-time = "2026-03-31T06:47:40.612Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/40/c6ea527147c73b24fc15c891c3fcffe9c019793119c5742b8784a062c7db/pandas-3.0.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db0dbfd2a6cdf3770aa60464d50333d8f3d9165b2f2671bcc299b72de5a6677b", size = 10326084, upload-time = "2026-03-31T06:47:43.834Z" },
+    { url = "https://files.pythonhosted.org/packages/95/25/bdb9326c3b5455f8d4d3549fce7abcf967259de146fe2cf7a82368141948/pandas-3.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0555c5882688a39317179ab4a0ed41d3ebc8812ab14c69364bbee8fb7a3f6288", size = 9914146, upload-time = "2026-03-31T06:47:46.67Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/77/3a227ff3337aa376c60d288e1d61c5d097131d0ac71f954d90a8f369e422/pandas-3.0.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01f31a546acd5574ef77fe199bc90b55527c225c20ccda6601cf6b0fd5ed597c", size = 10444081, upload-time = "2026-03-31T06:47:49.681Z" },
+    { url = "https://files.pythonhosted.org/packages/15/88/3cdd54fa279341afa10acf8d2b503556b1375245dccc9315659f795dd2e9/pandas-3.0.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deeca1b5a931fdf0c2212c8a659ade6d3b1edc21f0914ce71ef24456ca7a6535", size = 10897535, upload-time = "2026-03-31T06:47:53.033Z" },
+    { url = "https://files.pythonhosted.org/packages/06/9d/98cc7a7624f7932e40f434299260e2917b090a579d75937cb8a57b9d2de3/pandas-3.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f48afd9bb13300ffb5a3316973324c787054ba6665cda0da3fbd67f451995db", size = 11446992, upload-time = "2026-03-31T06:47:56.193Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/cd/19ff605cc3760e80602e6826ddef2824d8e7050ed80f2e11c4b079741dc3/pandas-3.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c4d8458b97a35717b62469a4ea0e85abd5ed8687277f5ccfc67f8a5126f8c53", size = 11968257, upload-time = "2026-03-31T06:47:59.137Z" },
+    { url = "https://files.pythonhosted.org/packages/db/60/aba6a38de456e7341285102bede27514795c1eaa353bc0e7638b6b785356/pandas-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:b35d14bb5d8285d9494fe93815a9e9307c0876e10f1e8e89ac5b88f728ec8dcf", size = 9865893, upload-time = "2026-03-31T06:48:02.038Z" },
+    { url = "https://files.pythonhosted.org/packages/08/71/e5ec979dd2e8a093dacb8864598c0ff59a0cee0bbcdc0bfec16a51684d4f/pandas-3.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:63d141b56ef686f7f0d714cfb8de4e320475b86bf4b620aa0b7da89af8cbdbbb", size = 9188644, upload-time = "2026-03-31T06:48:05.045Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/6c/7b45d85db19cae1eb524f2418ceaa9d85965dcf7b764ed151386b7c540f0/pandas-3.0.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:140f0cffb1fa2524e874dde5b477d9defe10780d8e9e220d259b2c0874c89d9d", size = 10776246, upload-time = "2026-03-31T06:48:07.789Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/3e/7b00648b086c106e81766f25322b48aa8dfa95b55e621dbdf2fdd413a117/pandas-3.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae37e833ff4fed0ba352f6bdd8b73ba3ab3256a85e54edfd1ab51ae40cca0af8", size = 10424801, upload-time = "2026-03-31T06:48:10.897Z" },
+    { url = "https://files.pythonhosted.org/packages/da/6e/558dd09a71b53b4008e7fc8a98ec6d447e9bfb63cdaeea10e5eb9b2dabe8/pandas-3.0.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d888a5c678a419a5bb41a2a93818e8ed9fd3172246555c0b37b7cc27027effd", size = 10345643, upload-time = "2026-03-31T06:48:13.7Z" },
+    { url = "https://files.pythonhosted.org/packages/be/e3/921c93b4d9a280409451dc8d07b062b503bbec0531d2627e73a756e99a82/pandas-3.0.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b444dc64c079e84df91baa8bf613d58405645461cabca929d9178f2cd392398d", size = 10743641, upload-time = "2026-03-31T06:48:16.659Z" },
+    { url = "https://files.pythonhosted.org/packages/56/ca/fd17286f24fa3b4d067965d8d5d7e14fe557dd4f979a0b068ac0deaf8228/pandas-3.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4544c7a54920de8eeacaa1466a6b7268ecfbc9bc64ab4dbb89c6bbe94d5e0660", size = 11361993, upload-time = "2026-03-31T06:48:19.475Z" },
+    { url = "https://files.pythonhosted.org/packages/e4/a5/2f6ed612056819de445a433ca1f2821ac3dab7f150d569a59e9cc105de1d/pandas-3.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:734be7551687c00fbd760dc0522ed974f82ad230d4a10f54bf51b80d44a08702", size = 11815274, upload-time = "2026-03-31T06:48:22.695Z" },
+    { url = "https://files.pythonhosted.org/packages/00/2f/b622683e99ec3ce00b0854bac9e80868592c5b051733f2cf3a868e5fea26/pandas-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:57a07209bebcbcf768d2d13c9b78b852f9a15978dac41b9e6421a81ad4cdd276", size = 10888530, upload-time = "2026-03-31T06:48:25.806Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/2b/f8434233fab2bd66a02ec014febe4e5adced20e2693e0e90a07d118ed30e/pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", size = 9455341, upload-time = "2026-03-31T06:48:28.418Z" },
+]
+
+[[package]]
+name = "pillow"
+version = "12.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" },
+    { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" },
+    { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" },
+    { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" },
+    { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" },
+    { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" },
+    { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" },
+    { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" },
+    { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" },
+    { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" },
+    { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" },
+    { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" },
+    { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" },
+    { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" },
+    { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
+    { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
+    { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
+    { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
+    { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
+    { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
+    { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
+    { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
+    { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
+    { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
+    { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
+    { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
+    { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
+    { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
+    { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
+    { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
+    { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
+    { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
+    { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
+    { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
+    { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
+    { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
+    { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
+    { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
+    { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" },
+    { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" },
+    { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" },
+    { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" },
+]
+
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
+    { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
+    { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
+    { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
+    { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
+    { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+    { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+    { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+    { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+    { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+    { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+    { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+    { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+    { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+    { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+    { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+    { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+    { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+    { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+    { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+    { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+    { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+    { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+    { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+    { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.33.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "certifi" },
+    { name = "charset-normalizer" },
+    { name = "idna" },
+    { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
+]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
+]
+
+[[package]]
+name = "tornado"
+version = "6.5.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" },
+    { url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" },
+    { url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" },
+    { url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" },
+    { url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" },
+    { url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" },
+]
+
+[[package]]
+name = "tzdata"
+version = "2026.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
+]
+
+[[package]]
+name = "xyzservices"
+version = "2026.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/08/3cb9f67a8d48021aca2a02292cc26eecd71d949ae70ad66420a8730cc302/xyzservices-2026.3.0.tar.gz", hash = "sha256:d226866a5d8e9fef337034d8da37a8298f0a1d9d1489b4018e69579eb321fea4", size = 1135736, upload-time = "2026-03-30T14:42:25.596Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a8/a9/d23012099dc88ec69a29c6407b41d89681cb674c2043cd5b467c7e299c08/xyzservices-2026.3.0-py3-none-any.whl", hash = "sha256:503183d4b322bfebc3c50cdd21192aa3e81e36c5efbf9133d54ae82143e0576b", size = 94101, upload-time = "2026-03-30T14:42:24.608Z" },
+]