[
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: Test\n\non: [push, pull_request]\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n      - uses: cachix/install-nix-action@v30\n      - name: Download data\n        run: nix develop --command python data/fetch_data.py all --symbols SPY --force\n      - name: Run tests\n        run: nix develop --command python -m pytest -v tests/ --ignore=tests/bench --ignore=tests/compat\n      - name: Type check\n        run: nix develop --command python -m mypy options_portfolio_backtester --ignore-missing-imports\n"
  },
  {
    "path": ".gitignore",
    "content": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n.hypothesis/\n.pytest_cache/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\ndb.sqlite3\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# celery beat schedule file\ncelerybeat-schedule\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n.env\n.venv\n.venv-bt/\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n\n# Generated charts/plots\n*.png\n\n# Mac OS-specific storage files\n.DS_Store\n\n# VS Code\n.vscode/\n_ob_rust.so\nmutants/\n\n# Large regenerable test data (from tests/bench/extract_prod_slices.py)\ntests/data/*.csv\n\n# exclude data from source control by default\ndata/*\n!data/README.md\n!data/fetch_data.py\n!data/fetch_signals.py\n!data/convert_optionsdx.py\n!data/raw/\ndata/raw/*\n!data/raw/.gitkeep\n!data/processed/\ndata/processed/*\n!data/processed/.gitkeep\n!data/processed/signals.csv\n"
  },
  {
    "path": ".python-version",
    "content": "3.12\n\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "Contributing\n============\n\nContributions are welcome and very much appreciated. Credit will be appropriately given.\n\n## License\n\nBy contributing, you agree that your contributions will be licensed under its MIT License.\n\n## Code contributions\n\nTo begin contributing to the project, please follow these steps.\n\n1. [Fork](https://help.github.com/en/articles/fork-a-repo) the repo.\n2. Clone your fork locally:\n\n```shell\n$ git clone git@github.com:your_username/backtester_options.git\n```\n\n3. Create the environment and install dependencies:\n\n```shell\n$ make init\n```\n\n4. Create your development branch from `master`\n\n```shell\n$ git checkout -b your_branch master\n```\n\n5. Start coding your contribution (Thanks!)\n\n6. Make sure your code passes all tests, lints and is formatted correctly (`TODO`: Add linting, code formatting to Travis.)\n\n6. Submit a pull request with a brief explanation of your work.\n\n## Types of Contributions\n\n### Bug reports\n\nMake sure to follow the setup steps detailed in the [readme](README.md). If you find a bug, please create an issue with the label `bug` and provide the following information:\n\n- Operating System and version.\n- Steps taken to replicate the bug.\n- What was the expected output and what actually happend.\n- Any details of your local environment that might be helpful for troubleshooting.\n\n### Bug fixes\n\nIf you find a bug issue you want to fix, follow the steps outlined [above](#code-contributions) and submit a pull request with a link to the original issue.\n\n### Proposing Features\n\nCreate an issue detailing what functionality you'd like to see implemented. If you can, provide general advice as to how the proposed feature could be done.\n\n### Implementing Features\n\nFind an issue with the label `help wanted` or `improvement` and [start coding](#code-contributions).  \nWhen you are done, submit a pull request with a link to the original issue and some code samples showing how the code works. Tests are expected when adding new functionality.\n\n\n### Documentation\n\nWe encourage users to improve our project documentation, either via docstrings, markdown documents to be added to the [project wiki](https://github.com/lambdaclass/backtester_options/wiki) or writing blog posts.  \nLet us know via issues labeled `docs` and we'll credit you appropriately.\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2019 Federico Carrone\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "Makefile",
    "content": "NIX_CMD := XDG_CACHE_HOME=$(CURDIR)/.cache nix --extra-experimental-features 'nix-command flakes' develop --command\nRUNCMD := $(NIX_CMD)\nPYTHON := python\n\n.PHONY: test test-bench lint typecheck notebooks rust-build rust-test rust-bench bench install-dev compare-bt benchmark-matrix walk-forward-report parity-gate bench-rust-vs-python help\n.DEFAULT_GOAL := help\n\ntest: ## Run all tests\n\t$(RUNCMD) $(PYTHON) -m pytest -v tests\n\ntest-bench: ## Run benchmark/property tests\n\t$(RUNCMD) $(PYTHON) -m pytest -v -m bench tests/bench\n\nlint: ## Run ruff linter\n\t$(RUNCMD) $(PYTHON) -m ruff check options_portfolio_backtester\n\ntypecheck: ## Run mypy type checker\n\t$(RUNCMD) $(PYTHON) -m mypy options_portfolio_backtester --ignore-missing-imports\n\nnotebooks: ## Execute all notebooks\n\t@for nb in notebooks/*.ipynb; do \\\n\t\techo \"Running $$nb...\"; \\\n\t\t$(RUNCMD) $(PYTHON) -m jupyter nbconvert --to notebook --execute \"$$nb\" \\\n\t\t\t--output \"$$(basename $$nb)\" --ExecutePreprocessor.timeout=600 || true; \\\n\tdone\n\nrust-build: ## Build Rust extension with maturin (release)\n\t$(RUNCMD) bash -c 'cd rust && maturin develop --manifest-path ob_python/Cargo.toml --release'\n\nrust-test: ## Run Rust unit tests\n\t$(RUNCMD) bash -c 'cd rust && cargo test'\n\nrust-bench: ## Run Rust benchmarks (criterion)\n\t$(RUNCMD) bash -c 'cd rust && cargo bench'\n\nbench: rust-build ## Run Python benchmarks (requires Rust build)\n\t$(RUNCMD) $(PYTHON) -m pytest tests/bench/ -v -m bench --benchmark-only 2>/dev/null || \\\n\t\techo \"Install pytest-benchmark for Python benchmarks\"\n\ninstall-dev: ## Install local dev deps into active nix dev environment\n\t$(PYTHON) -m pip install -e '.[dev,charts,notebooks,rust]'\n\ncompare-bt: ## Compare stock-only monthly rebalance vs bt library\n\t$(RUNCMD) $(PYTHON) scripts/compare_with_bt.py\n\nbenchmark-matrix: ## Run standardized runtime/accuracy matrix vs bt\n\t$(RUNCMD) $(PYTHON) scripts/benchmark_matrix.py\n\nwalk-forward-report: ## Run walk-forward/OOS harness and save report\n\t$(RUNCMD) $(PYTHON) scripts/walk_forward_report.py\n\nbench-rust-vs-python: ## Benchmark Rust vs Python vs bt (options + stock-only)\n\t$(RUNCMD) $(PYTHON) scripts/benchmark_rust_vs_python.py --stock-only\n\nparity-gate: ## Run bt overlap parity CI gate (bench marker)\n\t$(RUNCMD) $(PYTHON) -m pytest -v tests/compat/test_bt_overlap_gate.py -m bench\n\nhelp:\n\t@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) |\\\n\tawk 'BEGIN {FS = \":.*?## \"}; {printf \"\\033[36m%-30s\\033[0m %s\\n\", $$1, $$2}'\n"
  },
  {
    "path": "README.md",
    "content": "Options Portfolio Backtester\n============================\n\nBacktest options strategies with realistic execution, Greeks-aware risk management, and contract-level inventory. Also handles equities and multi-asset portfolios. Optional Rust core for speed.\n\n## Get started\n\n### Install\n\nWith Nix:\n```shell\nnix develop\n```\n\nWithout Nix (Python >= 3.12):\n```shell\npython -m venv .venv && source .venv/bin/activate\nmake install-dev\n```\n\n### Get data\n\n```shell\npython data/fetch_data.py all --symbols SPY\n```\n\nDownloads SPY stock prices and options chains to `data/processed/`. Supports 104+ symbols. See [`data/README.md`](data/README.md) for details.\n\n### Run your first backtest\n\n```python\nfrom options_portfolio_backtester import (\n    BacktestEngine, Stock, Type, Direction,\n    HistoricalOptionsData, TiingoData,\n    Strategy, StrategyLeg,\n    NearestDelta, PerContractCommission,\n    RiskManager, MaxDelta, MaxDrawdown,\n)\n\n# Load data\noptions_data = HistoricalOptionsData(\"data/processed/options.csv\")\nstocks_data = TiingoData(\"data/processed/stocks.csv\")\nschema = options_data.schema\n\n# Define strategy: buy OTM puts on SPY, exit when DTE drops below 30\nstrategy = Strategy(schema)\nleg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\nleg.entry_filter = (\n    (schema.underlying == \"SPY\")\n    & (schema.dte >= 60) & (schema.dte <= 120)\n    & (schema.delta >= -0.25) & (schema.delta <= -0.10)\n)\nleg.exit_filter = schema.dte <= 30\nstrategy.add_leg(leg)\n\n# Run backtest: 97% stocks, 3% options\nengine = BacktestEngine(\n    allocation={\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0.0},\n    initial_capital=1_000_000,\n    cost_model=PerContractCommission(rate=0.65),\n    signal_selector=NearestDelta(target_delta=-0.20),\n    risk_manager=RiskManager([MaxDelta(100.0), MaxDrawdown(0.20)]),\n)\nengine.stocks = [Stock(\"SPY\", 1.0)]\nengine.stocks_data = stocks_data\nengine.options_data = options_data\nengine.options_strategy = strategy\nengine.run(rebalance_freq=1)\n\n# Results\nprint(engine.balance[\"total capital\"].iloc[-1])  # final capital\nprint(len(engine.trade_log))                      # number of trades\n```\n\n### Strategy presets\n\nInstead of building legs manually:\n\n```python\nfrom options_portfolio_backtester import Strangle\n\nstrangle = Strangle(schema, \"short\", \"SPY\",\n                    dte_entry_range=(30, 60), dte_exit=7,\n                    otm_pct=5, pct_tolerance=1,\n                    exit_thresholds=(0.2, 0.2))\n```\n\nAvailable presets: `Strangle`, `IronCondor`, `CoveredCall`, `CashSecuredPut`, `Collar`, `Butterfly`.\n\n### Stock-only backtest with algo pipeline\n\nFor equity portfolios without options, use the pipeline API:\n\n```python\nfrom options_portfolio_backtester.engine.pipeline import (\n    AlgoPipelineBacktester,\n    RunMonthly, SelectAll, WeighInvVol, LimitWeights, Rebalance,\n)\nimport pandas as pd\n\nprices = pd.read_csv(\"data/processed/stocks.csv\", parse_dates=[\"date\"])\nprices = prices.pivot(index=\"date\", columns=\"symbol\", values=\"adjClose\")\n\nbt = AlgoPipelineBacktester(\n    prices=prices,\n    initial_capital=1_000_000,\n    algos=[\n        RunMonthly(),\n        SelectAll(),\n        WeighInvVol(lookback=252),\n        LimitWeights(limit=0.25),\n        Rebalance(),\n    ],\n)\nbt.run()\n```\n\n## Execution models\n\nEvery component is swappable. Pass them to `BacktestEngine(...)` or override per-leg.\n\n**Signal selectors** — which contract to pick from candidates:\n`FirstMatch()`, `NearestDelta(target)`, `MaxOpenInterest()`\n\n**Cost models** — commissions and fees:\n`NoCosts()`, `PerContractCommission(rate)`, `TieredCommission(tiers)`, `SpreadSlippage(pct)`\n\n**Fill models** — execution price:\n`MarketAtBidAsk()`, `MidPrice()`, `VolumeAwareFill(threshold)`\n\n**Position sizers** — how many contracts:\n`CapitalBased()`, `FixedQuantity(qty)`, `FixedDollar(amount)`, `PercentOfPortfolio(pct)`\n\n**Risk constraints** — pre-trade gating:\n`MaxDelta(limit)`, `MaxVega(limit)`, `MaxDrawdown(max_dd_pct)`\n\n## Rebalancing model\n\nAt each rebalance date, the engine follows a **full liquidation** approach:\n\n1. **Liquidate all options** — every open option position is sold at current market price (bid for long, ask for short)\n2. **Compute total capital** — cash + stock value (options are zero after liquidation)\n3. **Rebalance stocks** — sell all stocks, buy fresh at target allocation (e.g. 97%)\n4. **Buy new options** — use the full options allocation (e.g. 3%) to purchase contracts matching entry criteria (DTE, delta, etc.)\n\nThis ensures:\n- **Clean accounting** — no stale option value carried across rebalances, no money creation\n- **Fresh positions** — every rebalance picks the best available contracts for current market conditions\n- **Simple math** — `total_capital = cash + stocks` at the point of redeployment, no complex delta tracking\n\nBetween rebalance dates, positions are held (mark-to-market for balance tracking). If `check_exits_daily=True`, exit filters run daily but no new entries are made until the next rebalance.\n\nFor the **Spitznagel leverage** model (`options_budget` parameter), options are funded separately from the stock allocation so `{stocks: 1.0, options: 0.005}` means 100% equity + 0.5% put budget on top.\n\n## Rust acceleration\n\nOptional. Falls back to Python when not installed.\n\n```shell\nmake rust-build\n```\n\n| Benchmark | Python | Rust |\n|-----------|--------|------|\n| Full options backtest (24.7M rows) | 10.0s | **4.2s** |\n| Stock-only monthly rebalance | 3.7s | **0.6s** |\n| Parallel grid sweep (100 configs) | — | **5-8x** faster (Rayon, bypasses GIL) |\n\n## Data\n\n```shell\n# SPY stock + options data\npython data/fetch_data.py all --symbols SPY\n\n# Multiple symbols\npython data/fetch_data.py all --symbols SPY IWM QQQ --start 2020-01-01 --end 2023-01-01\n\n# FRED macro signals (VIX, GDP, Buffett Indicator, etc.)\npython data/fetch_signals.py\n\n# Convert OptionsDX format\npython data/convert_optionsdx.py data/raw/spx_eod_2020.csv --output data/processed/spx_options.csv\n```\n\nYou can also bring your own CSVs. Required columns:\n- **Stocks**: `date`, `symbol`, `adjClose`\n- **Options**: `quotedate`, `underlying`, `type`, `strike`, `expiration`, `dte`, `bid`, `ask`, `volume`, `openinterest`, `delta`\n\n## Tests\n\n```shell\nmake test            # all tests (1300+)\nmake test-regression # regression snapshots (locked golden values)\nmake test-chaos      # fault injection (corrupted/adversarial data)\nmake muttest         # mutation testing on core modules\nmake lint            # ruff\nmake typecheck       # mypy\nmake rust-test       # Rust unit tests\n```\n\n## Architecture\n\n```\noptions_portfolio_backtester/\n├── core/            # Types: Direction, OptionType, Greeks, Fill, Order\n├── data/            # Schema DSL, CSV providers\n├── strategy/        # Strategy, StrategyLeg, presets\n├── execution/       # CostModel, FillModel, Sizer, SignalSelector\n├── portfolio/       # Portfolio, OptionPosition, RiskManager\n├── engine/          # BacktestEngine, AlgoPipelineBacktester, StrategyTreeEngine\n└── analytics/       # BacktestStats, TradeLog, TearsheetReport, charts\n\nrust/\n├── ob_core/         # Backtest loop, stats, execution models, filter parser\n└── ob_python/       # PyO3 bindings, parallel sweep, Arrow bridge\n```\n\n## Pipeline algos\n\n40+ composable algos for the `AlgoPipelineBacktester`. All follow `__call__(ctx) -> StepDecision`.\n\n**Scheduling**: `RunDaily`, `RunWeekly`, `RunMonthly`, `RunQuarterly`, `RunYearly`, `RunOnce`, `RunOnDate`, `RunAfterDate`, `RunAfterDays`, `RunEveryNPeriods`, `RunIfOutOfBounds`, `Or`, `Not`, `Require`\n\n**Selection**: `SelectAll`, `SelectThese`, `SelectHasData`, `SelectN`, `SelectMomentum`, `SelectWhere`, `SelectRandomly`, `SelectActive`, `SelectRegex`\n\n**Weighting**: `WeighEqually`, `WeighSpecified`, `WeighTarget`, `WeighInvVol`, `WeighMeanVar`, `WeighERC`, `TargetVol`, `WeighRandomly`\n\n**Risk & rebalancing**: `LimitWeights`, `LimitDeltas`, `ScaleWeights`, `HedgeRisks`, `Margin`, `MaxDrawdownGuard`, `Rebalance`, `RebalanceOverTime`, `CapitalFlow`, `CloseDead`, `ClosePositionsAfterDates`, `ReplayTransactions`, `CouponPayingPosition`\n\n## Research\n\nResearch notebooks and analysis: [finance_research](https://github.com/unbalancedparentheses/finance_research).\n"
  },
  {
    "path": "benchmarks/benchmark_large_pipeline.py",
    "content": "\"\"\"Large-scale performance benchmark: Rust vs Python on production data.\n\nRuns the same strategy through Rust full-loop and Python BacktestEngine on\nthe full SPY options dataset (24.7M rows, 4500+ trading days) with frequent\nrebalancing to produce thousands of trades.\n\nUsage:\n    python scripts/benchmark_large_pipeline.py\n    python scripts/benchmark_large_pipeline.py --rebalance-freq 2 --runs 3\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport gc\nimport os\nimport sys\nimport time\nfrom dataclasses import dataclass, field\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\n\nREPO_ROOT = Path(__file__).resolve().parents[1]\n\nfrom options_portfolio_backtester import BacktestEngine as LegacyBacktest\nfrom options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData\nfrom options_portfolio_backtester.core.types import Direction, Stock, OptionType as Type\nfrom options_portfolio_backtester.strategy.strategy import Strategy\nfrom options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\n\nfrom options_portfolio_backtester.engine.engine import BacktestEngine\nfrom options_portfolio_backtester.engine._dispatch import use_rust\nfrom options_portfolio_backtester.engine import _dispatch as _rust_dispatch\nfrom options_portfolio_backtester.execution.cost_model import NoCosts\n\n\nSTOCKS_FILE = os.path.join(REPO_ROOT, \"data\", \"processed\", \"stocks.csv\")\nOPTIONS_FILE = os.path.join(REPO_ROOT, \"data\", \"processed\", \"options.csv\")\n\n\n@dataclass\nclass BenchResult:\n    name: str\n    runtime_s: float\n    final_capital: float\n    total_return_pct: float\n    n_trades: int\n    n_balance_rows: int\n    dispatch_mode: str\n    peak_mem_mb: float = 0.0\n    per_run_times: list[float] = field(default_factory=list)\n\n\ndef parse_args() -> argparse.Namespace:\n    p = argparse.ArgumentParser(description=\"Large-scale Rust vs Python benchmark.\")\n    p.add_argument(\"--runs\", type=int, default=3,\n                   help=\"Number of timing runs (default: 3).\")\n    p.add_argument(\"--rebalance-freq\", type=int, default=1,\n                   help=\"Rebalance frequency in business months (1=monthly).\")\n    p.add_argument(\"--dte-min\", type=int, default=20,\n                   help=\"Min DTE for entry filter.\")\n    p.add_argument(\"--dte-max\", type=int, default=60,\n                   help=\"Max DTE for entry filter.\")\n    p.add_argument(\"--dte-exit\", type=int, default=10,\n                   help=\"DTE threshold for exit.\")\n    p.add_argument(\"--initial-capital\", type=int, default=1_000_000,\n                   help=\"Initial capital.\")\n    p.add_argument(\"--options-pct\", type=float, default=0.10,\n                   help=\"Options allocation pct (0.10 = 10%%).\")\n    return p.parse_args()\n\n\ndef _load_data():\n    print(\"Loading data...\")\n    t0 = time.perf_counter()\n    stocks_data = TiingoData(STOCKS_FILE)\n    options_data = HistoricalOptionsData(OPTIONS_FILE)\n    load_time = time.perf_counter() - t0\n    n_opt = len(options_data._data)\n    n_stk = len(stocks_data._data)\n    n_dates = options_data._data[\"quotedate\"].nunique()\n    print(f\"  Loaded in {load_time:.2f}s\")\n    print(f\"  Options: {n_opt:,} rows, {n_dates:,} trading days\")\n    print(f\"  Stocks:  {n_stk:,} rows\")\n    return stocks_data, options_data\n\n\ndef _strategy(schema, dte_min, dte_max, dte_exit):\n    strat = Strategy(schema)\n    leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\n    leg.entry_filter = (\n        (schema.underlying == \"SPY\")\n        & (schema.dte >= dte_min)\n        & (schema.dte <= dte_max)\n    )\n    leg.exit_filter = schema.dte <= dte_exit\n    strat.add_legs([leg])\n    return strat\n\n\ndef _copy_data(stocks_data, options_data):\n    \"\"\"Deep-copy data handlers to avoid cross-run contamination.\"\"\"\n    sd = TiingoData.__new__(TiingoData)\n    sd.__dict__.update(stocks_data.__dict__)\n    sd._data = stocks_data._data.copy()\n\n    od = HistoricalOptionsData.__new__(HistoricalOptionsData)\n    od.__dict__.update(options_data.__dict__)\n    od._data = options_data._data.copy()\n    return sd, od\n\n\ndef run_engine(\n    stocks_data, options_data, args, runs, force_python=False,\n) -> BenchResult:\n    \"\"\"Run BacktestEngine. If force_python, temporarily disable Rust dispatch.\"\"\"\n    label = \"Python BacktestEngine\" if force_python else \"Rust BacktestEngine\"\n    times = []\n    engine = None\n\n    for i in range(runs):\n        sd, od = _copy_data(stocks_data, options_data)\n        engine = BacktestEngine(\n            {\"stocks\": 1.0 - args.options_pct, \"options\": args.options_pct, \"cash\": 0.0},\n            cost_model=NoCosts(),\n            initial_capital=args.initial_capital,\n        )\n        engine.stocks = [Stock(\"SPY\", 1.0)]\n        engine.stocks_data = sd\n        engine.options_data = od\n        engine.options_strategy = _strategy(od.schema, args.dte_min, args.dte_max, args.dte_exit)\n\n        gc.collect()\n        saved_rust = _rust_dispatch.RUST_AVAILABLE\n        if force_python:\n            _rust_dispatch.RUST_AVAILABLE = False\n        try:\n            t0 = time.perf_counter()\n            engine.run(rebalance_freq=args.rebalance_freq)\n            elapsed = time.perf_counter() - t0\n        finally:\n            _rust_dispatch.RUST_AVAILABLE = saved_rust\n        times.append(elapsed)\n        print(f\"  {label} run {i+1}/{runs}: {elapsed:.3f}s\")\n\n    assert engine is not None\n    mode = engine.run_metadata.get(\"dispatch_mode\", \"unknown\")\n    final = float(engine.balance[\"total capital\"].iloc[-1])\n    n_trades = len(engine.trade_log) if not engine.trade_log.empty else 0\n    total_ret = (final / args.initial_capital - 1.0) * 100.0\n    return BenchResult(\n        name=label,\n        runtime_s=float(np.mean(times)),\n        final_capital=final,\n        total_return_pct=total_ret,\n        n_trades=n_trades,\n        n_balance_rows=len(engine.balance),\n        dispatch_mode=mode,\n        per_run_times=times,\n    )\n\n\ndef run_legacy(stocks_data, options_data, args, runs) -> BenchResult:\n    \"\"\"Run legacy Backtest class.\"\"\"\n    times = []\n    bt = None\n\n    for i in range(runs):\n        sd, od = _copy_data(stocks_data, options_data)\n        bt = LegacyBacktest(\n            {\"stocks\": 1.0 - args.options_pct, \"options\": args.options_pct, \"cash\": 0.0},\n            initial_capital=args.initial_capital,\n        )\n        bt.stocks = [Stock(\"SPY\", 1.0)]\n        bt.stocks_data = sd\n        bt.options_data = od\n        bt.options_strategy = _strategy(od.schema, args.dte_min, args.dte_max, args.dte_exit)\n\n        gc.collect()\n        t0 = time.perf_counter()\n        bt.run(rebalance_freq=args.rebalance_freq)\n        elapsed = time.perf_counter() - t0\n        times.append(elapsed)\n        print(f\"  Legacy Python run {i+1}/{runs}: {elapsed:.3f}s\")\n\n    assert bt is not None\n    final = float(bt.balance[\"total capital\"].iloc[-1])\n    n_trades = len(bt.trade_log) if not bt.trade_log.empty else 0\n    total_ret = (final / bt.initial_capital - 1.0) * 100.0\n    return BenchResult(\n        name=\"Legacy Python Backtest\",\n        runtime_s=float(np.mean(times)),\n        final_capital=final,\n        total_return_pct=total_ret,\n        n_trades=n_trades,\n        n_balance_rows=len(bt.balance),\n        dispatch_mode=\"python-legacy\",\n        per_run_times=times,\n    )\n\n\ndef print_result(r: BenchResult, indent: str = \"  \") -> None:\n    print(f\"{indent}{r.name}\")\n    print(f\"{indent}  dispatch:       {r.dispatch_mode}\")\n    print(f\"{indent}  avg runtime:    {r.runtime_s:.3f}s\")\n    print(f\"{indent}  per-run times:  [{', '.join(f'{t:.3f}s' for t in r.per_run_times)}]\")\n    print(f\"{indent}  final capital:  ${r.final_capital:,.2f}\")\n    print(f\"{indent}  total return:   {r.total_return_pct:.4f}%\")\n    print(f\"{indent}  trades:         {r.n_trades:,}\")\n    print(f\"{indent}  balance rows:   {r.n_balance_rows:,}\")\n\n\ndef print_comparison(a: BenchResult, b: BenchResult) -> None:\n    if a.runtime_s > 0:\n        speedup = b.runtime_s / a.runtime_s\n    else:\n        speedup = float(\"nan\")\n    cap_delta = abs(a.final_capital - b.final_capital)\n    ret_delta = a.total_return_pct - b.total_return_pct\n    cap_pct = (cap_delta / max(a.final_capital, 1)) * 100\n    print(f\"  {a.name} vs {b.name}:\")\n    print(f\"    speedup:          {speedup:.2f}x ({a.name} is {'faster' if speedup > 1 else 'slower'})\")\n    print(f\"    capital delta:    ${cap_delta:,.2f} ({cap_pct:.4f}%)\")\n    print(f\"    return delta:     {ret_delta:+.4f} pct-pts\")\n    if a.n_trades > 0 and b.n_trades > 0:\n        print(f\"    trade count:      {a.n_trades:,} vs {b.n_trades:,} ({'match' if a.n_trades == b.n_trades else 'MISMATCH'})\")\n\n\ndef main() -> None:\n    args = parse_args()\n\n    for f in (STOCKS_FILE, OPTIONS_FILE):\n        if not Path(f).exists():\n            print(f\"ERROR: Missing data file: {f}\")\n            print(\"Run this benchmark from the repo root with production data in data/processed/\")\n            sys.exit(1)\n\n    print(f\"\\n{'='*65}\")\n    print(\"Large-Scale Performance Benchmark: Rust vs Python\")\n    print(f\"{'='*65}\")\n    print(f\"  Rust available:    {use_rust()}\")\n    print(f\"  runs per backend:  {args.runs}\")\n    print(f\"  rebalance freq:    {args.rebalance_freq} BMS\")\n    print(f\"  strategy:          BUY PUT, DTE {args.dte_min}-{args.dte_max}, exit DTE <= {args.dte_exit}\")\n    print(f\"  allocation:        {(1-args.options_pct)*100:.0f}% stocks / {args.options_pct*100:.0f}% options\")\n    print(f\"  initial capital:   ${args.initial_capital:,}\")\n    print()\n\n    stocks_data, options_data = _load_data()\n    print()\n\n    # -- Run all backends --\n    results = []\n\n    if use_rust():\n        print(\"Running Rust BacktestEngine...\")\n        rust_result = run_engine(stocks_data, options_data, args, args.runs, force_python=False)\n        results.append(rust_result)\n        print()\n\n    print(\"Running Python BacktestEngine...\")\n    python_result = run_engine(stocks_data, options_data, args, args.runs, force_python=True)\n    results.append(python_result)\n    print()\n\n    print(\"Running Legacy Python Backtest...\")\n    legacy_result = run_legacy(stocks_data, options_data, args, args.runs)\n    results.append(legacy_result)\n    print()\n\n    # -- Report --\n    print(f\"{'='*65}\")\n    print(\"Results\")\n    print(f\"{'='*65}\")\n    for r in results:\n        print_result(r)\n        print()\n\n    print(f\"{'='*65}\")\n    print(\"Comparisons\")\n    print(f\"{'='*65}\")\n    if use_rust():\n        print_comparison(rust_result, python_result)\n        print()\n        print_comparison(rust_result, legacy_result)\n        print()\n    print_comparison(python_result, legacy_result)\n    print()\n\n    # -- Summary table --\n    print(f\"{'='*65}\")\n    print(\"Summary Table\")\n    print(f\"{'='*65}\")\n    rows = []\n    for r in results:\n        rows.append({\n            \"Backend\": r.name,\n            \"Dispatch\": r.dispatch_mode,\n            \"Avg Time (s)\": f\"{r.runtime_s:.3f}\",\n            \"Trades\": f\"{r.n_trades:,}\",\n            \"Final Capital\": f\"${r.final_capital:,.0f}\",\n            \"Return %\": f\"{r.total_return_pct:.2f}\",\n        })\n    df = pd.DataFrame(rows)\n    print(df.to_string(index=False))\n\n    if use_rust() and rust_result.runtime_s > 0:\n        print(f\"\\n  Rust speedup over Python Engine: {python_result.runtime_s / rust_result.runtime_s:.2f}x\")\n        print(f\"  Rust speedup over Legacy:        {legacy_result.runtime_s / rust_result.runtime_s:.2f}x\")\n\n    print(\"\\nDone.\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "benchmarks/benchmark_matrix.py",
    "content": "\"\"\"Standardized benchmark matrix for options_portfolio_backtester vs bt.\n\nRuns multiple scenarios over date ranges/rebalance frequencies and writes\na CSV scorecard with runtime and parity metrics.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport sys\nimport time\nfrom dataclasses import dataclass\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\n\nREPO_ROOT = Path(__file__).resolve().parents[1]\n\nfrom options_portfolio_backtester import BacktestEngine as Backtest\nfrom options_portfolio_backtester.data.providers import TiingoData\nfrom options_portfolio_backtester.core.types import Stock\n\n\n@dataclass(frozen=True)\nclass Scenario:\n    label: str\n    start: pd.Timestamp\n    end: pd.Timestamp\n    rebalance_months: int\n    initial_capital: float\n\n\ndef parse_args() -> argparse.Namespace:\n    p = argparse.ArgumentParser(description=\"Benchmark matrix vs bt.\")\n    p.add_argument(\"--stocks-file\", default=\"data/processed/stocks.csv\")\n    p.add_argument(\"--symbols\", default=\"SPY\")\n    p.add_argument(\"--weights\", default=None)\n    p.add_argument(\"--date-ranges\", default=\"2008-01-01:2025-12-12,2016-01-01:2025-12-12\")\n    p.add_argument(\"--rebalance-months\", default=\"1,3\")\n    p.add_argument(\"--initial-capitals\", default=\"1000000\")\n    p.add_argument(\"--runs\", type=int, default=3)\n    p.add_argument(\"--output\", default=\"data/processed/benchmark_matrix.csv\")\n    return p.parse_args()\n\n\ndef parse_csv_list(s: str, cast):\n    return [cast(x.strip()) for x in s.split(\",\") if x.strip()]\n\n\ndef normalize_weights(symbols: list[str], raw_weights: str | None) -> list[float]:\n    if raw_weights is None:\n        return [1.0 / len(symbols)] * len(symbols)\n    vals = [float(x) for x in raw_weights.split(\",\")]\n    if len(vals) != len(symbols):\n        raise ValueError(\"--weights length must match --symbols length\")\n    total = float(sum(vals))\n    if total <= 0:\n        raise ValueError(\"--weights must sum to > 0\")\n    return [v / total for v in vals]\n\n\ndef compute_metrics(total_capital: pd.Series) -> tuple[float, float, float, float, float]:\n    total_capital = total_capital.dropna()\n    if total_capital.empty:\n        return 0.0, 0.0, 0.0, 0.0, 0.0\n    rets = total_capital.pct_change().dropna()\n    total_return = total_capital.iloc[-1] / total_capital.iloc[0] - 1.0\n    n_years = len(total_capital) / 252.0\n    cagr = (total_capital.iloc[-1] / total_capital.iloc[0]) ** (1.0 / n_years) - 1.0 if n_years > 0 else 0.0\n    peak = total_capital.cummax()\n    dd = total_capital / peak - 1.0\n    max_dd = float(dd.min()) if not dd.empty else 0.0\n    vol = float(rets.std(ddof=1) * np.sqrt(252)) if len(rets) > 1 else 0.0\n    sharpe = float((rets.mean() / rets.std(ddof=1)) * np.sqrt(252)) if len(rets) > 1 and rets.std(ddof=1) > 0 else 0.0\n    return total_return, cagr, max_dd, vol, sharpe\n\n\ndef slice_stocks_data(stocks_file: str, start: pd.Timestamp, end: pd.Timestamp) -> TiingoData:\n    d = TiingoData(stocks_file)\n    m = (d._data[\"date\"] >= start) & (d._data[\"date\"] <= end)\n    d._data = d._data.loc[m].copy()\n    d.start_date = d._data[\"date\"].min()\n    d.end_date = d._data[\"date\"].max()\n    return d\n\n\ndef run_options_portfolio_backtester(\n    stocks_file: str,\n    symbols: list[str],\n    weights: list[float],\n    scenario: Scenario,\n    runs: int,\n) -> tuple[dict[str, float], pd.Series]:\n    stocks = [Stock(sym, w) for sym, w in zip(symbols, weights)]\n    runtimes = []\n    last_eq = pd.Series(dtype=float)\n    for _ in range(runs):\n        stocks_data = slice_stocks_data(stocks_file, scenario.start, scenario.end)\n        bt = Backtest({\"stocks\": 1.0, \"options\": 0.0, \"cash\": 0.0}, initial_capital=int(scenario.initial_capital))\n        bt.stocks = stocks\n        bt.stocks_data = stocks_data\n        t0 = time.perf_counter()\n        bt.run(rebalance_freq=scenario.rebalance_months, rebalance_unit=\"BMS\")\n        runtimes.append(time.perf_counter() - t0)\n        last_eq = bt.balance[\"total capital\"].dropna()\n    tr, cagr, mdd, vol, sharpe = compute_metrics(last_eq)\n    return ({\n        \"ob_runtime_s\": float(np.mean(runtimes)),\n        \"ob_total_return_pct\": tr * 100.0,\n        \"ob_cagr_pct\": cagr * 100.0,\n        \"ob_max_drawdown_pct\": mdd * 100.0,\n        \"ob_vol_annual_pct\": vol * 100.0,\n        \"ob_sharpe\": sharpe,\n        \"ob_rows\": float(len(last_eq)),\n    }, last_eq)\n\n\ndef run_bt(\n    stocks_file: str,\n    symbols: list[str],\n    weights: list[float],\n    scenario: Scenario,\n    runs: int,\n) -> tuple[dict[str, float], pd.Series | None]:\n    try:\n        import bt  # type: ignore\n    except Exception:\n        return ({\"bt_available\": 0.0}, None)\n\n    prices = pd.read_csv(stocks_file, parse_dates=[\"date\"])\n    m = (prices[\"date\"] >= scenario.start) & (prices[\"date\"] <= scenario.end) & (prices[\"symbol\"].isin(symbols))\n    prices = prices.loc[m].copy()\n    px = prices.pivot(index=\"date\", columns=\"symbol\", values=\"adjClose\").sort_index().dropna()\n    px = px[symbols]\n\n    runtimes = []\n    last_eq = None\n    for _ in range(runs):\n        algos = [\n            bt.algos.RunMonthly(),\n            bt.algos.SelectThese(symbols),\n            bt.algos.WeighSpecified(**{s: w for s, w in zip(symbols, weights)}),\n            bt.algos.Rebalance(),\n        ]\n        test = bt.Backtest(bt.Strategy(\"bench_matrix\", algos), px, initial_capital=scenario.initial_capital)\n        t0 = time.perf_counter()\n        res = bt.run(test)\n        runtimes.append(time.perf_counter() - t0)\n        last_eq = res.prices.iloc[:, 0]\n\n    assert last_eq is not None\n    tr, cagr, mdd, vol, sharpe = compute_metrics(last_eq)\n    return ({\n        \"bt_available\": 1.0,\n        \"bt_runtime_s\": float(np.mean(runtimes)),\n        \"bt_total_return_pct\": tr * 100.0,\n        \"bt_cagr_pct\": cagr * 100.0,\n        \"bt_max_drawdown_pct\": mdd * 100.0,\n        \"bt_vol_annual_pct\": vol * 100.0,\n        \"bt_sharpe\": sharpe,\n        \"bt_rows\": float(len(last_eq)),\n    }, last_eq)\n\n\ndef overlap_parity(ob_eq: pd.Series, bt_eq: pd.Series | None) -> dict[str, float]:\n    if bt_eq is None:\n        return {\"overlap_rows\": 0.0, \"overlap_end_delta\": np.nan, \"overlap_max_abs_delta\": np.nan}\n    common = ob_eq.index.intersection(bt_eq.index)\n    if len(common) == 0:\n        return {\"overlap_rows\": 0.0, \"overlap_end_delta\": np.nan, \"overlap_max_abs_delta\": np.nan}\n    ob_n = ob_eq.loc[common] / ob_eq.loc[common].iloc[0]\n    bt_n = bt_eq.loc[common] / bt_eq.loc[common].iloc[0]\n    d = ob_n - bt_n\n    return {\n        \"overlap_rows\": float(len(common)),\n        \"overlap_end_delta\": float(d.iloc[-1]),\n        \"overlap_max_abs_delta\": float(d.abs().max()),\n    }\n\n\ndef build_scenarios(args: argparse.Namespace) -> list[Scenario]:\n    date_ranges = []\n    for chunk in args.date_ranges.split(\",\"):\n        chunk = chunk.strip()\n        if not chunk:\n            continue\n        s, e = chunk.split(\":\")\n        date_ranges.append((pd.Timestamp(s), pd.Timestamp(e)))\n    rebal = parse_csv_list(args.rebalance_months, int)\n    capitals = parse_csv_list(args.initial_capitals, float)\n\n    scenarios = []\n    idx = 1\n    for s, e in date_ranges:\n        for r in rebal:\n            for c in capitals:\n                scenarios.append(Scenario(\n                    label=f\"S{idx}\",\n                    start=s,\n                    end=e,\n                    rebalance_months=r,\n                    initial_capital=c,\n                ))\n                idx += 1\n    return scenarios\n\n\ndef main() -> None:\n    args = parse_args()\n    symbols = [s.strip().upper() for s in args.symbols.split(\",\") if s.strip()]\n    if not symbols:\n        raise ValueError(\"No symbols provided\")\n    weights = normalize_weights(symbols, args.weights)\n    scenarios = build_scenarios(args)\n\n    rows = []\n    for sc in scenarios:\n        ob_stats, ob_eq = run_options_portfolio_backtester(\n            stocks_file=args.stocks_file,\n            symbols=symbols,\n            weights=weights,\n            scenario=sc,\n            runs=args.runs,\n        )\n        bt_stats, bt_eq = run_bt(\n            stocks_file=args.stocks_file,\n            symbols=symbols,\n            weights=weights,\n            scenario=sc,\n            runs=args.runs,\n        )\n        parity = overlap_parity(ob_eq, bt_eq)\n        row = {\n            \"scenario\": sc.label,\n            \"start\": sc.start.date().isoformat(),\n            \"end\": sc.end.date().isoformat(),\n            \"rebalance_months\": sc.rebalance_months,\n            \"initial_capital\": sc.initial_capital,\n            \"symbols\": \",\".join(symbols),\n            \"weights\": \",\".join(f\"{w:.6f}\" for w in weights),\n            **ob_stats,\n            **bt_stats,\n            **parity,\n        }\n        if bt_stats.get(\"bt_available\", 0.0) == 1.0:\n            row[\"speed_ratio_bt_over_ob\"] = row[\"bt_runtime_s\"] / row[\"ob_runtime_s\"] if row[\"ob_runtime_s\"] > 0 else np.nan\n            row[\"return_delta_pct_pts\"] = row[\"ob_total_return_pct\"] - row[\"bt_total_return_pct\"]\n            row[\"maxdd_delta_pct_pts\"] = row[\"ob_max_drawdown_pct\"] - row[\"bt_max_drawdown_pct\"]\n        else:\n            row[\"speed_ratio_bt_over_ob\"] = np.nan\n            row[\"return_delta_pct_pts\"] = np.nan\n            row[\"maxdd_delta_pct_pts\"] = np.nan\n        rows.append(row)\n\n    out = pd.DataFrame(rows).sort_values([\"start\", \"rebalance_months\", \"initial_capital\"])\n    out_path = Path(args.output)\n    out_path.parent.mkdir(parents=True, exist_ok=True)\n    out.to_csv(out_path, index=False)\n\n    print(\"\\n=== Benchmark Matrix Summary ===\")\n    print(f\"scenarios: {len(out)}\")\n    print(f\"output: {out_path}\")\n    cols = [\n        \"scenario\", \"start\", \"end\", \"rebalance_months\",\n        \"ob_runtime_s\", \"bt_runtime_s\", \"speed_ratio_bt_over_ob\",\n        \"return_delta_pct_pts\", \"maxdd_delta_pct_pts\", \"overlap_max_abs_delta\",\n    ]\n    print(out[cols].to_string(index=False))\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "benchmarks/benchmark_rust_vs_python.py",
    "content": "\"\"\"Benchmark: Rust full-loop vs Python BacktestEngine vs legacy Backtest vs bt.\n\nRuns options backtest (with options data) through Rust and Python paths, plus\na stock-only comparison against bt if installed.\n\nUsage:\n    python scripts/benchmark_rust_vs_python.py\n    python scripts/benchmark_rust_vs_python.py --runs 5 --stock-only\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport os\nimport sys\nimport time\nfrom dataclasses import dataclass\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\n\nREPO_ROOT = Path(__file__).resolve().parents[1]\n\nfrom options_portfolio_backtester import BacktestEngine as LegacyBacktest\nfrom options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData\nfrom options_portfolio_backtester.core.types import Direction, Stock, OptionType as Type\nfrom options_portfolio_backtester.strategy.strategy import Strategy\nfrom options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\n\nfrom options_portfolio_backtester.engine.engine import BacktestEngine\nfrom options_portfolio_backtester.engine._dispatch import use_rust\nfrom options_portfolio_backtester.engine import _dispatch as _rust_dispatch\nfrom options_portfolio_backtester.execution.cost_model import NoCosts\nfrom options_portfolio_backtester.execution.fill_model import MarketAtBidAsk\nfrom options_portfolio_backtester.execution.signal_selector import FirstMatch\n\nTEST_DIR = os.path.join(REPO_ROOT, \"backtester\", \"test\")\nSTOCKS_FILE = os.path.join(TEST_DIR, \"test_data\", \"ivy_5assets_data.csv\")\nOPTIONS_FILE = os.path.join(TEST_DIR, \"test_data\", \"options_data.csv\")\nPROD_STOCKS_FILE = os.path.join(REPO_ROOT, \"data\", \"processed\", \"stocks.csv\")\nPROD_OPTIONS_FILE = os.path.join(REPO_ROOT, \"data\", \"processed\", \"options.csv\")\n\n\n@dataclass\nclass BenchResult:\n    name: str\n    runtime_s: float\n    final_capital: float\n    total_return_pct: float\n    n_trades: int\n    dispatch_mode: str\n\n\ndef parse_args() -> argparse.Namespace:\n    p = argparse.ArgumentParser(description=\"Benchmark Rust vs Python backtest paths.\")\n    p.add_argument(\"--runs\", type=int, default=3, help=\"Timing averaging repeats.\")\n    p.add_argument(\"--stock-only\", action=\"store_true\", help=\"Also run stock-only comparison vs bt.\")\n    p.add_argument(\"--use-prod-data\", action=\"store_true\", help=\"Use production data files if available.\")\n    p.add_argument(\"--rebalance-freq\", type=int, default=1, help=\"Rebalance frequency.\")\n    return p.parse_args()\n\n\ndef _stocks(use_prod: bool = False):\n    if use_prod:\n        return [Stock(\"SPY\", 1.0)]\n    return [Stock(\"VTI\", 0.2), Stock(\"VEU\", 0.2), Stock(\"BND\", 0.2),\n            Stock(\"VNQ\", 0.2), Stock(\"DBC\", 0.2)]\n\n\ndef _load_data(use_prod: bool):\n    if use_prod and Path(PROD_STOCKS_FILE).exists() and Path(PROD_OPTIONS_FILE).exists():\n        stocks_file, options_file = PROD_STOCKS_FILE, PROD_OPTIONS_FILE\n    else:\n        stocks_file, options_file = STOCKS_FILE, OPTIONS_FILE\n\n    stocks_data = TiingoData(stocks_file)\n    options_data = HistoricalOptionsData(options_file)\n\n    if stocks_file == STOCKS_FILE:\n        stocks_data._data[\"adjClose\"] = 10\n        options_data._data.at[2, \"ask\"] = 1\n        options_data._data.at[2, \"bid\"] = 0.5\n        options_data._data.at[51, \"ask\"] = 1.5\n        options_data._data.at[50, \"bid\"] = 0.5\n        options_data._data.at[130, \"bid\"] = 0.5\n        options_data._data.at[131, \"bid\"] = 1.5\n        options_data._data.at[206, \"bid\"] = 0.5\n        options_data._data.at[207, \"bid\"] = 1.5\n\n    return stocks_data, options_data, stocks_file\n\n\ndef _buy_strategy(schema):\n    strat = Strategy(schema)\n    leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\n    leg.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n    leg.exit_filter = schema.dte <= 30\n    strat.add_legs([leg])\n    return strat\n\n\n# ---------------------------------------------------------------------------\n# Runners\n# ---------------------------------------------------------------------------\n\ndef run_engine_python(stocks_data, options_data, stocks, rebalance_freq, runs) -> BenchResult:\n    \"\"\"Force Python path by temporarily disabling Rust dispatch.\"\"\"\n    times = []\n    engine = None\n    for _ in range(runs):\n        sd = TiingoData.__new__(TiingoData)\n        sd.__dict__.update(stocks_data.__dict__)\n        sd._data = stocks_data._data.copy()\n\n        od = HistoricalOptionsData.__new__(HistoricalOptionsData)\n        od.__dict__.update(options_data.__dict__)\n        od._data = options_data._data.copy()\n\n        engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n        )\n        engine.stocks = stocks\n        engine.stocks_data = sd\n        engine.options_data = od\n        engine.options_strategy = _buy_strategy(od.schema)\n\n        saved_rust = _rust_dispatch.RUST_AVAILABLE\n        _rust_dispatch.RUST_AVAILABLE = False\n        try:\n            t0 = time.perf_counter()\n            engine.run(rebalance_freq=rebalance_freq)\n            times.append(time.perf_counter() - t0)\n        finally:\n            _rust_dispatch.RUST_AVAILABLE = saved_rust\n\n    assert engine is not None\n    final = float(engine.balance[\"total capital\"].iloc[-1])\n    n_trades = len(engine.trade_log) if not engine.trade_log.empty else 0\n    total_ret = (final / engine.initial_capital - 1) * 100\n    return BenchResult(\n        name=\"Python BacktestEngine\",\n        runtime_s=float(np.mean(times)),\n        final_capital=final,\n        total_return_pct=total_ret,\n        n_trades=n_trades,\n        dispatch_mode=\"python\",\n    )\n\n\ndef run_engine_rust(stocks_data, options_data, stocks, rebalance_freq, runs) -> BenchResult | None:\n    \"\"\"Let Rust dispatch happen naturally (default path).\"\"\"\n    if not use_rust():\n        return None\n\n    times = []\n    engine = None\n    for _ in range(runs):\n        sd = TiingoData.__new__(TiingoData)\n        sd.__dict__.update(stocks_data.__dict__)\n        sd._data = stocks_data._data.copy()\n\n        od = HistoricalOptionsData.__new__(HistoricalOptionsData)\n        od.__dict__.update(options_data.__dict__)\n        od._data = options_data._data.copy()\n\n        engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n        )\n        engine.stocks = stocks\n        engine.stocks_data = sd\n        engine.options_data = od\n        engine.options_strategy = _buy_strategy(od.schema)\n\n        t0 = time.perf_counter()\n        engine.run(rebalance_freq=rebalance_freq)\n        times.append(time.perf_counter() - t0)\n\n    assert engine is not None\n    mode = engine.run_metadata.get(\"dispatch_mode\", \"unknown\")\n    final = float(engine.balance[\"total capital\"].iloc[-1])\n    n_trades = len(engine.trade_log) if not engine.trade_log.empty else 0\n    total_ret = (final / engine.initial_capital - 1) * 100\n    return BenchResult(\n        name=\"Rust BacktestEngine\",\n        runtime_s=float(np.mean(times)),\n        final_capital=final,\n        total_return_pct=total_ret,\n        n_trades=n_trades,\n        dispatch_mode=mode,\n    )\n\n\ndef run_legacy_python(stocks_data, options_data, stocks, rebalance_freq, runs) -> BenchResult:\n    \"\"\"Legacy Backtest class.\"\"\"\n    times = []\n    bt = None\n    for _ in range(runs):\n        sd = TiingoData.__new__(TiingoData)\n        sd.__dict__.update(stocks_data.__dict__)\n        sd._data = stocks_data._data.copy()\n\n        od = HistoricalOptionsData.__new__(HistoricalOptionsData)\n        od.__dict__.update(options_data.__dict__)\n        od._data = options_data._data.copy()\n\n        bt = LegacyBacktest({\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0})\n        bt.stocks = stocks\n        bt.stocks_data = sd\n        bt.options_data = od\n        bt.options_strategy = _buy_strategy(od.schema)\n\n        t0 = time.perf_counter()\n        bt.run(rebalance_freq=rebalance_freq)\n        times.append(time.perf_counter() - t0)\n\n    assert bt is not None\n    final = float(bt.balance[\"total capital\"].iloc[-1])\n    n_trades = len(bt.trade_log) if not bt.trade_log.empty else 0\n    total_ret = (final / bt.initial_capital - 1) * 100\n    return BenchResult(\n        name=\"Legacy Python Backtest\",\n        runtime_s=float(np.mean(times)),\n        final_capital=final,\n        total_return_pct=total_ret,\n        n_trades=n_trades,\n        dispatch_mode=\"python-legacy\",\n    )\n\n\ndef run_bt_stock_only(stocks_file, symbols, weights, initial_capital, runs) -> BenchResult | None:\n    \"\"\"bt library stock-only benchmark.\"\"\"\n    try:\n        import bt\n    except Exception:\n        return None\n\n    prices = pd.read_csv(stocks_file, parse_dates=[\"date\"])\n    prices = prices[prices[\"symbol\"].isin(symbols)].copy()\n    px = prices.pivot(index=\"date\", columns=\"symbol\", values=\"adjClose\").sort_index().dropna()\n    px = px[symbols]\n\n    times = []\n    last_res = None\n    for _ in range(runs):\n        algos = [\n            bt.algos.RunMonthly(),\n            bt.algos.SelectThese(symbols),\n            bt.algos.WeighSpecified(**dict(zip(symbols, weights))),\n            bt.algos.Rebalance(),\n        ]\n        strat = bt.Strategy(\"bench\", algos)\n        test = bt.Backtest(strat, px, initial_capital=initial_capital)\n        t0 = time.perf_counter()\n        last_res = bt.run(test)\n        times.append(time.perf_counter() - t0)\n\n    assert last_res is not None\n    series = last_res.prices.iloc[:, 0]\n    # bt normalizes NAV to start at initial_capital\n    final = float(series.iloc[-1])\n    start = float(series.iloc[0])\n    total_ret = (final / start - 1) * 100\n    return BenchResult(\n        name=\"bt library\",\n        runtime_s=float(np.mean(times)),\n        final_capital=final,\n        total_return_pct=total_ret,\n        n_trades=0,\n        dispatch_mode=\"bt\",\n    )\n\n\ndef run_ob_stock_only(stocks_file, symbols, weights, initial_capital, runs) -> BenchResult:\n    \"\"\"options_portfolio_backtester stock-only benchmark.\"\"\"\n    stocks = [Stock(sym, w) for sym, w in zip(symbols, weights)]\n    times = []\n    bt_obj = None\n    for _ in range(runs):\n        stocks_data = TiingoData(stocks_file)\n        bt_obj = LegacyBacktest({\"stocks\": 1.0, \"options\": 0.0, \"cash\": 0.0},\n                                initial_capital=int(initial_capital))\n        bt_obj.stocks = stocks\n        bt_obj.stocks_data = stocks_data\n        t0 = time.perf_counter()\n        bt_obj.run(rebalance_freq=1, rebalance_unit=\"BMS\")\n        times.append(time.perf_counter() - t0)\n\n    assert bt_obj is not None\n    bal = bt_obj.balance[\"total capital\"].dropna()\n    final = float(bal.iloc[-1])\n    total_ret = (final / initial_capital - 1) * 100\n    return BenchResult(\n        name=\"options_portfolio_backtester (stock-only)\",\n        runtime_s=float(np.mean(times)),\n        final_capital=final,\n        total_return_pct=total_ret,\n        n_trades=0,\n        dispatch_mode=\"python-legacy-stock-only\",\n    )\n\n\n# ---------------------------------------------------------------------------\n# Display\n# ---------------------------------------------------------------------------\n\ndef print_result(r: BenchResult) -> None:\n    print(f\"  {r.name}\")\n    print(f\"    dispatch:     {r.dispatch_mode}\")\n    print(f\"    runtime:      {r.runtime_s:.4f}s\")\n    print(f\"    final_capital: {r.final_capital:,.2f}\")\n    print(f\"    total_return: {r.total_return_pct:.4f}%\")\n    print(f\"    n_trades:     {r.n_trades}\")\n\n\ndef print_comparison(a: BenchResult, b: BenchResult) -> None:\n    speedup = b.runtime_s / a.runtime_s if a.runtime_s > 0 else float(\"nan\")\n    cap_delta = abs(a.final_capital - b.final_capital)\n    ret_delta = a.total_return_pct - b.total_return_pct\n    print(f\"  {a.name} vs {b.name}:\")\n    print(f\"    speedup:       {speedup:.2f}x ({a.name} is {'faster' if speedup > 1 else 'slower'})\")\n    print(f\"    capital delta: ${cap_delta:,.2f}\")\n    print(f\"    return delta:  {ret_delta:+.4f} pct-pts\")\n    if a.n_trades > 0 and b.n_trades > 0:\n        print(f\"    trades match:  {a.n_trades == b.n_trades} ({a.n_trades} vs {b.n_trades})\")\n\n\ndef main() -> None:\n    args = parse_args()\n    stocks_data, options_data, stocks_file = _load_data(args.use_prod_data)\n    stocks = _stocks(use_prod=args.use_prod_data)\n\n    print(f\"\\n{'='*60}\")\n    print(\"Benchmark: Rust vs Python vs Legacy\")\n    print(f\"{'='*60}\")\n    print(f\"  Rust available: {use_rust()}\")\n    print(f\"  runs per backend: {args.runs}\")\n    print(f\"  rebalance_freq: {args.rebalance_freq}\")\n    print(f\"  data: {'production' if args.use_prod_data else 'test'}\")\n    print()\n\n    # -- Options backtest benchmarks --\n    print(\"--- Options Backtest (with options data) ---\")\n    results = []\n\n    legacy = run_legacy_python(stocks_data, options_data, stocks, args.rebalance_freq, args.runs)\n    results.append(legacy)\n    print_result(legacy)\n\n    python_engine = run_engine_python(stocks_data, options_data, stocks, args.rebalance_freq, args.runs)\n    results.append(python_engine)\n    print_result(python_engine)\n\n    rust_engine = run_engine_rust(stocks_data, options_data, stocks, args.rebalance_freq, args.runs)\n    if rust_engine:\n        results.append(rust_engine)\n        print_result(rust_engine)\n    else:\n        print(\"  Rust BacktestEngine: SKIPPED (Rust not available)\")\n\n    print()\n    print(\"--- Comparisons ---\")\n    if rust_engine:\n        print_comparison(rust_engine, python_engine)\n        print_comparison(rust_engine, legacy)\n    print_comparison(python_engine, legacy)\n\n    # -- Stock-only benchmarks --\n    if args.stock_only and Path(PROD_STOCKS_FILE).exists():\n        print()\n        print(\"--- Stock-Only Monthly Rebalance (vs bt) ---\")\n        symbols = [\"SPY\"]\n        weights = [1.0]\n        capital = 1_000_000.0\n\n        ob_stock = run_ob_stock_only(PROD_STOCKS_FILE, symbols, weights, capital, args.runs)\n        print_result(ob_stock)\n\n        bt_res = run_bt_stock_only(PROD_STOCKS_FILE, symbols, weights, capital, args.runs)\n        if bt_res:\n            print_result(bt_res)\n            print()\n            print_comparison(ob_stock, bt_res)\n        else:\n            print(\"  bt: SKIPPED (not installed)\")\n\n    print()\n    print(\"Done.\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "benchmarks/benchmark_sweep.py",
    "content": "\"\"\"Benchmark: Rust parallel_sweep vs Python sequential grid search.\n\nThis is the PRIMARY benchmark for justifying the Rust backend.\nSingle backtests have Pandas<->Polars conversion overhead, but\nparallel_sweep amortizes that cost over N grid points and runs\nall backtests on Rayon threads (no GIL, no pickle, zero-copy data).\n\nUsage:\n    python scripts/benchmark_sweep.py\n    python scripts/benchmark_sweep.py --grid-sizes 10 50 100 --runs 3\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport gc\nimport math\nimport os\nimport sys\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\nimport polars as pl\n\nREPO_ROOT = Path(__file__).resolve().parents[1]\n\nfrom options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData\nfrom options_portfolio_backtester.core.types import Direction, Stock, OptionType as Type\nfrom options_portfolio_backtester.strategy.strategy import Strategy\nfrom options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\n\nfrom options_portfolio_backtester.engine.engine import BacktestEngine\nfrom options_portfolio_backtester.engine._dispatch import use_rust, rust\nfrom options_portfolio_backtester.engine import _dispatch as _rust_dispatch\nfrom options_portfolio_backtester.execution.cost_model import NoCosts\n\nTEST_DIR = os.path.join(REPO_ROOT, \"backtester\", \"test\")\nSTOCKS_FILE = os.path.join(TEST_DIR, \"test_data\", \"ivy_5assets_data.csv\")\nOPTIONS_FILE = os.path.join(TEST_DIR, \"test_data\", \"options_data.csv\")\nPROD_STOCKS_FILE = os.path.join(REPO_ROOT, \"data\", \"processed\", \"stocks.csv\")\nPROD_OPTIONS_FILE = os.path.join(REPO_ROOT, \"data\", \"processed\", \"options.csv\")\n\n\ndef parse_args():\n    p = argparse.ArgumentParser(description=\"Benchmark Rust parallel_sweep vs Python sequential\")\n    p.add_argument(\"--grid-sizes\", nargs=\"+\", type=int, default=[5, 10, 25, 50],\n                   help=\"Grid sizes to test (number of parameter combos)\")\n    p.add_argument(\"--runs\", type=int, default=2, help=\"Timing runs per grid size\")\n    p.add_argument(\"--use-prod-data\", action=\"store_true\", help=\"Use production data\")\n    return p.parse_args()\n\n\ndef _load_data(use_prod: bool):\n    if use_prod and Path(PROD_STOCKS_FILE).exists() and Path(PROD_OPTIONS_FILE).exists():\n        sf, of = PROD_STOCKS_FILE, PROD_OPTIONS_FILE\n    else:\n        sf, of = STOCKS_FILE, OPTIONS_FILE\n\n    stocks_data = TiingoData(sf)\n    options_data = HistoricalOptionsData(of)\n\n    if sf == STOCKS_FILE:\n        stocks_data._data[\"adjClose\"] = 10\n        options_data._data.at[2, \"ask\"] = 1\n        options_data._data.at[2, \"bid\"] = 0.5\n        options_data._data.at[51, \"ask\"] = 1.5\n        options_data._data.at[50, \"bid\"] = 0.5\n        options_data._data.at[130, \"bid\"] = 0.5\n        options_data._data.at[131, \"bid\"] = 1.5\n        options_data._data.at[206, \"bid\"] = 0.5\n        options_data._data.at[207, \"bid\"] = 1.5\n\n    return stocks_data, options_data, sf\n\n\ndef _build_param_grid(n: int, underlying: str = \"SPX\") -> list[dict]:\n    \"\"\"Generate n parameter override dicts varying DTE thresholds.\n\n    Returns list of dicts with both Rust filter strings AND raw dte values\n    so both the Rust and Python paths can use the same grid.\n    \"\"\"\n    dte_mins = np.linspace(20, 90, max(int(n**0.5), 2)).astype(int)\n    dte_exits = np.linspace(5, 45, max(int(n / len(dte_mins)) + 1, 2)).astype(int)\n    grid = []\n    for dmin in dte_mins:\n        for dex in dte_exits:\n            if len(grid) >= n:\n                break\n            grid.append({\n                \"label\": f\"dte_min={dmin}_exit={dex}\",\n                \"leg_entry_filters\": [\n                    f\"(underlying == '{underlying}') & (dte >= {dmin})\",\n                ],\n                \"leg_exit_filters\": [\n                    f\"dte <= {dex}\",\n                ],\n                # Raw params for Python path\n                \"_dte_min\": int(dmin),\n                \"_dte_exit\": int(dex),\n                \"_underlying\": underlying,\n            })\n        if len(grid) >= n:\n            break\n    return grid[:n]\n\n\ndef _build_rust_config(stocks_data, options_data, stocks, underlying=\"SPX\"):\n    \"\"\"Build the config dict for rust.parallel_sweep.\"\"\"\n    schema = options_data.schema\n    strat = Strategy(schema)\n    leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\n    leg.entry_filter = (schema.underlying == underlying) & (schema.dte >= 60)\n    leg.exit_filter = schema.dte <= 30\n    strat.add_legs([leg])\n\n    date_fmt = \"%Y-%m-%d %H:%M:%S\"\n    dates_df = (\n        pd.DataFrame(options_data._data[[\"quotedate\", \"volume\"]])\n        .drop_duplicates(\"quotedate\")\n        .set_index(\"quotedate\")\n    )\n    rebalancing_days = pd.to_datetime(\n        dates_df.groupby(pd.Grouper(freq=\"1BMS\"))\n        .apply(lambda x: x.index.min())\n        .values\n    )\n    rb_dates = [d.strftime(date_fmt) for d in rebalancing_days]\n\n    config = {\n        \"allocation\": {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0.0},\n        \"initial_capital\": 1_000_000.0,\n        \"shares_per_contract\": 100,\n        \"rebalance_dates\": rb_dates,\n        \"legs\": [{\n            \"name\": leg.name,\n            \"entry_filter\": leg.entry_filter.query,\n            \"exit_filter\": leg.exit_filter.query,\n            \"direction\": leg.direction.value,\n            \"type\": leg.type.value,\n            \"entry_sort_col\": None,\n            \"entry_sort_asc\": True,\n        }],\n        \"profit_pct\": None,\n        \"loss_pct\": None,\n        \"stocks\": [(s.symbol, s.percentage) for s in stocks],\n    }\n\n    stocks_schema = stocks_data.schema\n    opts_schema = options_data.schema\n    schema_mapping = {\n        \"contract\": opts_schema[\"contract\"],\n        \"date\": opts_schema[\"date\"],\n        \"stocks_date\": stocks_schema[\"date\"],\n        \"stocks_symbol\": stocks_schema[\"symbol\"],\n        \"stocks_price\": stocks_schema[\"adjClose\"],\n        \"underlying\": opts_schema[\"underlying\"],\n        \"expiration\": opts_schema[\"expiration\"],\n        \"type\": opts_schema[\"type\"],\n        \"strike\": opts_schema[\"strike\"],\n    }\n\n    # Convert datetime columns\n    opts_copy = options_data._data.copy()\n    for c in [opts_schema[\"date\"], opts_schema[\"expiration\"]]:\n        if c in opts_copy.columns and pd.api.types.is_datetime64_any_dtype(opts_copy[c]):\n            opts_copy[c] = opts_copy[c].dt.strftime(date_fmt)\n\n    stocks_copy = stocks_data._data.copy()\n    sc = stocks_schema[\"date\"]\n    if sc in stocks_copy.columns and pd.api.types.is_datetime64_any_dtype(stocks_copy[sc]):\n        stocks_copy[sc] = stocks_copy[sc].dt.strftime(date_fmt)\n\n    opts_pl = pl.from_pandas(opts_copy)\n    stocks_pl = pl.from_pandas(stocks_copy)\n\n    return config, schema_mapping, opts_pl, stocks_pl, strat\n\n\ndef run_rust_sweep(opts_pl, stocks_pl, config, schema_mapping, param_grid, runs):\n    \"\"\"Run Rust parallel_sweep.\"\"\"\n    times = []\n    last_results = None\n    for _ in range(runs):\n        gc.collect()\n        t0 = time.perf_counter()\n        results = rust.parallel_sweep(\n            opts_pl, stocks_pl, config, schema_mapping, param_grid, None,\n        )\n        elapsed = time.perf_counter() - t0\n        times.append(elapsed)\n        last_results = results\n    return times, last_results\n\n\ndef run_python_sequential(stocks_data, options_data, stocks, param_grid, runs, underlying=\"SPX\"):\n    \"\"\"Run sequential Python backtests for same grid.\"\"\"\n    times = []\n    last_results = None\n    for _ in range(runs):\n        gc.collect()\n        t0 = time.perf_counter()\n        results = []\n        for params in param_grid:\n            sd = TiingoData.__new__(TiingoData)\n            sd.__dict__.update(stocks_data.__dict__)\n            sd._data = stocks_data._data.copy()\n\n            od = HistoricalOptionsData.__new__(HistoricalOptionsData)\n            od.__dict__.update(options_data.__dict__)\n            od._data = options_data._data.copy()\n\n            schema = od.schema\n            strat = Strategy(schema)\n            leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\n\n            # Construct filters from raw DTE params\n            dte_min = params.get(\"_dte_min\", 60)\n            dte_exit = params.get(\"_dte_exit\", 30)\n            und = params.get(\"_underlying\", underlying)\n            leg.entry_filter = (schema.underlying == und) & (schema.dte >= dte_min)\n            leg.exit_filter = schema.dte <= dte_exit\n            strat.add_legs([leg])\n\n            engine = BacktestEngine(\n                {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n                cost_model=NoCosts(),\n            )\n            engine.stocks = stocks\n            engine.stocks_data = sd\n            engine.options_data = od\n            engine.options_strategy = strat\n\n            saved = _rust_dispatch.RUST_AVAILABLE\n            _rust_dispatch.RUST_AVAILABLE = False\n            try:\n                engine.run(rebalance_freq=1)\n            finally:\n                _rust_dispatch.RUST_AVAILABLE = saved\n\n            final = float(engine.balance[\"total capital\"].iloc[-1])\n            n_trades = len(engine.trade_log) if not engine.trade_log.empty else 0\n            results.append({\n                \"label\": params.get(\"label\", \"\"),\n                \"final_capital\": final,\n                \"total_trades\": n_trades,\n            })\n\n        elapsed = time.perf_counter() - t0\n        times.append(elapsed)\n        last_results = results\n    return times, last_results\n\n\ndef run_rust_single(opts_pl, stocks_pl, config, schema_mapping, runs):\n    \"\"\"Run a single Rust backtest (for overhead measurement).\"\"\"\n    times = []\n    for _ in range(runs):\n        gc.collect()\n        t0 = time.perf_counter()\n        rust.run_backtest_py(opts_pl, stocks_pl, config, schema_mapping)\n        elapsed = time.perf_counter() - t0\n        times.append(elapsed)\n    return times\n\n\ndef run_python_single(stocks_data, options_data, stocks, runs, underlying=\"SPX\"):\n    \"\"\"Run a single Python backtest.\"\"\"\n    times = []\n    for _ in range(runs):\n        sd = TiingoData.__new__(TiingoData)\n        sd.__dict__.update(stocks_data.__dict__)\n        sd._data = stocks_data._data.copy()\n\n        od = HistoricalOptionsData.__new__(HistoricalOptionsData)\n        od.__dict__.update(options_data.__dict__)\n        od._data = options_data._data.copy()\n\n        engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n        )\n        engine.stocks = stocks\n        engine.stocks_data = sd\n        engine.options_data = od\n        schema = od.schema\n        strat = Strategy(schema)\n        leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\n        leg.entry_filter = (schema.underlying == underlying) & (schema.dte >= 60)\n        leg.exit_filter = schema.dte <= 30\n        strat.add_legs([leg])\n        engine.options_strategy = strat\n\n        saved = _rust_dispatch.RUST_AVAILABLE\n        _rust_dispatch.RUST_AVAILABLE = False\n        try:\n            gc.collect()\n            t0 = time.perf_counter()\n            engine.run(rebalance_freq=1)\n            elapsed = time.perf_counter() - t0\n        finally:\n            _rust_dispatch.RUST_AVAILABLE = saved\n        times.append(elapsed)\n    return times\n\n\ndef main():\n    args = parse_args()\n\n    if not use_rust():\n        print(\"ERROR: Rust extension not available. Build with: make rust-build\")\n        sys.exit(1)\n\n    stocks_data, options_data, sf = _load_data(args.use_prod_data)\n    if args.use_prod_data and Path(PROD_STOCKS_FILE).exists():\n        stocks = [Stock(\"SPY\", 1.0)]\n    else:\n        stocks = [Stock(\"VTI\", 0.2), Stock(\"VEU\", 0.2), Stock(\"BND\", 0.2),\n                  Stock(\"VNQ\", 0.2), Stock(\"DBC\", 0.2)]\n    underlying = \"SPY\" if args.use_prod_data else \"SPX\"\n    n_rows = len(options_data._data)\n    n_dates = options_data._data[\"quotedate\"].nunique()\n\n    print(f\"\\n{'='*65}\")\n    print(\"Benchmark: Rust parallel_sweep vs Python sequential\")\n    print(f\"{'='*65}\")\n    print(f\"  Data: {'production' if args.use_prod_data else 'test'} ({n_rows:,} options rows, {n_dates} dates)\")\n    print(f\"  Underlying: {underlying}\")\n    print(f\"  Grid sizes: {args.grid_sizes}\")\n    print(f\"  Runs per test: {args.runs}\")\n    print(f\"  CPU cores: {os.cpu_count()}\")\n    print()\n\n    # Build Rust config once (amortized over all grid sizes)\n    config, schema_mapping, opts_pl, stocks_pl, strat = _build_rust_config(\n        stocks_data, options_data, stocks, underlying=underlying\n    )\n\n    # -- Single backtest comparison --\n    print(\"--- Single Backtest (1 run) ---\")\n    rust_single = run_rust_single(opts_pl, stocks_pl, config, schema_mapping, args.runs)\n    python_single = run_python_single(stocks_data, options_data, stocks, args.runs, underlying=underlying)\n    rust_avg = np.mean(rust_single)\n    py_avg = np.mean(python_single)\n    print(f\"  Rust single:    {rust_avg:.4f}s (per-run: [{', '.join(f'{t:.4f}s' for t in rust_single)}])\")\n    print(f\"  Python single:  {py_avg:.4f}s (per-run: [{', '.join(f'{t:.4f}s' for t in python_single)}])\")\n    print(f\"  Speedup:        {py_avg/rust_avg:.2f}x {'(Rust faster)' if rust_avg < py_avg else '(Python faster)'}\")\n    print()\n\n    # -- Grid sweep comparison --\n    print(\"--- Grid Sweep (N parallel Rust vs N sequential Python) ---\")\n    rows = []\n    for grid_size in args.grid_sizes:\n        param_grid = _build_param_grid(grid_size, underlying=underlying)\n\n        print(f\"\\n  Grid size: {grid_size}\")\n\n        # Rust parallel_sweep\n        rust_times, rust_results = run_rust_sweep(\n            opts_pl, stocks_pl, config, schema_mapping, param_grid, args.runs\n        )\n        rust_avg = np.mean(rust_times)\n        print(f\"    Rust parallel:  {rust_avg:.4f}s (per-run: [{', '.join(f'{t:.4f}s' for t in rust_times)}])\")\n\n        # Python sequential\n        python_times, python_results = run_python_sequential(\n            stocks_data, options_data, stocks, param_grid, args.runs, underlying=underlying\n        )\n        py_avg = np.mean(python_times)\n        print(f\"    Python seq:     {py_avg:.4f}s (per-run: [{', '.join(f'{t:.4f}s' for t in python_times)}])\")\n\n        speedup = py_avg / rust_avg if rust_avg > 0 else float(\"nan\")\n        throughput_rust = grid_size / rust_avg if rust_avg > 0 else 0\n        throughput_py = grid_size / py_avg if py_avg > 0 else 0\n        print(f\"    Speedup:        {speedup:.2f}x {'(Rust faster)' if speedup > 1 else '(Python faster)'}\")\n        print(f\"    Throughput:     Rust={throughput_rust:.1f}/s, Python={throughput_py:.1f}/s\")\n\n        rows.append({\n            \"Grid\": grid_size,\n            \"Rust (s)\": f\"{rust_avg:.4f}\",\n            \"Python (s)\": f\"{py_avg:.4f}\",\n            \"Speedup\": f\"{speedup:.2f}x\",\n            \"Rust runs/s\": f\"{throughput_rust:.1f}\",\n            \"Python runs/s\": f\"{throughput_py:.1f}\",\n        })\n\n    # -- Summary Table --\n    print(f\"\\n{'='*65}\")\n    print(\"Summary\")\n    print(f\"{'='*65}\")\n    df = pd.DataFrame(rows)\n    print(df.to_string(index=False))\n\n    print(f\"\\n{'='*65}\")\n    print(\"Conclusion\")\n    print(f\"{'='*65}\")\n    if rows:\n        final_speedup = float(rows[-1][\"Speedup\"].replace(\"x\", \"\"))\n        if final_speedup > 1:\n            print(f\"  Rust parallel_sweep is {final_speedup:.1f}x faster for {args.grid_sizes[-1]} grid points.\")\n            print(f\"  For optimization/grid search, Rust + Rayon provides real value.\")\n        else:\n            print(f\"  Rust is {1/final_speedup:.1f}x slower even for parallel sweep.\")\n            print(f\"  The Pandas<->Polars conversion overhead dominates.\")\n\n    single_speedup = np.mean(python_single) / np.mean(rust_single)\n    if single_speedup < 1:\n        print(f\"  Single backtest: Rust is {1/single_speedup:.1f}x SLOWER (conversion overhead).\")\n    else:\n        print(f\"  Single backtest: Rust is {single_speedup:.1f}x faster.\")\n\n    print(\"\\nDone.\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "benchmarks/compare_with_bt.py",
    "content": "\"\"\"Head-to-head comparison: options_portfolio_backtester stock-only mode vs bt.\n\nThis harness runs the same monthly stock-rebalance policy in both frameworks:\n- options_portfolio_backtester (legacy Backtest with options allocation = 0)\n- bt (if installed)\n\nOutputs a small scorecard with performance and runtime metrics.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport sys\nimport time\nfrom dataclasses import dataclass\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\n\nREPO_ROOT = Path(__file__).resolve().parents[1]\n\nfrom options_portfolio_backtester import BacktestEngine as Backtest\nfrom options_portfolio_backtester.data.providers import TiingoData\nfrom options_portfolio_backtester.core.types import Stock\n\n\n@dataclass\nclass RunResult:\n    name: str\n    total_return_pct: float\n    cagr_pct: float\n    max_drawdown_pct: float\n    vol_annual_pct: float\n    sharpe: float\n    runtime_s: float\n    start_date: str\n    end_date: str\n    n_days: int\n    equity: pd.Series\n\n\ndef parse_args() -> argparse.Namespace:\n    parser = argparse.ArgumentParser(description=\"Compare options_portfolio_backtester vs bt on stock-only allocation.\")\n    parser.add_argument(\"--stocks-file\", default=\"data/processed/stocks.csv\")\n    parser.add_argument(\"--options-file\", default=\"data/processed/options.csv\")\n    parser.add_argument(\"--symbols\", default=\"SPY\", help=\"Comma-separated symbols. Example: SPY or SPY,TLT,GLD\")\n    parser.add_argument(\"--weights\", default=None, help=\"Comma-separated weights matching symbols. Defaults equal.\")\n    parser.add_argument(\"--initial-capital\", type=float, default=1_000_000.0)\n    parser.add_argument(\"--rebalance-months\", type=int, default=1, help=\"Business-month-start rebalance frequency.\")\n    parser.add_argument(\"--runs\", type=int, default=3, help=\"Runtime averaging repeats.\")\n    return parser.parse_args()\n\n\ndef normalize_weights(symbols: list[str], raw_weights: str | None) -> list[float]:\n    if raw_weights is None:\n        return [1.0 / len(symbols)] * len(symbols)\n    vals = [float(x) for x in raw_weights.split(\",\")]\n    if len(vals) != len(symbols):\n        raise ValueError(\"--weights length must match --symbols length\")\n    total = float(sum(vals))\n    if total <= 0:\n        raise ValueError(\"--weights must sum to > 0\")\n    return [v / total for v in vals]\n\n\ndef compute_metrics(total_capital: pd.Series) -> tuple[float, float, float, float, float]:\n    total_capital = total_capital.dropna()\n    if total_capital.empty:\n        return 0.0, 0.0, 0.0, 0.0, 0.0\n\n    rets = total_capital.pct_change().dropna()\n    total_return = total_capital.iloc[-1] / total_capital.iloc[0] - 1.0\n    n_years = len(total_capital) / 252.0\n    cagr = (total_capital.iloc[-1] / total_capital.iloc[0]) ** (1.0 / n_years) - 1.0 if n_years > 0 else 0.0\n\n    peak = total_capital.cummax()\n    dd = total_capital / peak - 1.0\n    max_dd = float(dd.min()) if not dd.empty else 0.0\n\n    vol = float(rets.std(ddof=1) * np.sqrt(252)) if len(rets) > 1 else 0.0\n    sharpe = float((rets.mean() / rets.std(ddof=1)) * np.sqrt(252)) if len(rets) > 1 and rets.std(ddof=1) > 0 else 0.0\n    return total_return, cagr, max_dd, vol, sharpe\n\n\ndef run_options_portfolio_backtester(\n    stocks_file: str,\n    symbols: list[str],\n    weights: list[float],\n    initial_capital: float,\n    rebalance_months: int,\n    runs: int,\n) -> RunResult:\n    stocks_data = TiingoData(stocks_file)\n\n    stocks = [Stock(sym, w) for sym, w in zip(symbols, weights)]\n    times: list[float] = []\n    bt_obj = None\n    for _ in range(runs):\n        bt = Backtest({\"stocks\": 1.0, \"options\": 0.0, \"cash\": 0.0}, initial_capital=int(initial_capital))\n        bt.stocks = stocks\n        bt.stocks_data = stocks_data\n\n        t0 = time.perf_counter()\n        bt.run(rebalance_freq=rebalance_months, rebalance_unit=\"BMS\")\n        times.append(time.perf_counter() - t0)\n        bt_obj = bt\n\n    assert bt_obj is not None\n    bal = bt_obj.balance[\"total capital\"].dropna()\n    tr, cagr, mdd, vol, sharpe = compute_metrics(bal)\n    return RunResult(\n        name=\"options_portfolio_backtester\",\n        total_return_pct=tr * 100.0,\n        cagr_pct=cagr * 100.0,\n        max_drawdown_pct=mdd * 100.0,\n        vol_annual_pct=vol * 100.0,\n        sharpe=sharpe,\n        runtime_s=float(np.mean(times)),\n        start_date=str(bal.index.min().date()),\n        end_date=str(bal.index.max().date()),\n        n_days=int(len(bal)),\n        equity=bal,\n    )\n\n\ndef run_bt(\n    stocks_file: str,\n    symbols: list[str],\n    weights: list[float],\n    initial_capital: float,\n    runs: int,\n) -> RunResult | None:\n    try:\n        import bt  # type: ignore\n    except Exception:\n        return None\n\n    prices = pd.read_csv(stocks_file, parse_dates=[\"date\"])\n    prices = prices[prices[\"symbol\"].isin(symbols)].copy()\n    px = prices.pivot(index=\"date\", columns=\"symbol\", values=\"adjClose\").sort_index().dropna()\n    px = px[symbols]\n\n    times: list[float] = []\n    last_res = None\n    for _ in range(runs):\n        algos = [\n            bt.algos.RunMonthly(),\n            bt.algos.SelectThese(symbols),\n            bt.algos.WeighSpecified(**{s: w for s, w in zip(symbols, weights)}),\n            bt.algos.Rebalance(),\n        ]\n        strat = bt.Strategy(\"bt_monthly_rebal\", algos)\n        test = bt.Backtest(strat, px, initial_capital=initial_capital)\n\n        t0 = time.perf_counter()\n        last_res = bt.run(test)\n        times.append(time.perf_counter() - t0)\n\n    assert last_res is not None\n    series = last_res.prices.iloc[:, 0]\n    tr, cagr, mdd, vol, sharpe = compute_metrics(series)\n    return RunResult(\n        name=\"bt\",\n        total_return_pct=tr * 100.0,\n        cagr_pct=cagr * 100.0,\n        max_drawdown_pct=mdd * 100.0,\n        vol_annual_pct=vol * 100.0,\n        sharpe=sharpe,\n        runtime_s=float(np.mean(times)),\n        start_date=str(series.index.min().date()),\n        end_date=str(series.index.max().date()),\n        n_days=int(len(series)),\n        equity=series,\n    )\n\n\ndef print_result(r: RunResult) -> None:\n    print(f\"{r.name}\")\n    print(f\"  period: {r.start_date} -> {r.end_date} ({r.n_days} rows)\")\n    print(f\"  total_return: {r.total_return_pct:8.2f}%\")\n    print(f\"  cagr:         {r.cagr_pct:8.2f}%\")\n    print(f\"  max_drawdown: {r.max_drawdown_pct:8.2f}%\")\n    print(f\"  vol_annual:   {r.vol_annual_pct:8.2f}%\")\n    print(f\"  sharpe:       {r.sharpe:8.3f}\")\n    print(f\"  runtime:      {r.runtime_s:8.4f}s\")\n\n\ndef print_overlap_parity(a: RunResult, b: RunResult) -> None:\n    common = a.equity.index.intersection(b.equity.index)\n    if len(common) == 0:\n        print(\"  overlap: none\")\n        return\n\n    a_n = a.equity.loc[common] / a.equity.loc[common].iloc[0]\n    b_n = b.equity.loc[common] / b.equity.loc[common].iloc[0]\n    diff = a_n - b_n\n    print(f\"  overlap rows: {len(common)}\")\n    print(f\"  overlap end delta: {float(diff.iloc[-1]):.6e}\")\n    print(f\"  overlap max abs delta: {float(diff.abs().max()):.6e}\")\n\n\ndef main() -> None:\n    args = parse_args()\n    symbols = [s.strip().upper() for s in args.symbols.split(\",\") if s.strip()]\n    if not symbols:\n        raise ValueError(\"No symbols provided\")\n    weights = normalize_weights(symbols, args.weights)\n\n    for file_path in (args.stocks_file,):\n        if not Path(file_path).exists():\n            raise FileNotFoundError(f\"Missing file: {file_path}\")\n\n    ob = run_options_portfolio_backtester(\n        stocks_file=args.stocks_file,\n        symbols=symbols,\n        weights=weights,\n        initial_capital=args.initial_capital,\n        rebalance_months=args.rebalance_months,\n        runs=args.runs,\n    )\n    bt_res = run_bt(\n        stocks_file=args.stocks_file,\n        symbols=symbols,\n        weights=weights,\n        initial_capital=args.initial_capital,\n        runs=args.runs,\n    )\n\n    print(\"\\n=== Comparison Scorecard ===\")\n    print_result(ob)\n    if bt_res is None:\n        print(\"\\nbt\")\n        print(\"  not available (module 'bt' is not installed in this environment).\")\n        print(\"  install in nix shell and rerun:\")\n        print(\"    pip install bt\")\n    else:\n        print()\n        print_result(bt_res)\n        speedup = bt_res.runtime_s / ob.runtime_s if ob.runtime_s > 0 else float(\"nan\")\n        print(\"\\nsummary\")\n        print(f\"  speed ratio (bt / options_portfolio_backtester): {speedup:0.2f}x\")\n        print(\n            f\"  return delta (options_portfolio_backtester - bt): \"\n            f\"{(ob.total_return_pct - bt_res.total_return_pct):0.2f} pct-pts\"\n        )\n        print(\n            f\"  maxDD delta (options_portfolio_backtester - bt): \"\n            f\"{(ob.max_drawdown_pct - bt_res.max_drawdown_pct):0.2f} pct-pts\"\n        )\n        print(\"  overlap parity:\")\n        print_overlap_parity(ob, bt_res)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "data/README.md",
    "content": "# Data Scripts\n\nScripts for fetching and converting market data into the formats expected by the backtester.\n\n## Quick Start\n\nFetch both stock and options data for SPY, aligned by date:\n\n```bash\npython data/fetch_data.py all --symbols SPY --start 2020-01-01 --end 2023-01-01\n```\n\nData is first fetched from the [self-hosted GitHub Release](https://github.com/lambdaclass/options_backtester/releases/tag/data-v1), falling back to [philippdubach/options-data](https://github.com/philippdubach/options-data) CDN and yfinance. Outputs:\n- `data/processed/stocks.csv` — Tiingo-format stock data\n- `data/processed/options.csv` — options data with Greeks\n\n## Subcommands\n\n```bash\n# Stocks only (GitHub Release > options-data > yfinance)\npython data/fetch_data.py stocks --symbols SPY --start 2020-01-01 --end 2023-01-01\n\n# Options only\npython data/fetch_data.py options --symbols SPY --start 2020-01-01 --end 2023-01-01\n\n# Both + date alignment (default)\npython data/fetch_data.py all --symbols SPY --start 2020-01-01 --end 2023-01-01\n\n# Multiple symbols\npython data/fetch_data.py all --symbols SPY IWM QQQ --start 2020-01-01 --end 2023-01-01\n\n# Custom output paths\npython data/fetch_data.py all --symbols SPY --start 2020-01-01 --end 2023-01-01 \\\n    --stocks-output data/processed/spy_stocks.csv \\\n    --options-output data/processed/spy_options.csv\n\n# Force re-download (skip cache)\npython data/fetch_data.py all --symbols SPY --start 2020-01-01 --end 2023-01-01 --force\n```\n\n## OptionsDX Conversion (separate)\n\nFor SPX index options from [optionsdx.com](https://www.optionsdx.com/):\n\n```bash\npython data/convert_optionsdx.py data/raw/spx_eod_2020.csv --output data/processed/spx_options.csv\n```\n\n## Loading Data in the Backtester\n\n```python\nfrom backtester.datahandler import HistoricalOptionsData, TiingoData\n\noptions = HistoricalOptionsData(\"data/processed/options.csv\")\nstocks = TiingoData(\"data/processed/stocks.csv\")\n```\n\nThe `all` subcommand automatically aligns stock and option dates so the backtester's `np.array_equal` assertion passes.\n\n## Directory Structure\n\n- `raw/` — Cached parquet downloads (gitignored)\n- `processed/` — Converted CSV output ready for the backtester (gitignored)\n"
  },
  {
    "path": "data/convert_optionsdx.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Convert OptionsDX wide-format CSV to backtester long-format CSV.\n\nOptionsDX provides one row per strike/date/expiry with both call and put data\nin wide format. The backtester expects one row per contract in long format.\n\nUsage:\n    python data/convert_optionsdx.py data/raw/spx_eod.csv --output data/processed/spx_options.csv\n\"\"\"\n\nimport argparse\nimport sys\n\nimport pandas as pd\n\n\n# OptionsDX columns we need (they have trailing spaces, stripped on read)\nCALL_COLS = {\n    \"C_BID\": \"bid\",\n    \"C_ASK\": \"ask\",\n    \"C_LAST\": \"last\",\n    \"C_VOLUME\": \"volume\",\n    \"C_IV\": \"impliedvol\",\n    \"C_DELTA\": \"delta\",\n    \"C_GAMMA\": \"gamma\",\n    \"C_THETA\": \"theta\",\n    \"C_VEGA\": \"vega\",\n}\n\nPUT_COLS = {\n    \"P_BID\": \"bid\",\n    \"P_ASK\": \"ask\",\n    \"P_LAST\": \"last\",\n    \"P_VOLUME\": \"volume\",\n    \"P_IV\": \"impliedvol\",\n    \"P_DELTA\": \"delta\",\n    \"P_GAMMA\": \"gamma\",\n    \"P_THETA\": \"theta\",\n    \"P_VEGA\": \"vega\",\n}\n\nOUTPUT_COLUMNS = [\n    \"underlying\",\n    \"underlying_last\",\n    \"optionroot\",\n    \"type\",\n    \"expiration\",\n    \"quotedate\",\n    \"strike\",\n    \"last\",\n    \"bid\",\n    \"ask\",\n    \"volume\",\n    \"openinterest\",\n    \"impliedvol\",\n    \"delta\",\n    \"gamma\",\n    \"theta\",\n    \"vega\",\n    \"optionalias\",\n]\n\n\ndef make_optionroot(expire_dates, option_type, strikes):\n    \"\"\"Generate OCC-format option root symbols vectorized.\n\n    Format: SPX{YYMMDD}{C|P}{strike*1000:08d}\n    Example: SPX170317C00300000\n    \"\"\"\n    date_str = expire_dates.dt.strftime(\"%y%m%d\")\n    type_char = \"C\" if option_type == \"call\" else \"P\"\n    strike_str = (strikes * 1000).astype(int).astype(str).str.zfill(8)\n    return \"SPX\" + date_str + type_char + strike_str\n\n\ndef convert(input_path, output_path):\n    df = pd.read_csv(input_path, parse_dates=[\"QUOTE_DATE\", \"EXPIRE_DATE\"])\n    # Strip whitespace from column names (OptionsDX CSVs have trailing spaces)\n    df.columns = df.columns.str.strip()\n\n    shared = {\n        \"underlying\": \"SPX\",\n        \"underlying_last\": df[\"UNDERLYING_LAST\"],\n        \"expiration\": df[\"EXPIRE_DATE\"],\n        \"quotedate\": df[\"QUOTE_DATE\"],\n        \"strike\": df[\"STRIKE\"],\n        \"openinterest\": 0,\n    }\n\n    # Build call rows\n    calls = pd.DataFrame(shared)\n    calls[\"type\"] = \"call\"\n    for src, dst in CALL_COLS.items():\n        calls[dst] = df[src].values\n    calls[\"optionroot\"] = make_optionroot(df[\"EXPIRE_DATE\"], \"call\", df[\"STRIKE\"])\n    calls[\"optionalias\"] = calls[\"optionroot\"]\n\n    # Build put rows\n    puts = pd.DataFrame(shared)\n    puts[\"type\"] = \"put\"\n    for src, dst in PUT_COLS.items():\n        puts[dst] = df[src].values\n    puts[\"optionroot\"] = make_optionroot(df[\"EXPIRE_DATE\"], \"put\", df[\"STRIKE\"])\n    puts[\"optionalias\"] = puts[\"optionroot\"]\n\n    result = pd.concat([calls, puts], ignore_index=True)\n    result = result[OUTPUT_COLUMNS]\n    result = result.sort_values([\"quotedate\", \"expiration\", \"strike\", \"type\"])\n    result.to_csv(output_path, index=False)\n    print(f\"Wrote {len(result)} rows to {output_path}\")\n\n\ndef main():\n    parser = argparse.ArgumentParser(\n        description=\"Convert OptionsDX wide CSV to backtester long CSV\"\n    )\n    parser.add_argument(\"input\", help=\"Path to OptionsDX CSV file\")\n    parser.add_argument(\n        \"--output\",\n        default=\"data/processed/spx_options.csv\",\n        help=\"Output path (default: data/processed/spx_options.csv)\",\n    )\n    args = parser.parse_args()\n    convert(args.input, args.output)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "data/fetch_data.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Unified data fetch script for the options backtester.\n\nDownloads stock and options data, converts to backtester CSV formats,\nand aligns dates between datasets.\n\nDownload priority (for each symbol):\n  1. Self-hosted GitHub Release (lambdaclass/options_backtester data-v1)\n  2. philippdubach/options-data CDN — 104 symbols\n  3. philippdubach/options-dataset-hist — SPY/IWM/QQQ underlying prices\n  4. yfinance (last resort, stocks only)\n\nUsage:\n    python data/fetch_data.py all --symbols SPY --start 2020-01-01 --end 2023-01-01\n    python data/fetch_data.py stocks --symbols SPY --start 2020-01-01 --end 2023-01-01\n    python data/fetch_data.py options --symbols SPY --start 2020-01-01 --end 2023-01-01\n    python data/fetch_data.py all --symbols SPY --start 2020-01-01 --end 2023-01-01 --update\n\"\"\"\n\nimport argparse\nimport shutil\nimport sys\nfrom pathlib import Path\nfrom urllib.request import Request, urlopen\n\nimport pandas as pd\n\nBASE_DIR = Path(__file__).resolve().parent\nRAW_DIR = BASE_DIR / \"raw\"\nPROCESSED_DIR = BASE_DIR / \"processed\"\n\n# Self-hosted data on GitHub Releases (primary source)\nRELEASE_URL = \"https://github.com/lambdaclass/options_backtester/releases/download/data-v1\"\n\n# philippdubach/options-data — 104 symbols, options + underlying (underlying empty for some ETFs)\nOPTIONS_DATA_URL = \"https://static.philippdubach.com/data/options\"\n\n# philippdubach/options-dataset-hist — SPY/IWM/QQQ, proper underlying_prices via GitHub LFS\nHIST_REPO_RAW = \"https://github.com/philippdubach/options-dataset-hist/raw/main/data\"\nHIST_SYMBOLS = {\"SPY\", \"IWM\", \"QQQ\"}\n\n\n# ---------------------------------------------------------------------------\n# Download helpers\n# ---------------------------------------------------------------------------\n\ndef _download(url, dest, force=False):\n    \"\"\"Download url to dest. Returns dest on success, None on failure.\"\"\"\n    dest = Path(dest)\n    dest.parent.mkdir(parents=True, exist_ok=True)\n\n    if dest.exists() and not force:\n        print(f\"  Using cached {dest}\")\n        return dest\n\n    print(f\"  Downloading {url} ...\")\n    try:\n        req = Request(url, headers={\"User-Agent\": \"options-backtester/1.0\"})\n        with urlopen(req) as resp, open(dest, \"wb\") as f:\n            shutil.copyfileobj(resp, f)\n        print(f\"  Saved to {dest}\")\n    except Exception as e:\n        print(f\"  Error: {e}\", file=sys.stderr)\n        if dest.exists():\n            dest.unlink()\n        return None\n    return dest\n\n\ndef download_options_parquet(symbol, force=False):\n    \"\"\"Download options parquet. Priority: GitHub Release > options-data CDN.\"\"\"\n    sym = symbol.upper()\n\n    # 1. Self-hosted GitHub Release\n    dest = RAW_DIR / \"release\" / f\"{sym}_options.parquet\"\n    url = f\"{RELEASE_URL}/{sym}_options.parquet\"\n    result = _download(url, dest, force)\n    if result is not None:\n        return result\n\n    # 2. options-data CDN\n    dest = RAW_DIR / \"options-data\" / sym / \"options.parquet\"\n    url = f\"{OPTIONS_DATA_URL}/{sym.lower()}/options.parquet\"\n    return _download(url, dest, force)\n\n\ndef download_underlying(symbol, force=False):\n    \"\"\"Download underlying prices.\n\n    Priority: GitHub Release > options-dataset-hist > options-data > None (caller falls back to yfinance).\n    \"\"\"\n    sym = symbol.upper()\n\n    # 1. Self-hosted GitHub Release\n    dest = RAW_DIR / \"release\" / f\"{sym}_underlying.parquet\"\n    url = f\"{RELEASE_URL}/{sym}_underlying.parquet\"\n    result = _download(url, dest, force)\n    if result is not None:\n        df = pd.read_parquet(result)\n        if not df.empty:\n            return result\n        print(f\"  Warning: release underlying empty for {sym}\")\n\n    # 2. options-dataset-hist has proper underlying for SPY/IWM/QQQ\n    if sym in HIST_SYMBOLS:\n        dest = RAW_DIR / \"options-dataset-hist\" / sym / \"underlying_prices.parquet\"\n        url = f\"{HIST_REPO_RAW}/parquet_{sym.lower()}/underlying_prices.parquet\"\n        result = _download(url, dest, force)\n        if result is not None:\n            df = pd.read_parquet(result)\n            if not df.empty:\n                return result\n            print(f\"  Warning: options-dataset-hist underlying empty for {sym}\")\n\n    # 3. options-data underlying\n    dest = RAW_DIR / \"options-data\" / sym / \"underlying.parquet\"\n    url = f\"{OPTIONS_DATA_URL}/{sym.lower()}/underlying.parquet\"\n    result = _download(url, dest, force)\n    if result is not None:\n        df = pd.read_parquet(result)\n        if not df.empty:\n            return result\n        print(f\"  Warning: options-data underlying empty for {sym}\")\n\n    return None\n\n\n# ---------------------------------------------------------------------------\n# Underlying price reading\n# ---------------------------------------------------------------------------\n\ndef read_underlying_prices(symbol, und_path, start, end):\n    \"\"\"Read underlying parquet and return (date, close) DataFrame for joining.\"\"\"\n    und = pd.read_parquet(und_path)\n    und[\"date\"] = pd.to_datetime(und[\"date\"])\n    und = und[(und[\"date\"] >= start) & (und[\"date\"] <= end)]\n    if und.empty:\n        return None\n    return und[[\"date\", \"close\"]].rename(columns={\"close\": \"underlying_last\"})\n\n\ndef underlying_to_tiingo(symbol, und_path, start, end):\n    \"\"\"Convert an underlying.parquet to Tiingo-format DataFrame.\"\"\"\n    und = pd.read_parquet(und_path)\n    und[\"date\"] = pd.to_datetime(und[\"date\"])\n    und = und[(und[\"date\"] >= start) & (und[\"date\"] <= end)]\n\n    if und.empty:\n        return pd.DataFrame()\n\n    ratio = und[\"adjusted_close\"] / und[\"close\"]\n\n    return pd.DataFrame({\n        \"symbol\": symbol,\n        \"date\": und[\"date\"].values,\n        \"close\": und[\"close\"].values,\n        \"high\": und[\"high\"].values,\n        \"low\": und[\"low\"].values,\n        \"open\": und[\"open\"].values,\n        \"volume\": und[\"volume\"].values,\n        \"adjClose\": und[\"adjusted_close\"].values,\n        \"adjHigh\": (und[\"high\"] * ratio).values,\n        \"adjLow\": (und[\"low\"] * ratio).values,\n        \"adjOpen\": (und[\"open\"] * ratio).values,\n        \"adjVolume\": und[\"volume\"].values,\n        \"divCash\": und[\"dividend_amount\"].values,\n        \"splitFactor\": und[\"split_coefficient\"].values,\n    })\n\n\ndef fetch_yfinance(symbol, start, end):\n    \"\"\"Fetch one symbol via yfinance (last resort).\"\"\"\n    try:\n        import yfinance as yf\n    except ImportError:\n        print(f\"  yfinance not installed, cannot fetch {symbol}\", file=sys.stderr)\n        return pd.DataFrame()\n\n    print(f\"  Last resort: fetching {symbol} from yfinance...\")\n    ticker = yf.Ticker(symbol)\n    df = ticker.history(start=str(start.date()), end=str(end.date()), auto_adjust=False)\n\n    if df.empty:\n        return pd.DataFrame()\n\n    if df.index.tz is not None:\n        df.index = df.index.tz_localize(None)\n\n    ratio = df[\"Adj Close\"] / df[\"Close\"]\n\n    return pd.DataFrame({\n        \"symbol\": symbol,\n        \"date\": df.index,\n        \"close\": df[\"Close\"].values,\n        \"high\": df[\"High\"].values,\n        \"low\": df[\"Low\"].values,\n        \"open\": df[\"Open\"].values,\n        \"volume\": df[\"Volume\"].values,\n        \"adjClose\": df[\"Adj Close\"].values,\n        \"adjHigh\": (df[\"High\"] * ratio).values,\n        \"adjLow\": (df[\"Low\"] * ratio).values,\n        \"adjOpen\": (df[\"Open\"] * ratio).values,\n        \"adjVolume\": df[\"Volume\"].values,\n        \"divCash\": 0.0,\n        \"splitFactor\": 1.0,\n    })\n\n\n# ---------------------------------------------------------------------------\n# Options\n# ---------------------------------------------------------------------------\n\ndef fetch_options(symbols, start, end, output, force=False):\n    \"\"\"Download options parquets and convert to backtester CSV format.\"\"\"\n    frames = []\n\n    for symbol in symbols:\n        sym = symbol.upper()\n        print(f\"Fetching options for {sym}...\")\n\n        opt_path = download_options_parquet(sym, force)\n        if opt_path is None:\n            print(f\"  Skipping {sym} options (download failed)\", file=sys.stderr)\n            continue\n\n        opts = pd.read_parquet(opt_path)\n        opts[\"date\"] = pd.to_datetime(opts[\"date\"])\n        opts = opts[(opts[\"date\"] >= start) & (opts[\"date\"] <= end)]\n\n        if opts.empty:\n            print(f\"  No options data for {sym} in [{start}, {end}]\")\n            continue\n\n        # Get underlying close prices for underlying_last\n        und_path = download_underlying(sym, force)\n        und_prices = None\n        if und_path is not None:\n            und_prices = read_underlying_prices(sym, und_path, start, end)\n\n        if und_prices is None:\n            yf_df = fetch_yfinance(sym, start, end)\n            if not yf_df.empty:\n                und_prices = pd.DataFrame({\n                    \"date\": pd.to_datetime(yf_df[\"date\"]),\n                    \"underlying_last\": yf_df[\"close\"].values,\n                })\n\n        if und_prices is not None:\n            opts = opts.merge(und_prices, on=\"date\", how=\"left\")\n        else:\n            opts[\"underlying_last\"] = float(\"nan\")\n\n        # Last price: use column if present, else mid\n        if \"last\" in opts.columns:\n            opts[\"_last\"] = opts[\"last\"].fillna((opts[\"bid\"] + opts[\"ask\"]) / 2)\n        else:\n            opts[\"_last\"] = (opts[\"bid\"] + opts[\"ask\"]) / 2\n\n        out = pd.DataFrame({\n            \"underlying\": sym,\n            \"underlying_last\": opts[\"underlying_last\"].values,\n            \"optionroot\": opts[\"contract_id\"].values,\n            \"type\": opts[\"type\"].values,\n            \"expiration\": pd.to_datetime(opts[\"expiration\"]).values,\n            \"quotedate\": opts[\"date\"].values,\n            \"strike\": opts[\"strike\"].values,\n            \"last\": opts[\"_last\"].values,\n            \"bid\": opts[\"bid\"].values,\n            \"ask\": opts[\"ask\"].values,\n            \"volume\": opts[\"volume\"].values,\n            \"openinterest\": opts[\"open_interest\"].values,\n            \"impliedvol\": opts[\"implied_volatility\"].values,\n            \"delta\": opts[\"delta\"].values,\n            \"gamma\": opts[\"gamma\"].values,\n            \"theta\": opts[\"theta\"].values,\n            \"vega\": opts[\"vega\"].values,\n            \"optionalias\": opts[\"contract_id\"].values,\n        })\n        frames.append(out)\n        print(f\"  {len(out)} option rows for {sym}\")\n\n    if not frames:\n        print(\"No options data fetched.\", file=sys.stderr)\n        return None\n\n    result = pd.concat(frames, ignore_index=True)\n    result = result.sort_values([\"quotedate\", \"underlying\", \"expiration\", \"strike\", \"type\"])\n    PROCESSED_DIR.mkdir(parents=True, exist_ok=True)\n    result.to_csv(output, index=False)\n    print(f\"Wrote {len(result)} option rows to {output}\")\n    return result\n\n\n# ---------------------------------------------------------------------------\n# Stocks\n# ---------------------------------------------------------------------------\n\ndef fetch_stocks(symbols, start, end, output, force=False):\n    \"\"\"Download stock data. Priority: options-dataset-hist > options-data > yfinance.\"\"\"\n    frames = []\n\n    for symbol in symbols:\n        sym = symbol.upper()\n        print(f\"Fetching stocks for {sym}...\")\n\n        und_path = download_underlying(sym, force)\n        if und_path is not None:\n            df = underlying_to_tiingo(sym, und_path, start, end)\n            if not df.empty:\n                source = \"options-dataset-hist\" if \"options-dataset-hist\" in str(und_path) else \"options-data\"\n                frames.append(df)\n                print(f\"  {len(df)} stock rows for {sym} (from {source})\")\n                continue\n\n        df = fetch_yfinance(sym, start, end)\n        if not df.empty:\n            frames.append(df)\n            print(f\"  {len(df)} stock rows for {sym} (from yfinance)\")\n        else:\n            print(f\"  No stock data for {sym}\", file=sys.stderr)\n\n    if not frames:\n        print(\"No stock data fetched.\", file=sys.stderr)\n        return None\n\n    result = pd.concat(frames, ignore_index=True)\n    PROCESSED_DIR.mkdir(parents=True, exist_ok=True)\n    result.to_csv(output, index=False)\n    print(f\"Wrote {len(result)} stock rows to {output}\")\n    return result\n\n\n# ---------------------------------------------------------------------------\n# Date alignment\n# ---------------------------------------------------------------------------\n\ndef align_dates(stocks_path, options_path):\n    \"\"\"Align stock and option dates to their intersection.\"\"\"\n    stocks = pd.read_csv(stocks_path, parse_dates=[\"date\"])\n    options = pd.read_csv(options_path, parse_dates=[\"quotedate\", \"expiration\"])\n\n    stock_dates = set(stocks[\"date\"].dt.normalize())\n    option_dates = set(options[\"quotedate\"].dt.normalize())\n    shared = stock_dates & option_dates\n\n    if not shared:\n        print(\"Warning: no overlapping dates between stocks and options!\", file=sys.stderr)\n        return\n\n    stocks_filtered = stocks[stocks[\"date\"].dt.normalize().isin(shared)]\n    options_filtered = options[options[\"quotedate\"].dt.normalize().isin(shared)]\n\n    stocks_filtered.to_csv(stocks_path, index=False)\n    options_filtered.to_csv(options_path, index=False)\n\n    dropped_stock = len(stocks) - len(stocks_filtered)\n    dropped_opt = len(options) - len(options_filtered)\n    print(f\"Aligned dates: {len(shared)} shared trading days\")\n    if dropped_stock:\n        print(f\"  Dropped {dropped_stock} stock rows without matching option dates\")\n    if dropped_opt:\n        print(f\"  Dropped {dropped_opt} option rows without matching stock dates\")\n\n\n# ---------------------------------------------------------------------------\n# CLI\n# ---------------------------------------------------------------------------\n\ndef main():\n    parser = argparse.ArgumentParser(\n        description=\"Fetch stock and options data for the backtester\"\n    )\n    parser.add_argument(\n        \"command\", nargs=\"?\", default=\"all\",\n        choices=[\"all\", \"stocks\", \"options\"],\n        help=\"What to fetch (default: all)\",\n    )\n    parser.add_argument(\n        \"--symbols\", nargs=\"+\", required=True,\n        help=\"Ticker symbols (e.g. SPY IWM QQQ AAPL)\",\n    )\n    parser.add_argument(\"--start\", required=True, help=\"Start date (YYYY-MM-DD)\")\n    parser.add_argument(\"--end\", required=True, help=\"End date (YYYY-MM-DD)\")\n    parser.add_argument(\n        \"--stocks-output\", default=\"data/processed/stocks.csv\",\n        help=\"Stock CSV output path\",\n    )\n    parser.add_argument(\n        \"--options-output\", default=\"data/processed/options.csv\",\n        help=\"Options CSV output path\",\n    )\n    parser.add_argument(\n        \"--update\", action=\"store_true\",\n        help=\"Re-download parquets to get latest data\",\n    )\n    args = parser.parse_args()\n\n    start = pd.Timestamp(args.start)\n    end = pd.Timestamp(args.end)\n    force = args.update\n\n    if args.command in (\"all\", \"options\"):\n        fetch_options(args.symbols, start, end, args.options_output, force)\n\n    if args.command in (\"all\", \"stocks\"):\n        fetch_stocks(args.symbols, start, end, args.stocks_output, force)\n\n    if args.command == \"all\":\n        if Path(args.stocks_output).exists() and Path(args.options_output).exists():\n            print(\"\\nAligning dates...\")\n            align_dates(args.stocks_output, args.options_output)\n\n    print(\"\\nDone!\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "data/fetch_signals.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Download macro signal data from FRED for use in backtest signal filters.\n\nDownloads:\n  - GDP (quarterly) — for Buffett Indicator proxy\n  - VIX (daily) — CBOE Volatility Index\n  - High Yield Spread (daily) — credit stress indicator\n  - 10Y-2Y Yield Curve (daily) — recession predictor\n  - Nonfinancial Corporate Equity Market Value (quarterly) — for Tobin's Q\n  - Nonfinancial Corporate Net Worth (quarterly) — for Tobin's Q\n  - Dollar Index (daily) — broad trade-weighted USD\n\nOutputs:\n  data/processed/signals.csv — daily signal data, forward-filled from quarterly\n\"\"\"\n\nimport io\nimport urllib.request\n\nimport pandas as pd\n\nFRED_SERIES = {\n    'gdp': 'GDP',\n    'vix': 'VIXCLS',\n    'hy_spread': 'BAMLH0A0HYM2',\n    'yield_curve_10y2y': 'T10Y2Y',\n    'nfc_equity_mv': 'NCBEILQ027S',\n    'nfc_net_worth': 'NCBCMDPMVCE',\n    'dollar_index': 'DTWEXBGS',\n}\n\nSTART = '2007-01-01'\nEND = '2025-12-31'\n\n\ndef fetch_fred(series_id: str) -> pd.Series:\n    url = (f'https://fred.stlouisfed.org/graph/fredgraph.csv'\n           f'?id={series_id}&cosd={START}&coed={END}')\n    req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})\n    resp = urllib.request.urlopen(req, timeout=30)\n    data = resp.read().decode()\n    df = pd.read_csv(io.StringIO(data), parse_dates=['observation_date'],\n                     index_col='observation_date')\n    col = df.columns[0]\n    s = pd.to_numeric(df[col], errors='coerce')\n    s.index.name = 'date'\n    return s.dropna()\n\n\ndef main():\n    signals = {}\n\n    for name, sid in FRED_SERIES.items():\n        print(f'Fetching {name} ({sid})...', end=' ', flush=True)\n        try:\n            s = fetch_fred(sid)\n            signals[name] = s\n            print(f'{len(s)} obs, {s.index[0].date()} to {s.index[-1].date()}')\n        except Exception as e:\n            print(f'FAILED: {e}')\n\n    if not signals:\n        print('No data fetched.')\n        return\n\n    # Build daily DataFrame\n    all_dates = sorted(set().union(*(s.index for s in signals.values())))\n    daily = pd.DataFrame(index=pd.DatetimeIndex(all_dates, name='date'))\n\n    for name, s in signals.items():\n        daily[name] = s.reindex(daily.index)\n\n    # Forward-fill quarterly data to daily\n    daily = daily.ffill()\n\n    # Compute derived signals\n    if 'gdp' in daily.columns:\n        # Buffett Indicator proxy: we don't have total market cap, but\n        # nfc_equity_mv is corporate equity market value (in millions)\n        # GDP is in billions. Scale NFC equity to billions to match.\n        if 'nfc_equity_mv' in daily.columns:\n            daily['buffett_indicator'] = daily['nfc_equity_mv'] / (daily['gdp'] * 1000) * 100\n            print(f'Computed buffett_indicator (nfc_equity_mv / GDP)')\n\n    if 'nfc_equity_mv' in daily.columns and 'nfc_net_worth' in daily.columns:\n        # Tobin's Q proxy: market value / net worth\n        # nfc_net_worth is in weird units (ratio), use nfc_equity_mv levels\n        # Actually NCBCMDPMVCE is \"market value / cost\" already\n        daily['tobin_q'] = daily['nfc_net_worth']\n        print(f'Computed tobin_q (NCBCMDPMVCE is already MV/replacement cost)')\n\n    daily = daily.dropna(how='all')\n\n    out = 'data/processed/signals.csv'\n    daily.to_csv(out)\n    print(f'\\nSaved {len(daily)} rows to {out}')\n    print(f'Columns: {list(daily.columns)}')\n    print(f'Date range: {daily.index[0].date()} to {daily.index[-1].date()}')\n    print(daily.describe().round(2))\n\n\nif __name__ == '__main__':\n    main()\n"
  },
  {
    "path": "flake.nix",
    "content": "{\n  description = \"Options backtester dev environment\";\n\n  inputs = {\n    nixpkgs.url = \"github:NixOS/nixpkgs/nixpkgs-unstable\";\n    rust-overlay.url = \"github:oxalica/rust-overlay\";\n  };\n\n  outputs = { self, nixpkgs, rust-overlay }:\n    let\n      supportedSystems = [ \"x86_64-linux\" \"aarch64-linux\" \"x86_64-darwin\" \"aarch64-darwin\" ];\n      forAllSystems = nixpkgs.lib.genAttrs supportedSystems;\n    in {\n      devShells = forAllSystems (system:\n        let\n          pkgs = import nixpkgs {\n            inherit system;\n            overlays = [ rust-overlay.overlays.default ];\n          };\n          python = pkgs.python312;\n          pythonPkgs = python.pkgs;\n          rustToolchain = pkgs.rust-bin.stable.latest.default.override {\n            extensions = [ \"rust-src\" \"rust-analyzer\" ];\n          };\n        in {\n          default = pkgs.mkShell {\n            packages = [\n              # Rust\n              rustToolchain\n              pkgs.maturin\n              pkgs.cargo-nextest\n\n              (python.withPackages (ps: [\n                # Runtime\n                ps.pandas\n                ps.numpy\n                ps.altair\n                ps.pyprind\n                ps.seaborn\n                ps.matplotlib\n                ps.pyarrow\n                ps.polars\n\n                # Notebooks\n                ps.jupyter\n                ps.nbconvert\n                ps.ipykernel\n\n                # Testing\n                ps.pytest\n                ps.hypothesis\n                ps.pytest-benchmark\n                ps.mypy\n                ps.pandas-stubs\n                ps.ruff\n\n                # Dev tools\n                ps.yapf\n\n                # Data fetching (optional, for data/ scripts)\n                ps.yfinance\n              ]))\n            ];\n\n            shellHook = ''\n              export PYO3_PYTHON=${python}/bin/python\n              export PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1\n\n              # Build Rust extension and symlink for Python import\n              if [ -f rust/ob_python/Cargo.toml ]; then\n                if [ ! -f rust/target/release/lib_ob_rust.dylib ] && [ ! -f rust/target/release/lib_ob_rust.so ]; then\n                  echo \"Building Rust extension (first time only)...\"\n                  cargo build --manifest-path rust/ob_python/Cargo.toml --release 2>&1 | tail -1\n                fi\n                # Python needs _ob_rust.so, Rust produces lib_ob_rust.dylib/.so\n                if [ -f rust/target/release/lib_ob_rust.dylib ] && [ ! -f _ob_rust.so ]; then\n                  ln -sf rust/target/release/lib_ob_rust.dylib _ob_rust.so\n                elif [ -f rust/target/release/lib_ob_rust.so ] && [ ! -f _ob_rust.so ]; then\n                  ln -sf rust/target/release/lib_ob_rust.so _ob_rust.so\n                fi\n              fi\n            '';\n          };\n        });\n    };\n}\n"
  },
  {
    "path": "options_portfolio_backtester/__init__.py",
    "content": "\"\"\"options_portfolio_backtester — the open-source options backtesting framework.\"\"\"\n\n# Core types\nfrom options_portfolio_backtester.core.types import (\n    Direction,\n    OptionType,\n    Type,\n    Order,\n    Signal,\n    Fill,\n    Greeks,\n    OptionContract,\n    StockAllocation,\n    Stock,\n    get_order,\n)\n\n# Data\nfrom options_portfolio_backtester.data.schema import Schema, Field, Filter\nfrom options_portfolio_backtester.data.providers import (\n    CsvOptionsProvider, CsvStocksProvider,\n    TiingoData, HistoricalOptionsData,\n)\n\n# Strategy\nfrom options_portfolio_backtester.strategy.strategy import Strategy\nfrom options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\nfrom options_portfolio_backtester.strategy.presets import Strangle\n\n# Execution\nfrom options_portfolio_backtester.execution.cost_model import (\n    NoCosts, PerContractCommission, TieredCommission, SpreadSlippage,\n)\nfrom options_portfolio_backtester.execution.fill_model import MarketAtBidAsk, MidPrice, VolumeAwareFill\nfrom options_portfolio_backtester.execution.sizer import (\n    CapitalBased, FixedQuantity, FixedDollar, PercentOfPortfolio,\n)\nfrom options_portfolio_backtester.execution.signal_selector import (\n    FirstMatch, NearestDelta, MaxOpenInterest,\n)\n\n# Portfolio\nfrom options_portfolio_backtester.portfolio.portfolio import Portfolio\nfrom options_portfolio_backtester.portfolio.position import OptionPosition\nfrom options_portfolio_backtester.portfolio.greeks import aggregate_greeks\nfrom options_portfolio_backtester.portfolio.risk import RiskManager, MaxDelta, MaxVega, MaxDrawdown\n\n# Engine\nfrom options_portfolio_backtester.engine.engine import BacktestEngine\nfrom options_portfolio_backtester.engine.clock import TradingClock\n\n# Analytics\nfrom options_portfolio_backtester.analytics.stats import BacktestStats, PeriodStats, LookbackReturns\nfrom options_portfolio_backtester.analytics.trade_log import TradeLog\nfrom options_portfolio_backtester.analytics.tearsheet import TearsheetReport, build_tearsheet\nfrom options_portfolio_backtester.analytics.summary import summary\n\n__all__ = [\n    # Core types\n    \"Direction\", \"OptionType\", \"Type\", \"Order\", \"Signal\", \"Fill\", \"Greeks\",\n    \"OptionContract\", \"StockAllocation\", \"Stock\", \"get_order\",\n    # Data\n    \"Schema\", \"Field\", \"Filter\", \"CsvOptionsProvider\", \"CsvStocksProvider\",\n    \"TiingoData\", \"HistoricalOptionsData\",\n    # Strategy\n    \"Strategy\", \"StrategyLeg\", \"Strangle\",\n    # Execution\n    \"NoCosts\", \"PerContractCommission\", \"TieredCommission\", \"SpreadSlippage\",\n    \"MarketAtBidAsk\", \"MidPrice\", \"VolumeAwareFill\",\n    \"CapitalBased\", \"FixedQuantity\", \"FixedDollar\", \"PercentOfPortfolio\",\n    \"FirstMatch\", \"NearestDelta\", \"MaxOpenInterest\",\n    # Portfolio\n    \"Portfolio\", \"OptionPosition\", \"aggregate_greeks\",\n    \"RiskManager\", \"MaxDelta\", \"MaxVega\", \"MaxDrawdown\",\n    # Engine\n    \"BacktestEngine\", \"TradingClock\",\n    # Analytics\n    \"BacktestStats\", \"PeriodStats\", \"LookbackReturns\",\n    \"TradeLog\", \"TearsheetReport\", \"build_tearsheet\",\n    \"summary\",\n]\n"
  },
  {
    "path": "options_portfolio_backtester/analytics/__init__.py",
    "content": ""
  },
  {
    "path": "options_portfolio_backtester/analytics/charts.py",
    "content": "\"\"\"Charts — Altair charts + matplotlib additions.\"\"\"\n\nfrom __future__ import annotations\n\nimport altair as alt\nimport pandas as pd\n\n\ndef returns_chart(report: pd.DataFrame) -> alt.VConcatChart:\n    # Time interval selector\n    time_interval = alt.selection_interval(encodings=['x'])\n\n    # Area plot\n    areas = alt.Chart().mark_area(opacity=0.7).encode(x='index:T',\n                                                      y=alt.Y('accumulated return:Q', axis=alt.Axis(format='%')))\n\n    # Nearest point selector\n    nearest = alt.selection_point(nearest=True, on='mouseover', fields=['index'])\n\n    points = areas.mark_point().encode(opacity=alt.condition(nearest, alt.value(1), alt.value(0)))\n\n    # Transparent date selector\n    selectors = alt.Chart().mark_point().encode(\n        x='index:T',\n        opacity=alt.value(0),\n    ).add_params(nearest)\n\n    text = areas.mark_text(\n        align='left', dx=5,\n        dy=-5).encode(text=alt.condition(nearest, 'accumulated return:Q', alt.value(' '), format='.2%'))\n\n    layered = alt.layer(selectors,\n                        points,\n                        text,\n                        areas.encode(\n                            alt.X('index:T', axis=alt.Axis(title='date'), scale=alt.Scale(domain=time_interval))),\n                        width=700,\n                        height=350,\n                        title='Returns over time')\n\n    lower = areas.properties(width=700, height=70).add_params(time_interval)\n\n    return alt.vconcat(layered, lower, data=report.reset_index())\n\n\ndef returns_histogram(report: pd.DataFrame) -> alt.Chart:\n    bar = alt.Chart(report).mark_bar().encode(x=alt.X('% change:Q',\n                                                      bin=alt.BinParams(maxbins=100),\n                                                      axis=alt.Axis(format='%')),\n                                              y='count():Q')\n    return bar\n\n\ndef monthly_returns_heatmap(report: pd.DataFrame) -> alt.Chart:\n    resample = report.resample('ME')['total capital'].last()\n    monthly_returns = resample.pct_change().reset_index()\n    monthly_returns.loc[monthly_returns.index[0], 'total capital'] = resample.iloc[0] / report.iloc[0]['total capital'] - 1\n    monthly_returns.columns = ['date', 'total capital']\n\n    chart = alt.Chart(monthly_returns).mark_rect().encode(\n        alt.X('year(date):O', title='Year'), alt.Y('month(date):O', title='Month'),\n        alt.Color('mean(total capital)', title='Return', scale=alt.Scale(scheme='redyellowgreen')),\n        alt.Tooltip('mean(total capital)', format='.2f')).properties(title='Monthly Returns')\n\n    return chart\n\n\ndef weights_chart(balance: pd.DataFrame, figsize: tuple[float, float] = (12, 6)):\n    \"\"\"Stacked area chart of portfolio weights over time.\n\n    Expects a balance DataFrame with ``{symbol} qty`` columns and a\n    ``total capital`` column (as produced by ``AlgoPipelineBacktester``).\n\n    Returns ``(fig, ax)`` from matplotlib.\n    \"\"\"\n    import matplotlib.pyplot as plt\n\n    qty_cols = [c for c in balance.columns if c.endswith(\" qty\")]\n    if not qty_cols:\n        fig, ax = plt.subplots(figsize=figsize)\n        ax.set_title(\"Portfolio Weights (no positions found)\")\n        return fig, ax\n\n    symbols = [c.replace(\" qty\", \"\") for c in qty_cols]\n    total = balance[\"total capital\"]\n\n    # Compute weights: qty * price / total_capital\n    # We don't have price columns directly, but stocks capital is available.\n    # Reconstruct per-symbol value: qty * (total - cash) is aggregate,\n    # so we estimate from qty shares of total stock value.\n    weights = pd.DataFrame(index=balance.index)\n    for sym, col in zip(symbols, qty_cols):\n        weights[sym] = balance[col].fillna(0)\n\n    # Normalize to weights (proportional share of total qty-weighted value)\n    row_sums = weights.abs().sum(axis=1)\n    row_sums = row_sums.replace(0, 1)  # avoid division by zero\n    # If we have cash and total capital, use stock fraction\n    if \"cash\" in balance.columns:\n        stock_fraction = 1.0 - balance[\"cash\"] / total.replace(0, 1)\n        for sym in symbols:\n            weights[sym] = (weights[sym] / row_sums) * stock_fraction\n    else:\n        weights = weights.div(row_sums, axis=0)\n\n    fig, ax = plt.subplots(figsize=figsize)\n    ax.stackplot(weights.index, *[weights[s] for s in symbols], labels=symbols, alpha=0.8)\n    ax.set_title(\"Portfolio Weights Over Time\")\n    ax.set_ylabel(\"Weight\")\n    ax.set_xlabel(\"Date\")\n    ax.legend(loc=\"upper left\", fontsize=\"small\")\n    ax.set_ylim(0, 1)\n    fig.tight_layout()\n    return fig, ax\n\n\n__all__ = [\"returns_chart\", \"returns_histogram\", \"monthly_returns_heatmap\", \"weights_chart\"]\n"
  },
  {
    "path": "options_portfolio_backtester/analytics/optimization.py",
    "content": "\"\"\"Walk-forward optimization and parameter grid sweep.\"\"\"\n\nfrom __future__ import annotations\n\nimport itertools\nfrom concurrent.futures import ProcessPoolExecutor, as_completed\nfrom dataclasses import dataclass\nfrom typing import Any, Callable\n\nimport pandas as pd\nimport numpy as np\n\nfrom options_portfolio_backtester.analytics.stats import BacktestStats\n\n\n@dataclass\nclass OptimizationResult:\n    \"\"\"Result of a single parameter combination.\"\"\"\n    params: dict[str, Any]\n    stats: BacktestStats\n    balance: pd.DataFrame\n\n\ndef grid_sweep(\n    run_fn: Callable[..., tuple[BacktestStats, pd.DataFrame]],\n    param_grid: dict[str, list[Any]],\n    max_workers: int | None = None,\n) -> list[OptimizationResult]:\n    \"\"\"Run a parameter grid sweep using parallel execution.\n\n    Args:\n        run_fn: Function that takes **params and returns (BacktestStats, balance).\n        param_grid: Dict mapping parameter names to lists of values.\n        max_workers: Number of parallel workers (None = CPU count).\n\n    Returns:\n        List of OptimizationResult, sorted by Sharpe ratio descending.\n    \"\"\"\n    keys = list(param_grid.keys())\n    combos = list(itertools.product(*param_grid.values()))\n    results: list[OptimizationResult] = []\n\n    with ProcessPoolExecutor(max_workers=max_workers) as executor:\n        futures = {}\n        for combo in combos:\n            params = dict(zip(keys, combo))\n            future = executor.submit(run_fn, **params)\n            futures[future] = params\n\n        for future in as_completed(futures):\n            params = futures[future]\n            try:\n                stats, balance = future.result()\n                results.append(OptimizationResult(\n                    params=params, stats=stats, balance=balance,\n                ))\n            except Exception:\n                continue\n\n    results.sort(key=lambda r: r.stats.sharpe_ratio, reverse=True)\n    return results\n\n\ndef walk_forward(\n    run_fn: Callable[[pd.Timestamp, pd.Timestamp], tuple[BacktestStats, pd.DataFrame]],\n    dates: pd.DatetimeIndex,\n    in_sample_pct: float = 0.70,\n    n_splits: int = 5,\n) -> list[tuple[OptimizationResult, OptimizationResult]]:\n    \"\"\"Walk-forward analysis with rolling in-sample/out-of-sample splits.\n\n    Args:\n        run_fn: Function that takes (start_date, end_date) and returns (stats, balance).\n        dates: Full date range.\n        in_sample_pct: Fraction of each window used for in-sample.\n        n_splits: Number of walk-forward windows.\n\n    Returns:\n        List of (in_sample_result, out_of_sample_result) tuples.\n    \"\"\"\n    total = len(dates)\n    window_size = total // n_splits\n    results = []\n\n    for i in range(n_splits):\n        start_idx = i * window_size\n        end_idx = min(start_idx + window_size, total)\n        split_idx = start_idx + int((end_idx - start_idx) * in_sample_pct)\n\n        is_start = dates[start_idx]\n        is_end = dates[split_idx - 1]\n        oos_start = dates[split_idx]\n        oos_end = dates[end_idx - 1]\n\n        try:\n            is_stats, is_balance = run_fn(is_start, is_end)\n            oos_stats, oos_balance = run_fn(oos_start, oos_end)\n            results.append((\n                OptimizationResult(params={\"split\": i, \"type\": \"in_sample\"},\n                                   stats=is_stats, balance=is_balance),\n                OptimizationResult(params={\"split\": i, \"type\": \"out_of_sample\"},\n                                   stats=oos_stats, balance=oos_balance),\n            ))\n        except Exception:\n            continue\n\n    return results\n\n\ndef rust_grid_sweep(\n    options_data,\n    stocks_data,\n    base_config: dict,\n    schema_mapping: dict,\n    param_overrides: list[dict],\n    n_workers: int | None = None,\n) -> list[dict]:\n    \"\"\"Run a parallel grid sweep using the Rust backtest engine.\n\n    Each dict in param_overrides can contain:\n        - \"label\": str\n        - \"profit_pct\": Optional[float]\n        - \"loss_pct\": Optional[float]\n        - \"rebalance_dates\": Optional[list[str]]\n        - \"leg_entry_filters\": Optional[list[Optional[str]]]\n        - \"leg_exit_filters\": Optional[list[Optional[str]]]\n\n    Returns list of result dicts sorted by sharpe_ratio descending.\n    \"\"\"\n    from options_portfolio_backtester._ob_rust import parallel_sweep\n\n    results = parallel_sweep(\n        options_data, stocks_data,\n        base_config, schema_mapping,\n        param_overrides, n_workers,\n    )\n    results.sort(key=lambda r: r.get(\"sharpe_ratio\", 0.0), reverse=True)\n    return results\n"
  },
  {
    "path": "options_portfolio_backtester/analytics/stats.py",
    "content": "\"\"\"BacktestStats — comprehensive analytics matching and exceeding bt/ffn.\n\nProvides:\n- Trade stats: profit factor, win rate, largest win/loss\n- Return stats: total, annualized, Sharpe, Sortino, Calmar\n- Risk stats: max drawdown, drawdown duration, volatility, tail ratio\n- Period stats: monthly/yearly Sharpe, Sortino, mean, vol, skew, kurtosis\n- Extreme analysis: best/worst day, month, year\n- Lookback returns: MTD, 3M, 6M, YTD, 1Y, 3Y, 5Y, 10Y\n- Portfolio metrics: turnover, Herfindahl concentration index\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\nfrom typing import Any\n\nimport numpy as np\nimport pandas as pd\n\nfrom options_portfolio_backtester._ob_rust import compute_full_stats\n\n\n@dataclass\nclass PeriodStats:\n    \"\"\"Stats for a specific return frequency (daily, monthly, yearly).\"\"\"\n    mean: float = 0.0\n    vol: float = 0.0\n    sharpe: float = 0.0\n    sortino: float = 0.0\n    skew: float = 0.0\n    kurtosis: float = 0.0\n    best: float = 0.0\n    worst: float = 0.0\n\n\n@dataclass\nclass LookbackReturns:\n    \"\"\"Trailing period returns as of the end date.\"\"\"\n    mtd: float | None = None\n    three_month: float | None = None\n    six_month: float | None = None\n    ytd: float | None = None\n    one_year: float | None = None\n    three_year: float | None = None\n    five_year: float | None = None\n    ten_year: float | None = None\n\n\n@dataclass\nclass BacktestStats:\n    \"\"\"Comprehensive backtest statistics.\"\"\"\n\n    # Trade stats\n    total_trades: int = 0\n    wins: int = 0\n    losses: int = 0\n    win_pct: float = 0.0\n    profit_factor: float = 0.0\n    largest_win: float = 0.0\n    largest_loss: float = 0.0\n    avg_win: float = 0.0\n    avg_loss: float = 0.0\n    avg_trade: float = 0.0\n\n    # Return stats\n    total_return: float = 0.0\n    annualized_return: float = 0.0\n    sharpe_ratio: float = 0.0\n    sortino_ratio: float = 0.0\n    calmar_ratio: float = 0.0\n\n    # Risk stats\n    max_drawdown: float = 0.0\n    max_drawdown_duration: int = 0\n    avg_drawdown: float = 0.0\n    avg_drawdown_duration: int = 0\n    volatility: float = 0.0\n    tail_ratio: float = 0.0\n\n    # Period stats\n    daily: PeriodStats = field(default_factory=PeriodStats)\n    monthly: PeriodStats = field(default_factory=PeriodStats)\n    yearly: PeriodStats = field(default_factory=PeriodStats)\n\n    # Lookback\n    lookback: LookbackReturns = field(default_factory=LookbackReturns)\n\n    # Portfolio metrics\n    turnover: float = 0.0\n    herfindahl: float = 0.0\n\n    @classmethod\n    def from_balance_range(\n        cls,\n        balance: pd.DataFrame,\n        start: str | pd.Timestamp | None = None,\n        end: str | pd.Timestamp | None = None,\n        **kwargs,\n    ) -> \"BacktestStats\":\n        \"\"\"Slice balance to [start, end] and recompute all stats.\"\"\"\n        if balance.empty:\n            return cls()\n        b = balance.copy()\n        if start:\n            b = b.loc[pd.Timestamp(start):]\n        if end:\n            b = b.loc[:pd.Timestamp(end)]\n        if b.empty:\n            return cls()\n        b[\"% change\"] = b[\"total capital\"].pct_change()\n        return cls.from_balance(b, **kwargs)\n\n    @classmethod\n    def from_balance(\n        cls,\n        balance: pd.DataFrame,\n        trade_pnls: np.ndarray | None = None,\n        risk_free_rate: float = 0.0,\n    ) -> \"BacktestStats\":\n        \"\"\"Compute stats from a balance DataFrame and optional trade P&Ls.\"\"\"\n        if balance.empty:\n            return cls()\n\n        total_capital = balance[\"total capital\"].values.astype(np.float64)\n        timestamps_ns = balance.index.values.astype(\"datetime64[ns]\").view(\"int64\").astype(np.int64).tolist()\n        pnls = trade_pnls.astype(np.float64).tolist() if trade_pnls is not None else []\n\n        # Build stock weight matrix\n        stock_cols = [c for c in balance.columns if f\"{c} qty\" in balance.columns]\n        if stock_cols:\n            total = balance[\"total capital\"].values\n            with np.errstate(divide=\"ignore\", invalid=\"ignore\"):\n                weights = balance[stock_cols].values / total[:, None]\n            weights = np.nan_to_num(weights, 0.0).astype(np.float64)\n            flat_weights = weights.ravel().tolist()\n            n_stocks = len(stock_cols)\n        else:\n            flat_weights = []\n            n_stocks = 0\n\n        d = compute_full_stats(\n            total_capital.tolist(),\n            timestamps_ns,\n            pnls,\n            flat_weights,\n            n_stocks,\n            risk_free_rate,\n        )\n\n        stats = cls()\n        # Scalars\n        for attr in (\n            \"total_trades\", \"wins\", \"losses\", \"win_pct\", \"profit_factor\",\n            \"largest_win\", \"largest_loss\", \"avg_win\", \"avg_loss\", \"avg_trade\",\n            \"total_return\", \"annualized_return\", \"sharpe_ratio\", \"sortino_ratio\",\n            \"calmar_ratio\", \"max_drawdown\", \"max_drawdown_duration\",\n            \"avg_drawdown\", \"avg_drawdown_duration\", \"volatility\", \"tail_ratio\",\n            \"turnover\", \"herfindahl\",\n        ):\n            setattr(stats, attr, d[attr])\n\n        # Period stats\n        for period_name in (\"daily\", \"monthly\", \"yearly\"):\n            pd_dict = d[period_name]\n            setattr(stats, period_name, PeriodStats(\n                mean=pd_dict[\"mean\"], vol=pd_dict[\"vol\"],\n                sharpe=pd_dict[\"sharpe\"], sortino=pd_dict[\"sortino\"],\n                skew=pd_dict[\"skew\"], kurtosis=pd_dict[\"kurtosis\"],\n                best=pd_dict[\"best\"], worst=pd_dict[\"worst\"],\n            ))\n\n        # Lookback\n        lb = d[\"lookback\"]\n        stats.lookback = LookbackReturns(\n            mtd=lb[\"mtd\"], three_month=lb[\"three_month\"],\n            six_month=lb[\"six_month\"], ytd=lb[\"ytd\"],\n            one_year=lb[\"one_year\"], three_year=lb[\"three_year\"],\n            five_year=lb[\"five_year\"], ten_year=lb[\"ten_year\"],\n        )\n\n        return stats\n\n    def to_dataframe(self) -> pd.DataFrame:\n        \"\"\"Return stats as a styled DataFrame.\"\"\"\n        data = {\n            \"Total trades\": self.total_trades,\n            \"Wins\": self.wins,\n            \"Losses\": self.losses,\n            \"Win %\": self.win_pct,\n            \"Profit factor\": self.profit_factor,\n            \"Largest win\": self.largest_win,\n            \"Largest loss\": self.largest_loss,\n            \"Avg win\": self.avg_win,\n            \"Avg loss\": self.avg_loss,\n            \"Avg trade\": self.avg_trade,\n            \"Total return\": self.total_return,\n            \"Annualized return\": self.annualized_return,\n            \"Sharpe ratio\": self.sharpe_ratio,\n            \"Sortino ratio\": self.sortino_ratio,\n            \"Calmar ratio\": self.calmar_ratio,\n            \"Max drawdown\": self.max_drawdown,\n            \"Max DD duration (days)\": self.max_drawdown_duration,\n            \"Avg drawdown\": self.avg_drawdown,\n            \"Avg DD duration (days)\": self.avg_drawdown_duration,\n            \"Volatility\": self.volatility,\n            \"Tail ratio\": self.tail_ratio,\n            # Daily\n            \"Daily mean\": self.daily.mean,\n            \"Daily vol\": self.daily.vol,\n            \"Daily Sharpe\": self.daily.sharpe,\n            \"Daily Sortino\": self.daily.sortino,\n            \"Daily skew\": self.daily.skew,\n            \"Daily kurtosis\": self.daily.kurtosis,\n            \"Best day\": self.daily.best,\n            \"Worst day\": self.daily.worst,\n            # Monthly\n            \"Monthly mean\": self.monthly.mean,\n            \"Monthly vol\": self.monthly.vol,\n            \"Monthly Sharpe\": self.monthly.sharpe,\n            \"Monthly Sortino\": self.monthly.sortino,\n            \"Monthly skew\": self.monthly.skew,\n            \"Monthly kurtosis\": self.monthly.kurtosis,\n            \"Best month\": self.monthly.best,\n            \"Worst month\": self.monthly.worst,\n            # Yearly\n            \"Yearly mean\": self.yearly.mean,\n            \"Yearly vol\": self.yearly.vol,\n            \"Yearly Sharpe\": self.yearly.sharpe,\n            \"Yearly Sortino\": self.yearly.sortino,\n            \"Best year\": self.yearly.best,\n            \"Worst year\": self.yearly.worst,\n            # Portfolio\n            \"Turnover\": self.turnover,\n            \"Herfindahl index\": self.herfindahl,\n        }\n        # Add lookback returns (skip None values)\n        lb = self.lookback\n        for label, val in [\n            (\"MTD\", lb.mtd), (\"3M return\", lb.three_month),\n            (\"6M return\", lb.six_month), (\"YTD\", lb.ytd),\n            (\"1Y return\", lb.one_year), (\"3Y return\", lb.three_year),\n            (\"5Y return\", lb.five_year), (\"10Y return\", lb.ten_year),\n        ]:\n            if val is not None:\n                data[label] = val\n\n        return pd.DataFrame(\n            list(data.values()), index=list(data.keys()), columns=[\"Value\"]\n        )\n\n    def summary(self) -> str:\n        \"\"\"Return a formatted text summary.\"\"\"\n        lines = [\n            f\"Total Return:      {self.total_return:>10.2%}\",\n            f\"Annualized Return: {self.annualized_return:>10.2%}\",\n            f\"Sharpe Ratio:      {self.sharpe_ratio:>10.2f}\",\n            f\"Sortino Ratio:     {self.sortino_ratio:>10.2f}\",\n            f\"Max Drawdown:      {self.max_drawdown:>10.2%}\",\n            f\"Max DD Duration:   {self.max_drawdown_duration:>10d} days\",\n            f\"Calmar Ratio:      {self.calmar_ratio:>10.2f}\",\n            f\"Profit Factor:     {self.profit_factor:>10.2f}\",\n            f\"Win Rate:          {self.win_pct:>10.1f}%\",\n            f\"Total Trades:      {self.total_trades:>10d}\",\n        ]\n        if self.monthly.sharpe != 0:\n            lines.append(f\"Monthly Sharpe:    {self.monthly.sharpe:>10.2f}\")\n        if self.monthly.best != 0:\n            lines.append(f\"Best Month:        {self.monthly.best:>10.2%}\")\n            lines.append(f\"Worst Month:       {self.monthly.worst:>10.2%}\")\n        if self.turnover != 0:\n            lines.append(f\"Turnover:          {self.turnover:>10.2%}\")\n        return \"\\n\".join(lines)\n\n    def lookback_table(self) -> pd.DataFrame:\n        \"\"\"Lookback returns as a single-row DataFrame.\"\"\"\n        lb = self.lookback\n        data = {}\n        for label, val in [\n            (\"MTD\", lb.mtd), (\"3M\", lb.three_month), (\"6M\", lb.six_month),\n            (\"YTD\", lb.ytd), (\"1Y\", lb.one_year), (\"3Y\", lb.three_year),\n            (\"5Y\", lb.five_year), (\"10Y\", lb.ten_year),\n        ]:\n            if val is not None:\n                data[label] = val\n        if not data:\n            return pd.DataFrame()\n        return pd.DataFrame([data])\n"
  },
  {
    "path": "options_portfolio_backtester/analytics/summary.py",
    "content": "\"\"\"Summary statistics for trade logs.\"\"\"\n\nfrom __future__ import annotations\n\nimport numpy as np\nimport pandas as pd\n\nfrom options_portfolio_backtester.core.types import Order\n\n\ndef summary(trade_log: pd.DataFrame, balance: pd.DataFrame) -> pd.io.formats.style.Styler:\n    \"\"\"Returns a table with summary statistics about the trade log\"\"\"\n\n    initial_capital: float = balance['total capital'].iloc[0]\n    trade_log.loc[:,\n                  ('totals',\n                   'capital')] = (-trade_log['totals']['cost'] * trade_log['totals']['qty']).cumsum() + initial_capital\n\n    daily_returns: pd.Series = balance['% change'] * 100\n\n    first_leg: str = trade_log.columns.levels[0][0]\n\n    ## Not sure of a better way to to this, just doing `Order` or `@Order` inside\n    ## the .eval(...) does not seem to work.\n    order_bto = Order.BTO\n    order_sto = Order.STO\n\n    entry_mask: pd.Series = trade_log[first_leg].eval('(order == @order_bto) | (order == @order_sto)')\n    entries: pd.DataFrame = trade_log.loc[entry_mask]\n    exits: pd.DataFrame = trade_log.loc[~entry_mask]\n\n    costs: np.ndarray = np.array([])\n    for contract in entries[first_leg]['contract']:\n        entry = entries.loc[entries[first_leg]['contract'] == contract]\n        exit_ = exits.loc[exits[first_leg]['contract'] == contract]\n        try:\n            # Here we assume we are entering only once per contract (i.e both entry and exit_ have only one row)\n            costs = np.append(costs, (entry['totals']['cost'] * entry['totals']['qty']).values[0] +\n                              (exit_['totals']['cost'] * exit_['totals']['qty']).values[0])\n        except IndexError:\n            continue\n\n    wins: np.ndarray = costs < 0\n    losses: np.ndarray = costs >= 0\n    total_trades: int = len(exits)\n    win_number: int = int(np.sum(wins))\n    loss_number: int = total_trades - win_number\n    win_pct: float = (win_number / total_trades) * 100 if total_trades > 0 else 0\n    profit_factor: float = np.sum(wins) / np.sum(losses) if np.sum(losses) > 0 else 0\n    largest_loss: float = max(0, np.max(costs)) if len(costs) > 0 else 0\n    avg_profit: float = np.mean(-costs) if len(costs) > 0 else 0\n    avg_pl: float = np.mean(daily_returns)\n    total_pl: float = (trade_log['totals']['capital'].iloc[-1] / initial_capital) * 100\n\n    data = [total_trades, win_number, loss_number, win_pct, largest_loss, profit_factor, avg_profit, avg_pl, total_pl]\n    stats = [\n        'Total trades', 'Number of wins', 'Number of losses', 'Win %', 'Largest loss', 'Profit factor',\n        'Average profit', 'Average P&L %', 'Total P&L %'\n    ]\n    strat = ['Strategy']\n    summary_df = pd.DataFrame(data, stats, strat)\n\n    formatters: dict[str, str] = {\n        \"Total trades\": \"{:.0f}\",\n        \"Number of wins\": \"{:.0f}\",\n        \"Number of losses\": \"{:.0f}\",\n        \"Win %\": \"{:.2f}%\",\n        \"Largest loss\": \"${:.2f}\",\n        \"Profit factor\": \"{:.2f}\",\n        \"Average profit\": \"${:.2f}\",\n        \"Average P&L %\": \"{:.2f}%\",\n        \"Total P&L %\": \"{:.2f}%\"\n    }\n\n    styled = summary_df.style\n    for row_label, fmt in formatters.items():\n        styled = styled.format(fmt, subset=pd.IndexSlice[row_label, :])\n    return styled\n"
  },
  {
    "path": "options_portfolio_backtester/analytics/tearsheet.py",
    "content": "\"\"\"Simple tearsheet-style report helpers.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\n\nfrom options_portfolio_backtester.analytics.stats import BacktestStats\n\n\n@dataclass\nclass TearsheetReport:\n    \"\"\"Container for common report artifacts.\"\"\"\n\n    stats: BacktestStats\n    stats_table: pd.DataFrame\n    monthly_returns: pd.DataFrame\n    drawdown_series: pd.Series\n\n    def to_dict(self) -> dict[str, object]:\n        return {\n            \"stats\": self.stats,\n            \"stats_table\": self.stats_table,\n            \"monthly_returns\": self.monthly_returns,\n            \"drawdown_series\": self.drawdown_series,\n        }\n\n    def to_csv(self, directory: str | Path) -> dict[str, Path]:\n        out_dir = Path(directory)\n        out_dir.mkdir(parents=True, exist_ok=True)\n        stats_path = out_dir / \"stats_table.csv\"\n        monthly_path = out_dir / \"monthly_returns.csv\"\n        drawdown_path = out_dir / \"drawdown_series.csv\"\n        self.stats_table.to_csv(stats_path)\n        self.monthly_returns.to_csv(monthly_path)\n        self.drawdown_series.rename(\"drawdown\").to_frame().to_csv(drawdown_path)\n        return {\n            \"stats_table\": stats_path,\n            \"monthly_returns\": monthly_path,\n            \"drawdown_series\": drawdown_path,\n        }\n\n    def to_markdown(self) -> str:\n        lines = [\"# Tearsheet\", \"\", \"## Summary\", \"\"]\n        try:\n            lines.extend(self.stats_table.to_markdown().splitlines())\n        except Exception:\n            lines.extend(self.stats_table.to_string().splitlines())\n        lines.extend([\"\", \"## Monthly Returns\", \"\"])\n        if self.monthly_returns.empty:\n            lines.append(\"_No monthly returns available._\")\n        else:\n            try:\n                lines.extend(self.monthly_returns.to_markdown().splitlines())\n            except Exception:\n                lines.extend(self.monthly_returns.to_string().splitlines())\n        return \"\\n\".join(lines)\n\n    def to_html(self) -> str:\n        summary = self.stats_table.to_html(classes=\"stats-table\")\n        monthly = (\n            self.monthly_returns.to_html(classes=\"monthly-returns\")\n            if not self.monthly_returns.empty\n            else \"<p>No monthly returns available.</p>\"\n        )\n        return (\n            \"<html><head><meta charset='utf-8'><title>Tearsheet</title></head><body>\"\n            \"<h1>Tearsheet</h1>\"\n            \"<h2>Summary</h2>\"\n            f\"{summary}\"\n            \"<h2>Monthly Returns</h2>\"\n            f\"{monthly}\"\n            \"</body></html>\"\n        )\n\n\ndef monthly_return_table(balance: pd.DataFrame) -> pd.DataFrame:\n    if balance.empty or \"% change\" not in balance.columns:\n        return pd.DataFrame()\n    rets = balance[\"% change\"].dropna()\n    if rets.empty:\n        return pd.DataFrame()\n    monthly = (1.0 + rets).groupby(pd.Grouper(freq=\"ME\")).prod() - 1.0\n    out = monthly.to_frame(name=\"return\")\n    out[\"year\"] = out.index.year\n    out[\"month\"] = out.index.month\n    return out.pivot(index=\"year\", columns=\"month\", values=\"return\").sort_index()\n\n\ndef drawdown_series(balance: pd.DataFrame) -> pd.Series:\n    if balance.empty or \"total capital\" not in balance.columns:\n        return pd.Series(dtype=float)\n    total = balance[\"total capital\"].dropna()\n    if total.empty:\n        return pd.Series(dtype=float)\n    peak = total.cummax()\n    return (total - peak) / peak\n\n\ndef build_tearsheet(\n    balance: pd.DataFrame,\n    trade_pnls=None,\n    risk_free_rate: float = 0.0,\n) -> TearsheetReport:\n    trade_arr = None if trade_pnls is None else np.asarray(trade_pnls, dtype=float)\n    stats = BacktestStats.from_balance(balance, trade_pnls=trade_arr, risk_free_rate=risk_free_rate)\n    table = stats.to_dataframe()\n    monthly = monthly_return_table(balance)\n    dd = drawdown_series(balance)\n    return TearsheetReport(\n        stats=stats,\n        stats_table=table,\n        monthly_returns=monthly,\n        drawdown_series=dd,\n    )\n"
  },
  {
    "path": "options_portfolio_backtester/analytics/trade_log.py",
    "content": "\"\"\"Structured trade log — replaces MultiIndex trade log with per-trade P&L.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\nfrom typing import Any\n\nimport pandas as pd\nimport numpy as np\n\nfrom options_portfolio_backtester.core.types import Order\n\n\n@dataclass\nclass Trade:\n    \"\"\"A single round-trip trade (entry + exit).\"\"\"\n    contract: str\n    underlying: str\n    option_type: str\n    strike: float\n    entry_date: Any\n    exit_date: Any\n    entry_price: float\n    exit_price: float\n    quantity: int\n    shares_per_contract: int\n    entry_order: Order\n    exit_order: Order\n    entry_commission: float = 0.0\n    exit_commission: float = 0.0\n\n    @property\n    def gross_pnl(self) -> float:\n        \"\"\"P&L before commissions.\"\"\"\n        return (self.exit_price - self.entry_price) * self.quantity * self.shares_per_contract\n\n    @property\n    def net_pnl(self) -> float:\n        \"\"\"P&L after commissions.\"\"\"\n        return self.gross_pnl - self.entry_commission - self.exit_commission\n\n    @property\n    def return_pct(self) -> float:\n        \"\"\"Return as percentage of entry cost.\"\"\"\n        entry_cost = abs(self.entry_price * self.quantity * self.shares_per_contract)\n        if entry_cost == 0:\n            return 0.0\n        return self.net_pnl / entry_cost\n\n\nclass TradeLog:\n    \"\"\"Structured collection of round-trip trades with analysis methods.\"\"\"\n\n    def __init__(self) -> None:\n        self.trades: list[Trade] = []\n\n    def add_trade(self, trade: Trade) -> None:\n        self.trades.append(trade)\n\n    @classmethod\n    def from_legacy_trade_log(cls, trade_log: pd.DataFrame,\n                              shares_per_contract: int = 100) -> \"TradeLog\":\n        \"\"\"Build a TradeLog from the legacy MultiIndex trade_log DataFrame.\"\"\"\n        tl = cls()\n        if trade_log.empty:\n            return tl\n\n        first_leg: str = trade_log.columns.levels[0][0]\n\n        order_bto = Order.BTO\n        order_sto = Order.STO\n        entry_mask = trade_log[first_leg].eval(\n            \"(order == @order_bto) | (order == @order_sto)\"\n        )\n        entries = trade_log.loc[entry_mask]\n        exits = trade_log.loc[~entry_mask]\n\n        for contract in entries[first_leg][\"contract\"]:\n            entry = entries.loc[entries[first_leg][\"contract\"] == contract]\n            exit_ = exits.loc[exits[first_leg][\"contract\"] == contract]\n            if entry.empty or exit_.empty:\n                continue\n            try:\n                e_row = entry.iloc[0]\n                x_row = exit_.iloc[0]\n                trade = Trade(\n                    contract=contract,\n                    underlying=e_row[first_leg][\"underlying\"],\n                    option_type=e_row[first_leg][\"type\"],\n                    strike=e_row[first_leg][\"strike\"],\n                    entry_date=e_row[\"totals\"][\"date\"],\n                    exit_date=x_row[\"totals\"][\"date\"],\n                    entry_price=abs(e_row[first_leg][\"cost\"]) / shares_per_contract,\n                    exit_price=abs(x_row[first_leg][\"cost\"]) / shares_per_contract,\n                    quantity=int(e_row[\"totals\"][\"qty\"]),\n                    shares_per_contract=shares_per_contract,\n                    entry_order=e_row[first_leg][\"order\"],\n                    exit_order=x_row[first_leg][\"order\"],\n                )\n                tl.add_trade(trade)\n            except (IndexError, KeyError):\n                continue\n\n        return tl\n\n    def to_dataframe(self) -> pd.DataFrame:\n        \"\"\"Convert to a flat DataFrame for analysis.\"\"\"\n        if not self.trades:\n            return pd.DataFrame()\n        rows = []\n        for t in self.trades:\n            rows.append({\n                \"contract\": t.contract,\n                \"underlying\": t.underlying,\n                \"type\": t.option_type,\n                \"strike\": t.strike,\n                \"entry_date\": t.entry_date,\n                \"exit_date\": t.exit_date,\n                \"entry_price\": t.entry_price,\n                \"exit_price\": t.exit_price,\n                \"quantity\": t.quantity,\n                \"gross_pnl\": t.gross_pnl,\n                \"net_pnl\": t.net_pnl,\n                \"return_pct\": t.return_pct,\n                \"entry_commission\": t.entry_commission,\n                \"exit_commission\": t.exit_commission,\n            })\n        return pd.DataFrame(rows)\n\n    @property\n    def net_pnls(self) -> np.ndarray:\n        return np.array([t.net_pnl for t in self.trades])\n\n    @property\n    def winners(self) -> list[Trade]:\n        return [t for t in self.trades if t.net_pnl > 0]\n\n    @property\n    def losers(self) -> list[Trade]:\n        return [t for t in self.trades if t.net_pnl <= 0]\n\n    def __len__(self) -> int:\n        return len(self.trades)\n"
  },
  {
    "path": "options_portfolio_backtester/convexity/__init__.py",
    "content": "\"\"\"Convexity scanner: cross-asset tail protection scoring and allocation.\"\"\"\n\nfrom options_portfolio_backtester.convexity.allocator import (\n    allocate_equal_weight,\n    allocate_inverse_vol,\n    pick_cheapest,\n)\nfrom options_portfolio_backtester.convexity.backtest import (\n    BacktestResult,\n    run_backtest,\n    run_unhedged,\n)\nfrom options_portfolio_backtester.convexity.config import (\n    BacktestConfig,\n    InstrumentConfig,\n    default_config,\n)\nfrom options_portfolio_backtester.convexity.scoring import compute_convexity_scores\n\n__all__ = [\n    \"InstrumentConfig\",\n    \"BacktestConfig\",\n    \"default_config\",\n    \"compute_convexity_scores\",\n    \"BacktestResult\",\n    \"run_backtest\",\n    \"run_unhedged\",\n    \"pick_cheapest\",\n    \"allocate_equal_weight\",\n    \"allocate_inverse_vol\",\n]\n"
  },
  {
    "path": "options_portfolio_backtester/convexity/_utils.py",
    "content": "\"\"\"Shared utilities for the convexity module.\"\"\"\n\nfrom __future__ import annotations\n\nimport numpy as np\nimport pandas as pd\n\n\ndef _to_ns(series: pd.Series) -> np.ndarray:\n    \"\"\"Convert a datetime Series to int64 nanosecond timestamps.\"\"\"\n    return series.values.astype(\"datetime64[ns]\").view(\"int64\").astype(np.int64)\n"
  },
  {
    "path": "options_portfolio_backtester/convexity/allocator.py",
    "content": "\"\"\"Allocation strategies: pick which instrument(s) to hedge.\"\"\"\n\nfrom __future__ import annotations\n\n\ndef pick_cheapest(scores: dict[str, float]) -> str:\n    \"\"\"Pick the instrument with the highest convexity ratio.\"\"\"\n    if not scores:\n        raise ValueError(\"No scores to pick from\")\n    return max(scores, key=scores.get)\n\n\ndef allocate_equal_weight(symbols: list[str], budget: float) -> dict[str, float]:\n    \"\"\"Split budget equally across all instruments.\"\"\"\n    if not symbols:\n        return {}\n    per_symbol = budget / len(symbols)\n    return {s: per_symbol for s in symbols}\n\n\ndef allocate_inverse_vol(vol_map: dict[str, float], budget: float) -> dict[str, float]:\n    \"\"\"Allocate more to lower-volatility instruments.\n\n    Weight is proportional to 1/vol, normalized to sum to budget.\n    \"\"\"\n    if not vol_map:\n        return {}\n\n    inv_vols = {}\n    for sym, vol in vol_map.items():\n        if vol > 0:\n            inv_vols[sym] = 1.0 / vol\n\n    if not inv_vols:\n        return allocate_equal_weight(list(vol_map.keys()), budget)\n\n    total_inv_vol = sum(inv_vols.values())\n    return {sym: (iv / total_inv_vol) * budget for sym, iv in inv_vols.items()}\n"
  },
  {
    "path": "options_portfolio_backtester/convexity/backtest.py",
    "content": "\"\"\"Backtest: run the monthly rebalance loop via Rust backend.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom dataclasses import dataclass\n\nimport numpy as np\nimport pandas as pd\n\nfrom .config import BacktestConfig\n\nlog = logging.getLogger(__name__)\n\n\ndef _to_ns(series: pd.Series) -> np.ndarray:\n    \"\"\"Convert a datetime Series to int64 nanosecond timestamps.\"\"\"\n    return series.values.astype(\"datetime64[ns]\").view(\"int64\").astype(np.int64)\n\n\n@dataclass\nclass BacktestResult:\n    \"\"\"Results from a single-instrument backtest.\"\"\"\n\n    records: pd.DataFrame  # monthly rebalance records\n    daily_balance: pd.DataFrame  # daily portfolio values\n    config: BacktestConfig\n\n\ndef run_backtest(\n    options_data,\n    stocks_data,\n    config: BacktestConfig,\n) -> BacktestResult:\n    \"\"\"Run the full backtest: monthly put overlay on equity portfolio.\n\n    Takes HistoricalOptionsData and TiingoData from options_backtester.\n    \"\"\"\n    from options_portfolio_backtester._ob_rust import run_convexity_backtest\n\n    opt_df = options_data._data\n    puts = opt_df[opt_df[\"type\"] == \"put\"].sort_values(\"quotedate\")\n    stk_df = stocks_data._data.sort_values(\"date\")\n\n    if puts.empty or stk_df.empty:\n        empty_records = pd.DataFrame()\n        empty_daily = pd.DataFrame()\n        return BacktestResult(records=empty_records, daily_balance=empty_daily, config=config)\n\n    result = run_convexity_backtest(\n        put_dates_ns=_to_ns(puts[\"quotedate\"]),\n        put_expirations_ns=_to_ns(puts[\"expiration\"]),\n        put_strikes=puts[\"strike\"].values.astype(np.float64),\n        put_bids=puts[\"bid\"].values.astype(np.float64),\n        put_asks=puts[\"ask\"].values.astype(np.float64),\n        put_deltas=puts[\"delta\"].values.astype(np.float64),\n        put_underlying=puts[\"underlying_last\"].values.astype(np.float64),\n        put_dtes=puts[\"dte\"].values.astype(np.int32),\n        put_ivs=puts[\"impliedvol\"].values.astype(np.float64),\n        stock_dates_ns=_to_ns(stk_df[\"date\"]),\n        stock_prices=stk_df[\"adjClose\"].values.astype(np.float64),\n        initial_capital=config.initial_capital,\n        budget_pct=config.budget_pct,\n        target_delta=config.target_delta,\n        dte_min=config.dte_min,\n        dte_max=config.dte_max,\n        tail_drop=config.tail_drop,\n    )\n\n    # Build monthly records DataFrame\n    rec = result[\"records\"]\n    records = pd.DataFrame(\n        {\n            \"date\": pd.to_datetime(rec[\"dates_ns\"], unit=\"ns\"),\n            \"shares\": rec[\"shares\"],\n            \"stock_price\": rec[\"stock_prices\"],\n            \"equity_value\": rec[\"equity_values\"],\n            \"put_cost\": rec[\"put_costs\"],\n            \"put_exit_value\": rec[\"put_exit_values\"],\n            \"put_pnl\": rec[\"put_pnls\"],\n            \"portfolio_value\": rec[\"portfolio_values\"],\n            \"convexity_ratio\": rec[\"convexity_ratios\"],\n            \"strike\": rec[\"strikes\"],\n            \"contracts\": rec[\"contracts\"],\n        }\n    ).set_index(\"date\")\n\n    # Build daily balance DataFrame\n    daily = pd.DataFrame(\n        {\n            \"date\": pd.to_datetime(result[\"daily_dates_ns\"], unit=\"ns\"),\n            \"balance\": result[\"daily_balances\"],\n        }\n    ).set_index(\"date\")\n    daily[\"pct_change\"] = daily[\"balance\"].pct_change()\n\n    log.info(\n        \"Backtest: %d months, final value $%.0f (started $%.0f)\",\n        len(records),\n        daily[\"balance\"].iloc[-1] if len(daily) > 0 else 0,\n        config.initial_capital,\n    )\n\n    return BacktestResult(records=records, daily_balance=daily, config=config)\n\n\ndef run_unhedged(stocks_data, config: BacktestConfig) -> pd.DataFrame:\n    \"\"\"Run unhedged equity-only benchmark. Returns daily balance DataFrame.\"\"\"\n    stk_df = stocks_data._data.sort_values(\"date\")\n    if stk_df.empty:\n        return pd.DataFrame()\n\n    prices = stk_df[\"adjClose\"].values.astype(np.float64)\n    dates = stk_df[\"date\"]\n\n    initial_shares = config.initial_capital / prices[0]\n    daily_balance = initial_shares * prices\n\n    df = pd.DataFrame({\"date\": dates, \"balance\": daily_balance}).set_index(\"date\")\n    df[\"pct_change\"] = df[\"balance\"].pct_change()\n    return df\n"
  },
  {
    "path": "options_portfolio_backtester/convexity/config.py",
    "content": "\"\"\"Configuration: instrument registry and backtest parameters.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\n\n\n@dataclass(frozen=True)\nclass InstrumentConfig:\n    \"\"\"Configuration for a single instrument.\"\"\"\n\n    symbol: str\n    options_file: str\n    stocks_file: str\n    target_delta: float = -0.10\n    dte_min: int = 14\n    dte_max: int = 60\n    tail_drop: float = 0.20\n\n\n@dataclass(frozen=True)\nclass BacktestConfig:\n    \"\"\"Global backtest parameters.\"\"\"\n\n    initial_capital: float = 1_000_000.0\n    budget_pct: float = 0.005  # 0.5% of portfolio per month on puts\n    target_delta: float = -0.10\n    dte_min: int = 14\n    dte_max: int = 60\n    tail_drop: float = 0.20\n    instruments: list[InstrumentConfig] = field(default_factory=list)\n\n\ndef default_config(\n    options_file: str = \"data/processed/options.csv\",\n    stocks_file: str = \"data/processed/stocks.csv\",\n) -> BacktestConfig:\n    \"\"\"Default config with SPY only.\"\"\"\n    spy = InstrumentConfig(\n        symbol=\"SPY\",\n        options_file=options_file,\n        stocks_file=stocks_file,\n    )\n    return BacktestConfig(instruments=[spy])\n"
  },
  {
    "path": "options_portfolio_backtester/convexity/scoring.py",
    "content": "\"\"\"Scoring: compute convexity ratios via Rust backend.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\n\nimport numpy as np\nimport pandas as pd\n\nfrom .config import BacktestConfig\n\nlog = logging.getLogger(__name__)\n\n\ndef _to_ns(series: pd.Series) -> np.ndarray:\n    \"\"\"Convert a datetime Series to int64 nanosecond timestamps.\"\"\"\n    return series.values.astype(\"datetime64[ns]\").view(\"int64\").astype(np.int64)\n\n\ndef compute_convexity_scores(\n    options_data,\n    config: BacktestConfig,\n) -> pd.DataFrame:\n    \"\"\"Compute daily convexity ratio scores for an instrument.\n\n    Takes an HistoricalOptionsData object from options_backtester and\n    returns a DataFrame indexed by date with convexity_ratio and supporting fields.\n    \"\"\"\n    from options_portfolio_backtester._ob_rust import compute_daily_scores\n\n    df = options_data._data\n    puts = df[df[\"type\"] == \"put\"].sort_values(\"quotedate\")\n\n    if puts.empty:\n        return pd.DataFrame()\n\n    result = compute_daily_scores(\n        dates_ns=_to_ns(puts[\"quotedate\"]),\n        strikes=puts[\"strike\"].values.astype(np.float64),\n        bids=puts[\"bid\"].values.astype(np.float64),\n        asks=puts[\"ask\"].values.astype(np.float64),\n        deltas=puts[\"delta\"].values.astype(np.float64),\n        underlying_prices=puts[\"underlying_last\"].values.astype(np.float64),\n        dtes=puts[\"dte\"].values.astype(np.int32),\n        implied_vols=puts[\"impliedvol\"].values.astype(np.float64),\n        target_delta=config.target_delta,\n        dte_min=config.dte_min,\n        dte_max=config.dte_max,\n        tail_drop=config.tail_drop,\n    )\n\n    scores = pd.DataFrame(\n        {\n            \"date\": pd.to_datetime(result[\"dates_ns\"], unit=\"ns\"),\n            \"convexity_ratio\": result[\"convexity_ratios\"],\n            \"strike\": result[\"strikes\"],\n            \"ask\": result[\"asks\"],\n            \"bid\": result[\"bids\"],\n            \"delta\": result[\"deltas\"],\n            \"underlying_price\": result[\"underlying_prices\"],\n            \"implied_vol\": result[\"implied_vols\"],\n            \"dte\": result[\"dtes\"],\n            \"annual_cost\": result[\"annual_costs\"],\n            \"tail_payoff\": result[\"tail_payoffs\"],\n        }\n    ).set_index(\"date\")\n\n    log.info(\"Computed %d daily scores (%.1f years)\", len(scores), len(scores) / 252)\n\n    return scores\n"
  },
  {
    "path": "options_portfolio_backtester/convexity/viz.py",
    "content": "\"\"\"Visualization: Altair charts for scores, allocations, and P&L.\"\"\"\n\nfrom __future__ import annotations\n\nimport altair as alt\nimport pandas as pd\n\n\ndef convexity_scores_chart(scores_df: pd.DataFrame) -> alt.Chart:\n    \"\"\"Line chart of daily convexity ratios over time.\"\"\"\n    data = scores_df.reset_index()\n    return (\n        alt.Chart(data)\n        .mark_line()\n        .encode(\n            x=alt.X(\"date:T\", title=\"Date\"),\n            y=alt.Y(\"convexity_ratio:Q\", title=\"Convexity Ratio\"),\n            tooltip=[\"date:T\", \"convexity_ratio:Q\", \"strike:Q\", \"underlying_price:Q\", \"implied_vol:Q\"],\n        )\n        .properties(title=\"Daily Convexity Ratio\", width=800, height=300)\n    )\n\n\ndef monthly_pnl_chart(records: pd.DataFrame) -> alt.Chart:\n    \"\"\"Bar chart of monthly put P&L.\"\"\"\n    data = records.reset_index()\n    return (\n        alt.Chart(data)\n        .mark_bar()\n        .encode(\n            x=alt.X(\"date:T\", title=\"Date\"),\n            y=alt.Y(\"put_pnl:Q\", title=\"Put P&L ($)\"),\n            color=alt.condition(\n                alt.datum.put_pnl > 0,\n                alt.value(\"steelblue\"),\n                alt.value(\"salmon\"),\n            ),\n            tooltip=[\"date:T\", \"put_pnl:Q\", \"put_cost:Q\", \"put_exit_value:Q\", \"strike:Q\", \"contracts:Q\"],\n        )\n        .properties(title=\"Monthly Put P&L\", width=800, height=200)\n    )\n\n\ndef cumulative_pnl_chart(results: dict[str, pd.DataFrame]) -> alt.Chart:\n    \"\"\"Cumulative portfolio value for multiple strategies.\"\"\"\n    frames = []\n    for name, daily_df in results.items():\n        df = daily_df[[\"balance\"]].copy()\n        df[\"strategy\"] = name\n        frames.append(df.reset_index())\n\n    if not frames:\n        return alt.Chart(pd.DataFrame()).mark_line()\n\n    data = pd.concat(frames, ignore_index=True)\n\n    return (\n        alt.Chart(data)\n        .mark_line()\n        .encode(\n            x=alt.X(\"date:T\", title=\"Date\"),\n            y=alt.Y(\"balance:Q\", title=\"Portfolio Value ($)\", scale=alt.Scale(zero=False)),\n            color=alt.Color(\"strategy:N\", title=\"Strategy\"),\n            tooltip=[\"date:T\", \"balance:Q\", \"strategy:N\"],\n        )\n        .properties(title=\"Cumulative Portfolio Value\", width=800, height=400)\n    )\n"
  },
  {
    "path": "options_portfolio_backtester/core/__init__.py",
    "content": ""
  },
  {
    "path": "options_portfolio_backtester/core/types.py",
    "content": "\"\"\"Core domain types for options backtesting.\n\nDirection is decoupled from column names — use Direction.price_column instead of\nDirection.value to get the DataFrame column for pricing.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections import namedtuple\nfrom dataclasses import dataclass, field\nfrom enum import Enum\nfrom typing import Any\n\n\n# ---------------------------------------------------------------------------\n# Enums\n# ---------------------------------------------------------------------------\n\nclass OptionType(Enum):\n    CALL = \"call\"\n    PUT = \"put\"\n\n    def __invert__(self) -> OptionType:\n        return OptionType.PUT if self == OptionType.CALL else OptionType.CALL\n\n\nclass Direction(Enum):\n    \"\"\"Trade direction. price_column gives the DataFrame column name.\"\"\"\n    BUY = \"buy\"\n    SELL = \"sell\"\n\n    @property\n    def price_column(self) -> str:\n        \"\"\"Column name used for trade execution pricing.\"\"\"\n        return \"ask\" if self == Direction.BUY else \"bid\"\n\n    def __invert__(self) -> Direction:\n        return Direction.SELL if self == Direction.BUY else Direction.BUY\n\n\nclass Signal(Enum):\n    ENTRY = \"entry\"\n    EXIT = \"exit\"\n\n\nclass Order(Enum):\n    BTO = \"BTO\"  # Buy to Open\n    BTC = \"BTC\"  # Buy to Close\n    STO = \"STO\"  # Sell to Open\n    STC = \"STC\"  # Sell to Close\n\n    def __invert__(self) -> Order:\n        _inv = {Order.BTO: Order.STC, Order.STC: Order.BTO,\n                Order.STO: Order.BTC, Order.BTC: Order.STO}\n        return _inv[self]\n\n\ndef get_order(direction: Direction, signal: Signal) -> Order:\n    \"\"\"Map (direction, signal) to the appropriate Order type.\"\"\"\n    if direction == Direction.BUY:\n        return Order.BTO if signal == Signal.ENTRY else Order.STC\n    return Order.STO if signal == Signal.ENTRY else Order.BTC\n\n\n# ---------------------------------------------------------------------------\n# Value objects\n# ---------------------------------------------------------------------------\n\n@dataclass(frozen=True, slots=True)\nclass Greeks:\n    \"\"\"Option Greeks for a single contract or aggregated position.\n\n    Supports addition (aggregation) and scalar multiplication (scaling by qty).\n    \"\"\"\n    delta: float = 0.0\n    gamma: float = 0.0\n    theta: float = 0.0\n    vega: float = 0.0\n\n    def __add__(self, other: Greeks) -> Greeks:\n        return Greeks(\n            delta=self.delta + other.delta,\n            gamma=self.gamma + other.gamma,\n            theta=self.theta + other.theta,\n            vega=self.vega + other.vega,\n        )\n\n    def __mul__(self, scalar: float) -> Greeks:\n        return Greeks(\n            delta=self.delta * scalar,\n            gamma=self.gamma * scalar,\n            theta=self.theta * scalar,\n            vega=self.vega * scalar,\n        )\n\n    def __rmul__(self, scalar: float) -> Greeks:\n        return self.__mul__(scalar)\n\n    def __neg__(self) -> Greeks:\n        return self * -1.0\n\n    @property\n    def as_dict(self) -> dict[str, float]:\n        return {\"delta\": self.delta, \"gamma\": self.gamma,\n                \"theta\": self.theta, \"vega\": self.vega}\n\n\n@dataclass(frozen=True, slots=True)\nclass Fill:\n    \"\"\"A single execution fill.\n\n    Captures price, quantity, commission, slippage, and computes notional.\n    \"\"\"\n    price: float\n    quantity: int\n    direction: Direction\n    shares_per_contract: int = 100\n    commission: float = 0.0\n    slippage: float = 0.0\n\n    @property\n    def direction_sign(self) -> int:\n        return -1 if self.direction == Direction.BUY else 1\n\n    @property\n    def notional(self) -> float:\n        \"\"\"Net cash impact: direction_sign * (price * qty * spc) - commission - slippage.\"\"\"\n        raw = self.direction_sign * self.price * self.quantity * self.shares_per_contract\n        return raw - self.commission - self.slippage\n\n\n@dataclass(frozen=True, slots=True)\nclass OptionContract:\n    \"\"\"Identifies a specific option contract.\"\"\"\n    contract_id: str\n    underlying: str\n    expiration: Any  # pd.Timestamp or str\n    option_type: OptionType\n    strike: float\n\n\n# Re-use namedtuple for backward compatibility\nStockAllocation = namedtuple(\"StockAllocation\", \"symbol percentage\")\n\n# Backward-compatible aliases\nStock = StockAllocation\nType = OptionType\n"
  },
  {
    "path": "options_portfolio_backtester/data/__init__.py",
    "content": "\"\"\"Data module — schema and providers.\"\"\"\n"
  },
  {
    "path": "options_portfolio_backtester/data/providers.py",
    "content": "\"\"\"Data providers — ABCs, CSV implementations, and data loaders.\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom abc import ABC, abstractmethod\nfrom typing import Any, Union\n\nimport pandas as pd\n\nfrom .schema import Schema, Filter\n\n\nclass TiingoData:\n    \"\"\"Tiingo (stocks & indeces) Data container class.\"\"\"\n    def __init__(self, file: str, schema: Schema | None = None, **params: Any) -> None:\n        if schema is None:\n            self.schema = TiingoData.default_schema()\n\n        file_extension = os.path.splitext(file)[1]\n\n        if file_extension == '.h5':\n            self._data: pd.DataFrame = pd.read_hdf(file, **params)\n        elif file_extension == '.csv':\n            params['parse_dates'] = [self.schema.date.mapping]\n            self._data = pd.read_csv(file, **params)\n\n        columns = self._data.columns\n        assert all((col in columns for _key, col in self.schema))\n\n        date_col = self.schema['date']\n\n        self.start_date: pd.Timestamp = self._data[date_col].min()\n        self.end_date: pd.Timestamp = self._data[date_col].max()\n\n    def apply_filter(self, f: Filter) -> pd.DataFrame:\n        \"\"\"Apply Filter `f` to the data. Returns a `pd.DataFrame` with the filtered rows.\"\"\"\n        return self._data.query(f.query)\n\n    def iter_dates(self) -> pd.core.groupby.DataFrameGroupBy:\n        \"\"\"Returns `pd.DataFrameGroupBy` that groups stocks by date\"\"\"\n        return self._data.groupby(self.schema['date'])\n\n    def iter_months(self) -> pd.core.groupby.DataFrameGroupBy:\n        \"\"\"Returns `pd.DataFrameGroupBy` that groups stocks by month\"\"\"\n        date_col = self.schema['date']\n        first_date_per_month = (\n            self._data.groupby(self._data[date_col].dt.to_period('M'))[date_col]\n            .min()\n        )\n        mask = self._data[date_col].isin(first_date_per_month.values)\n        return self._data[mask].groupby(date_col)\n\n    def __getattr__(self, attr: str) -> Any:\n        \"\"\"Pass method invocation to `self._data`\"\"\"\n\n        method = getattr(self._data, attr)\n        if hasattr(method, '__call__'):\n\n            def df_method(*args: Any, **kwargs: Any) -> Any:\n                return method(*args, **kwargs)\n\n            return df_method\n        else:\n            return method\n\n    def __getitem__(self, item: Union[str, pd.Series]) -> Union[pd.DataFrame, pd.Series]:\n        if isinstance(item, pd.Series):\n            return self._data[item]\n        else:\n            key = self.schema[item]\n            return self._data[key]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self._data[key] = value\n        if key not in self.schema:\n            self.schema.update({key: key})\n\n    def __len__(self) -> int:\n        return len(self._data)\n\n    def __repr__(self) -> str:\n        return self._data.__repr__()\n\n    @staticmethod\n    def default_schema() -> Schema:\n        \"\"\"Returns default schema for Tiingo Data\"\"\"\n        return Schema.stocks()\n\n    def sma(self, periods: int) -> None:\n        sma = self._data.groupby('symbol', as_index=False).rolling(periods)['adjClose'].mean()\n        sma = sma.fillna(0)\n        sma.index = [index[1] for index in sma.index]\n        self._data['sma'] = sma\n        self.schema.update({'sma': 'sma'})\n\n\nclass HistoricalOptionsData:\n    \"\"\"Historical Options Data container class.\"\"\"\n    def __init__(self, file: str, schema: Schema | None = None, **params: Any) -> None:\n        if schema is None:\n            self.schema = HistoricalOptionsData.default_schema()\n\n        file_extension = os.path.splitext(file)[1]\n\n        if file_extension == '.h5':\n            self._data: pd.DataFrame = pd.read_hdf(file, **params)\n        elif file_extension == '.csv':\n            params['parse_dates'] = [self.schema.expiration.mapping, self.schema.date.mapping]\n            self._data = pd.read_csv(file, **params)\n\n        columns = self._data.columns\n        assert all((col in columns for _key, col in self.schema))\n\n        date_col = self.schema['date']\n        expiration_col = self.schema['expiration']\n\n        self._data['dte'] = (self._data[expiration_col] - self._data[date_col]).dt.days\n        self.schema.update({'dte': 'dte'})\n\n        self.start_date: pd.Timestamp = self._data[date_col].min()\n        self.end_date: pd.Timestamp = self._data[date_col].max()\n\n    def apply_filter(self, f: Filter) -> pd.DataFrame:\n        \"\"\"Apply Filter `f` to the data. Returns a `pd.DataFrame` with the filtered rows.\"\"\"\n        return self._data.query(f.query)\n\n    def iter_dates(self) -> pd.core.groupby.DataFrameGroupBy:\n        \"\"\"Returns `pd.DataFrameGroupBy` that groups contracts by date\"\"\"\n        return self._data.groupby(self.schema['date'])\n\n    def iter_months(self) -> pd.core.groupby.DataFrameGroupBy:\n        \"\"\"Returns `pd.DataFrameGroupBy` that groups contracts by month\"\"\"\n        date_col = self.schema['date']\n        first_date_per_month = (\n            self._data.groupby(self._data[date_col].dt.to_period('M'))[date_col]\n            .min()\n        )\n        mask = self._data[date_col].isin(first_date_per_month.values)\n        return self._data[mask].groupby(date_col)\n\n    def __getattr__(self, attr: str) -> Any:\n        \"\"\"Pass method invocation to `self._data`\"\"\"\n\n        method = getattr(self._data, attr)\n        if hasattr(method, '__call__'):\n\n            def df_method(*args: Any, **kwargs: Any) -> Any:\n                return method(*args, **kwargs)\n\n            return df_method\n        else:\n            return method\n\n    def __getitem__(self, item: Union[str, pd.Series]) -> Union[pd.DataFrame, pd.Series]:\n        if isinstance(item, pd.Series):\n            return self._data[item]\n        else:\n            key = self.schema[item]\n            return self._data[key]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self._data[key] = value\n        if key not in self.schema:\n            self.schema.update({key: key})\n\n    def __len__(self) -> int:\n        return len(self._data)\n\n    def __repr__(self) -> str:\n        return self._data.__repr__()\n\n    @staticmethod\n    def default_schema() -> Schema:\n        \"\"\"Returns default schema for Historical Options Data\"\"\"\n        schema = Schema.options()\n        schema.update({\n            'contract': 'optionroot',\n            'date': 'quotedate',\n            'last': 'last',\n            'open_interest': 'openinterest',\n            'impliedvol': 'impliedvol',\n            'delta': 'delta',\n            'gamma': 'gamma',\n            'theta': 'theta',\n            'vega': 'vega'\n        })\n        return schema\n\n\n# ---------------------------------------------------------------------------\n# Abstract base classes\n# ---------------------------------------------------------------------------\n\nclass DataProvider(ABC):\n    \"\"\"Base interface for all data providers.\"\"\"\n\n    @property\n    @abstractmethod\n    def schema(self) -> Schema:\n        ...\n\n    @property\n    @abstractmethod\n    def data(self) -> pd.DataFrame:\n        ...\n\n    @property\n    @abstractmethod\n    def start_date(self) -> pd.Timestamp:\n        ...\n\n    @property\n    @abstractmethod\n    def end_date(self) -> pd.Timestamp:\n        ...\n\n    @abstractmethod\n    def apply_filter(self, f: Filter) -> pd.DataFrame:\n        ...\n\n    @abstractmethod\n    def iter_dates(self) -> Any:\n        ...\n\n    @abstractmethod\n    def iter_months(self) -> Any:\n        ...\n\n\nclass OptionsDataProvider(DataProvider):\n    \"\"\"Options-specific data provider interface.\"\"\"\n    pass\n\n\nclass StocksDataProvider(DataProvider):\n    \"\"\"Stocks-specific data provider interface.\"\"\"\n\n    @abstractmethod\n    def sma(self, periods: int) -> None:\n        ...\n\n\n# ---------------------------------------------------------------------------\n# CSV implementations (wrap existing loaders)\n# ---------------------------------------------------------------------------\n\nclass CsvOptionsProvider(OptionsDataProvider):\n    \"\"\"Load options data from CSV files using the existing HistoricalOptionsData loader.\"\"\"\n\n    def __init__(self, file: str, schema: Schema | None = None, **params: Any) -> None:\n        self._loader = HistoricalOptionsData(file, schema=schema, **params)\n\n    @property\n    def schema(self) -> Schema:\n        return self._loader.schema\n\n    @property\n    def data(self) -> pd.DataFrame:\n        return self._loader._data\n\n    @property\n    def start_date(self) -> pd.Timestamp:\n        return self._loader.start_date\n\n    @property\n    def end_date(self) -> pd.Timestamp:\n        return self._loader.end_date\n\n    def apply_filter(self, f: Filter) -> pd.DataFrame:\n        return self._loader.apply_filter(f)\n\n    def iter_dates(self) -> Any:\n        return self._loader.iter_dates()\n\n    def iter_months(self) -> Any:\n        return self._loader.iter_months()\n\n    def __getitem__(self, item: Any) -> Any:\n        return self._loader[item]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self._loader[key] = value\n\n    def __len__(self) -> int:\n        return len(self._loader)\n\n    @property\n    def _data(self) -> pd.DataFrame:\n        \"\"\"Access to underlying DataFrame.\"\"\"\n        return self._loader._data\n\n\nclass CsvStocksProvider(StocksDataProvider):\n    \"\"\"Load stock data from CSV files using the existing TiingoData loader.\"\"\"\n\n    def __init__(self, file: str, schema: Schema | None = None, **params: Any) -> None:\n        self._loader = TiingoData(file, schema=schema, **params)\n\n    @property\n    def schema(self) -> Schema:\n        return self._loader.schema\n\n    @property\n    def data(self) -> pd.DataFrame:\n        return self._loader._data\n\n    @property\n    def start_date(self) -> pd.Timestamp:\n        return self._loader.start_date\n\n    @property\n    def end_date(self) -> pd.Timestamp:\n        return self._loader.end_date\n\n    def apply_filter(self, f: Filter) -> pd.DataFrame:\n        return self._loader.apply_filter(f)\n\n    def iter_dates(self) -> Any:\n        return self._loader.iter_dates()\n\n    def iter_months(self) -> Any:\n        return self._loader.iter_months()\n\n    def sma(self, periods: int) -> None:\n        self._loader.sma(periods)\n\n    def __getitem__(self, item: Any) -> Any:\n        return self._loader[item]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self._loader[key] = value\n\n    def __len__(self) -> int:\n        return len(self._loader)\n\n    @property\n    def _data(self) -> pd.DataFrame:\n        \"\"\"Access to underlying DataFrame.\"\"\"\n        return self._loader._data\n"
  },
  {
    "path": "options_portfolio_backtester/data/schema.py",
    "content": "\"\"\"Filter DSL — Schema, Field, and Filter for building query expressions.\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import Any, Iterator, Union\n\n\nclass Schema:\n    \"\"\"Data schema class.\n    Used provide uniform access to fields in the data set.\n    \"\"\"\n\n    stock_columns = [\n        \"symbol\", \"date\", \"open\", \"close\", \"high\", \"low\", \"volume\", \"adjClose\", \"adjHigh\", \"adjLow\", \"adjOpen\",\n        \"adjVolume\", \"divCash\", \"splitFactor\"\n    ]\n\n    option_columns = [\n        \"underlying\", \"underlying_last\", \"date\", \"contract\", \"type\", \"expiration\", \"strike\", \"bid\", \"ask\", \"volume\",\n        \"open_interest\"\n    ]\n\n    @staticmethod\n    def stocks() -> Schema:\n        \"\"\"Builder method that returns a `Schema` with default mappings for stocks\"\"\"\n        mappings = {key: key for key in Schema.stock_columns}\n        return Schema(mappings)\n\n    @staticmethod\n    def options() -> Schema:\n        \"\"\"Builder method that returns a `Schema` with default mappings for options\"\"\"\n        mappings = {key: key for key in Schema.option_columns}\n        return Schema(mappings)\n\n    def __init__(self, mappings: dict[str, str]) -> None:\n        assert all((key in mappings for key in Schema.stock_columns)) or all(\n            (key in mappings for key in Schema.option_columns))\n\n        self._mappings: dict[str, str] = mappings\n\n    def update(self, mappings: dict[str, str]) -> Schema:\n        \"\"\"Update schema according to given `mappings`\"\"\"\n        self._mappings.update(mappings)\n        return self\n\n    def __contains__(self, key: str) -> bool:\n        \"\"\"Returns True if key is in schema\"\"\"\n        return key in self._mappings.keys()\n\n    def __getattr__(self, key: str) -> Field:\n        \"\"\"Returns Field object used to build Filters\"\"\"\n        return Field(key, self._mappings[key])\n\n    def __setitem__(self, key: str, value: str) -> None:\n        self._mappings[key] = value\n\n    def __getitem__(self, key: str) -> str:\n        \"\"\"Returns mapping of given `key`\"\"\"\n        return self._mappings[key]\n\n    def __iter__(self) -> Iterator[tuple[str, str]]:\n        return iter(self._mappings.items())\n\n    def __repr__(self) -> str:\n        return \"Schema({})\".format([Field(k, m) for k, m in self._mappings.items()])\n\n    def __eq__(self, other: object) -> bool:\n        if not isinstance(other, Schema):\n            return NotImplemented\n        return self._mappings == other._mappings\n\n\nclass Field:\n    \"\"\"Encapsulates data fields to build filters used by strategies\"\"\"\n\n    __slots__ = (\"name\", \"mapping\")\n\n    def __init__(self, name: str, mapping: str) -> None:\n        self.name = name\n        self.mapping = mapping\n\n    def _create_filter(self, op: str, other: Union[Field, Any]) -> Filter:\n        if isinstance(other, Field):\n\n            query = Field._format_query(self.mapping, op, other.mapping)\n        else:\n            query = Field._format_query(self.mapping, op, other)\n        return Filter(query)\n\n    def _combine_fields(self, op: str, other: Union[Field, int, float], invert: bool = False) -> Field:\n        if isinstance(other, Field):\n            name = Field._format_query(self.name, op, other.name, invert)\n            mapping = Field._format_query(self.mapping, op, other.mapping, invert)\n        elif isinstance(other, (int, float)):\n            name = Field._format_query(self.name, op, other, invert)\n            mapping = Field._format_query(self.mapping, op, other, invert)\n        else:\n            raise TypeError\n\n        return Field(name, mapping)\n\n    @staticmethod\n    def _format_query(left: Any, op: str, right: Any, invert: bool = False) -> str:\n        if invert:\n            left, right = right, left\n        query = \"{left} {op} {right}\".format(left=left, op=op, right=right)\n        return query\n\n    def __add__(self, value: Union[Field, int, float]) -> Field:\n        return self._combine_fields(\"+\", value)\n\n    def __radd__(self, value: Union[Field, int, float]) -> Field:\n        return self._combine_fields(\"+\", value, invert=True)\n\n    def __sub__(self, value: Union[Field, int, float]) -> Field:\n        return self._combine_fields(\"-\", value)\n\n    def __rsub__(self, value: Union[Field, int, float]) -> Field:\n        return self._combine_fields(\"-\", value, invert=True)\n\n    def __mul__(self, value: Union[Field, int, float]) -> Field:\n        return self._combine_fields(\"*\", value)\n\n    def __rmul__(self, value: Union[Field, int, float]) -> Field:\n        return self._combine_fields(\"*\", value, invert=True)\n\n    def __truediv__(self, value: Union[Field, int, float]) -> Field:\n        return self._combine_fields(\"/\", value)\n\n    def __rtruediv__(self, value: Union[Field, int, float]) -> Field:\n        return self._combine_fields(\"/\", value, invert=True)\n\n    def __lt__(self, value: Union[Field, Any]) -> Filter:\n        return self._create_filter(\"<\", value)\n\n    def __le__(self, value: Union[Field, Any]) -> Filter:\n        return self._create_filter(\"<=\", value)\n\n    def __gt__(self, value: Union[Field, Any]) -> Filter:\n        return self._create_filter(\">\", value)\n\n    def __ge__(self, value: Union[Field, Any]) -> Filter:\n        return self._create_filter(\">=\", value)\n\n    def __eq__(self, value: Union[Field, Any]) -> Filter:  # type: ignore[override]\n        if isinstance(value, str):\n            value = \"'{}'\".format(value)\n        return self._create_filter(\"==\", value)\n\n    def __ne__(self, value: Union[Field, Any]) -> Filter:  # type: ignore[override]\n        return self._create_filter(\"!=\", value)\n\n    def __repr__(self) -> str:\n        return \"Field(name='{}', mapping='{}')\".format(self.name, self.mapping)\n\n\nclass Filter:\n    \"\"\"This class determines entry/exit conditions for strategies\"\"\"\n\n    __slots__ = (\"query\")\n\n    def __init__(self, query: str) -> None:\n        self.query = query\n\n    def __and__(self, other: Filter) -> Filter:\n        \"\"\"Returns logical *and* between `self` and `other`\"\"\"\n        assert isinstance(other, Filter)\n        new_query = \"({}) & ({})\".format(self.query, other.query)\n        return Filter(query=new_query)\n\n    def __or__(self, other: Filter) -> Filter:\n        \"\"\"Returns logical *or* between `self` and `other`\"\"\"\n        assert isinstance(other, Filter)\n        new_query = \"(({}) | ({}))\".format(self.query, other.query)\n        return Filter(query=new_query)\n\n    def __invert__(self) -> Filter:\n        \"\"\"Negates filter\"\"\"\n        return Filter(\"!({})\".format(self.query))\n\n    def __call__(self, data: 'pd.DataFrame') -> 'pd.Series':\n        \"\"\"Returns dataframe of filtered data\"\"\"\n        return data.eval(self.query)\n\n    def __repr__(self) -> str:\n        return \"Filter(query='{}')\".format(self.query)\n\n\n__all__ = [\"Schema\", \"Field\", \"Filter\"]\n"
  },
  {
    "path": "options_portfolio_backtester/engine/__init__.py",
    "content": ""
  },
  {
    "path": "options_portfolio_backtester/engine/algo_adapters.py",
    "content": "\"\"\"Algo adapter layer to drive BacktestEngine with bt-style pipeline blocks.\"\"\"\n\nfrom __future__ import annotations\n\nimport math\nfrom dataclasses import dataclass, field\nfrom typing import Literal, Protocol\n\nimport pandas as pd\n\nfrom options_portfolio_backtester.core.types import Greeks\n\n\nStepStatus = Literal[\"continue\", \"skip_day\", \"stop\"]\n\n\n@dataclass(frozen=True)\nclass EngineStepDecision:\n    \"\"\"Decision emitted by one engine-algo step.\"\"\"\n\n    status: StepStatus = \"continue\"\n    message: str = \"\"\n\n\n@dataclass\nclass EnginePipelineContext:\n    \"\"\"Mutable run context shared by all engine algo steps for one rebalance date.\"\"\"\n\n    date: pd.Timestamp\n    stocks: pd.DataFrame\n    options: pd.DataFrame\n    total_capital: float\n    current_cash: float\n    current_greeks: Greeks\n    options_allocation: float\n    entry_filters: list = field(default_factory=list)\n    exit_threshold_override: tuple[float, float] | None = None\n\n\nclass EngineAlgo(Protocol):\n    def __call__(self, ctx: EnginePipelineContext) -> EngineStepDecision:\n        ...\n\n\nclass EngineRunMonthly:\n    \"\"\"Allow rebalances only on first rebalance day per month.\"\"\"\n\n    def __init__(self) -> None:\n        self._last_month: tuple[int, int] | None = None\n\n    def reset(self) -> None:\n        self._last_month = None\n\n    def __call__(self, ctx: EnginePipelineContext) -> EngineStepDecision:\n        key = (ctx.date.year, ctx.date.month)\n        if self._last_month == key:\n            return EngineStepDecision(status=\"skip_day\", message=\"not month-start\")\n        self._last_month = key\n        return EngineStepDecision()\n\n\nclass BudgetPercent:\n    \"\"\"Set options allocation budget as percent of current total capital.\"\"\"\n\n    def __init__(self, pct: float) -> None:\n        self.pct = float(pct)\n\n    def __call__(self, ctx: EnginePipelineContext) -> EngineStepDecision:\n        ctx.options_allocation = max(0.0, float(ctx.total_capital) * self.pct)\n        return EngineStepDecision()\n\n\nclass RangeFilter:\n    \"\"\"Keep contracts where *column* falls within [min_val, max_val].\n\n    Generic building block — use directly or via the convenience aliases\n    ``SelectByDelta``, ``SelectByDTE``, ``IVRankFilter``.\n    \"\"\"\n\n    def __init__(self, column: str, min_val: float, max_val: float) -> None:\n        self.column = column\n        self.min_val = float(min_val)\n        self.max_val = float(max_val)\n\n    def __call__(self, ctx: EnginePipelineContext) -> EngineStepDecision:\n        lo, hi, col = self.min_val, self.max_val, self.column\n\n        def _flt(df: pd.DataFrame) -> pd.Series:\n            if col not in df.columns:\n                return pd.Series(True, index=df.index)\n            v = df[col]\n            return (v >= lo) & (v <= hi)\n\n        ctx.entry_filters.append(_flt)\n        return EngineStepDecision()\n\n\ndef SelectByDelta(min_delta: float = -1.0, max_delta: float = 1.0, column: str = \"delta\") -> RangeFilter:\n    \"\"\"Keep contracts with delta within [min_delta, max_delta].\"\"\"\n    return RangeFilter(column=column, min_val=min_delta, max_val=max_delta)\n\n\ndef SelectByDTE(min_dte: int = 0, max_dte: int = 10_000, column: str = \"dte\") -> RangeFilter:\n    \"\"\"Keep contracts with DTE within [min_dte, max_dte].\"\"\"\n    return RangeFilter(column=column, min_val=float(min_dte), max_val=float(max_dte))\n\n\ndef IVRankFilter(min_rank: float = 0.0, max_rank: float = 1.0, column: str = \"iv_rank\") -> RangeFilter:\n    \"\"\"Keep contracts with IV rank within [min_rank, max_rank].\"\"\"\n    return RangeFilter(column=column, min_val=min_rank, max_val=max_rank)\n\n\nclass MaxGreekExposure:\n    \"\"\"Skip new entries when current absolute greek exposure exceeds limits.\"\"\"\n\n    def __init__(\n        self,\n        max_abs_delta: float | None = None,\n        max_abs_vega: float | None = None,\n    ) -> None:\n        self.max_abs_delta = float(max_abs_delta) if max_abs_delta is not None else None\n        self.max_abs_vega = float(max_abs_vega) if max_abs_vega is not None else None\n\n    def __call__(self, ctx: EnginePipelineContext) -> EngineStepDecision:\n        if self.max_abs_delta is not None and abs(float(ctx.current_greeks.delta)) > self.max_abs_delta:\n            return EngineStepDecision(\n                status=\"skip_day\",\n                message=f\"|delta|>{self.max_abs_delta}\",\n            )\n        if self.max_abs_vega is not None and abs(float(ctx.current_greeks.vega)) > self.max_abs_vega:\n            return EngineStepDecision(\n                status=\"skip_day\",\n                message=f\"|vega|>{self.max_abs_vega}\",\n            )\n        return EngineStepDecision()\n\n\nclass ExitOnThreshold:\n    \"\"\"Override strategy exit profit/loss thresholds for this run.\n\n    At least one of *profit_pct* or *loss_pct* must be finite, otherwise the\n    algo is a no-op and likely a caller mistake.\n    \"\"\"\n\n    def __init__(self, profit_pct: float = float(\"inf\"), loss_pct: float = float(\"inf\")) -> None:\n        self.profit_pct = float(profit_pct)\n        self.loss_pct = float(loss_pct)\n        if math.isinf(self.profit_pct) and math.isinf(self.loss_pct):\n            import warnings\n            warnings.warn(\n                \"ExitOnThreshold created with both thresholds infinite — \"\n                \"exit overrides will have no effect\",\n                stacklevel=2,\n            )\n\n    def __call__(self, ctx: EnginePipelineContext) -> EngineStepDecision:\n        ctx.exit_threshold_override = (self.profit_pct, self.loss_pct)\n        return EngineStepDecision()\n"
  },
  {
    "path": "options_portfolio_backtester/engine/clock.py",
    "content": "\"\"\"Trading clock — date iteration and rebalance scheduling.\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import Generator\n\nimport pandas as pd\n\n\nclass TradingClock:\n    \"\"\"Generates (date, stocks_df, options_df) tuples for the backtest loop.\n\n    Handles daily/monthly iteration and rebalance scheduling.\n    \"\"\"\n\n    def __init__(\n        self,\n        stocks_data: pd.DataFrame,\n        options_data: pd.DataFrame,\n        stocks_date_col: str = \"date\",\n        options_date_col: str = \"quotedate\",\n        monthly: bool = False,\n    ) -> None:\n        self.stocks_data = stocks_data\n        self.options_data = options_data\n        self.stocks_date_col = stocks_date_col\n        self.options_date_col = options_date_col\n        self.monthly = monthly\n\n    def iter_dates(self) -> Generator[tuple[pd.Timestamp, pd.DataFrame, pd.DataFrame], None, None]:\n        \"\"\"Iterate over trading dates, yielding (date, stocks, options) per step.\"\"\"\n        if self.monthly:\n            stocks_iter = self._monthly_iter(self.stocks_data, self.stocks_date_col)\n            options_iter = self._monthly_iter(self.options_data, self.options_date_col)\n        else:\n            stocks_iter = self.stocks_data.groupby(self.stocks_date_col)\n            options_iter = self.options_data.groupby(self.options_date_col)\n\n        for (date, stocks), (_, options) in zip(stocks_iter, options_iter):\n            yield date, stocks, options\n\n    def rebalance_dates(self, freq: int) -> pd.DatetimeIndex:\n        \"\"\"Compute rebalance dates using business-month-start frequency.\n\n        Args:\n            freq: Number of business months between rebalances.\n\n        Returns:\n            DatetimeIndex of rebalance dates present in the data.\n        \"\"\"\n        if freq <= 0:\n            return pd.DatetimeIndex([])\n\n        dates = pd.DataFrame(\n            self.options_data[[self.options_date_col, \"volume\"]]\n        ).drop_duplicates(self.options_date_col).set_index(self.options_date_col)\n\n        return pd.to_datetime(\n            dates.groupby(pd.Grouper(freq=f\"{freq}BMS\"))\n            .apply(lambda x: x.index.min())\n            .values\n        )\n\n    @staticmethod\n    def _monthly_iter(data: pd.DataFrame, date_col: str):\n        first_date_per_month = (\n            data.groupby(data[date_col].dt.to_period('M'))[date_col]\n            .min()\n        )\n        mask = data[date_col].isin(first_date_per_month.values)\n        return data[mask].groupby(date_col)\n\n    @property\n    def all_dates(self) -> pd.DatetimeIndex:\n        return pd.DatetimeIndex(self.options_data[self.options_date_col].unique())\n"
  },
  {
    "path": "options_portfolio_backtester/engine/engine.py",
    "content": "\"\"\"BacktestEngine — thin orchestrator composing all framework components.\n\nReplaces the monolithic Backtest class with a clean composition of:\n- Data providers (stocks, options)\n- Strategy (legs, filters, thresholds)\n- Execution (cost model, fill model, sizer, signal selector)\n- Portfolio (positions, cash, holdings)\n- Risk management (constraints)\n- Analytics (trade log, balance sheet)\n\"\"\"\n\nfrom __future__ import annotations\n\nimport hashlib\nimport json\nimport logging\nimport subprocess\nfrom dataclasses import dataclass, field\nfrom datetime import datetime, timezone\nfrom pathlib import Path\nfrom typing import Any\n\nimport numpy as np\nimport pandas as pd\n\nfrom options_portfolio_backtester.core.types import (\n    Direction, OptionType, Order, Signal, Greeks, Stock, StockAllocation,\n    get_order,\n)\nfrom options_portfolio_backtester.execution.cost_model import TransactionCostModel, NoCosts\nfrom options_portfolio_backtester.execution.fill_model import FillModel, MarketAtBidAsk\nfrom options_portfolio_backtester.execution.sizer import PositionSizer, CapitalBased\nfrom options_portfolio_backtester.execution.signal_selector import SignalSelector, FirstMatch\nfrom options_portfolio_backtester.portfolio.risk import RiskManager\nfrom options_portfolio_backtester.portfolio.portfolio import Portfolio\nfrom options_portfolio_backtester import _ob_rust\nfrom options_portfolio_backtester.engine.algo_adapters import (\n    EngineAlgo,\n    EnginePipelineContext,\n)\n\nfrom options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData\nfrom options_portfolio_backtester.data.schema import Schema\nfrom options_portfolio_backtester.strategy.strategy import Strategy\nfrom options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\n\nlogger = logging.getLogger(__name__)\n\n\ndef _intrinsic_value(option_type: str, strike: float, underlying_price: float) -> float:\n    \"\"\"Compute intrinsic value of an option given spot price.\n\n    For puts:  max(0, strike - spot)\n    For calls: max(0, spot - strike)\n    \"\"\"\n    if option_type == OptionType.CALL.value:\n        return max(0.0, underlying_price - strike)\n    return max(0.0, strike - underlying_price)\n\n\n@dataclass\nclass _StrategySlot:\n    \"\"\"Configuration and runtime state for one strategy within a multi-strategy engine.\"\"\"\n    strategy: Strategy\n    weight: float\n    rebalance_freq: int\n    rebalance_unit: str = 'BMS'\n    check_exits_daily: bool = False\n    name: str = \"\"\n    inventory: pd.DataFrame = field(default=None, repr=False)\n    rebalance_dates: pd.DatetimeIndex = field(default=None, repr=False)\n\n\nclass BacktestEngine:\n    \"\"\"Orchestrates backtest with pluggable execution components.\n\n    Composes data providers, strategy legs, cost/fill/sizer/selector models,\n    and risk constraints into a single backtest loop.  Dispatches to Rust\n    for all supported configurations.\n    \"\"\"\n\n    def __init__(\n        self,\n        allocation: dict[str, float],\n        initial_capital: int = 1_000_000,\n        shares_per_contract: int = 100,\n        cost_model: TransactionCostModel | None = None,\n        fill_model: FillModel | None = None,\n        sizer: PositionSizer | None = None,\n        signal_selector: SignalSelector | None = None,\n        risk_manager: RiskManager | None = None,\n        algos: list[EngineAlgo] | None = None,\n        stop_if_broke: bool = False,\n        max_notional_pct: float | None = None,\n    ) -> None:\n        assets = (\"stocks\", \"options\", \"cash\")\n        self._raw_allocation = {a: allocation.get(a, 0.0) for a in assets}\n        total_allocation = sum(self._raw_allocation.values())\n\n        self.allocation: dict[str, float] = {}\n        for asset in assets:\n            self.allocation[asset] = self._raw_allocation[asset] / total_allocation\n\n        self.initial_capital = initial_capital\n        self.shares_per_contract = shares_per_contract\n        self.cost_model = cost_model or NoCosts()\n        self.fill_model = fill_model or MarketAtBidAsk()\n        self.sizer = sizer or CapitalBased()\n        self.signal_selector = signal_selector or FirstMatch()\n        self.risk_manager = risk_manager or RiskManager()\n        self.algos = list(algos or [])\n        self.stop_if_broke = stop_if_broke\n        self.max_notional_pct = max_notional_pct\n\n        self.options_budget_pct: float | None = None\n        self.options_budget_annual_pct: float | None = None\n        self._stocks: list[Stock] = []\n        self._options_strategy: Strategy | None = None\n        self._stocks_data: TiingoData | None = None\n        self._options_data: HistoricalOptionsData | None = None\n        self.run_metadata: dict[str, Any] = {}\n        self._event_log_rows: list[dict[str, Any]] = []\n\n    # -- Properties (same API as original Backtest) --\n\n    @property\n    def stocks(self) -> list[Stock]:\n        return self._stocks\n\n    @stocks.setter\n    def stocks(self, stocks: list[Stock]) -> None:\n        assert np.isclose(sum(s.percentage for s in stocks), 1.0, atol=1e-6)\n        self._stocks = list(stocks)\n\n    @property\n    def options_strategy(self) -> Strategy | None:\n        return self._options_strategy\n\n    @options_strategy.setter\n    def options_strategy(self, strat: Strategy) -> None:\n        self._options_strategy = strat\n\n    @property\n    def stocks_data(self) -> TiingoData | None:\n        return self._stocks_data\n\n    @stocks_data.setter\n    def stocks_data(self, data: TiingoData) -> None:\n        self._stocks_schema = data.schema\n        self._stocks_data = data\n\n    @property\n    def options_data(self) -> HistoricalOptionsData | None:\n        return self._options_data\n\n    @options_data.setter\n    def options_data(self, data: HistoricalOptionsData) -> None:\n        self._options_schema = data.schema\n        self._options_data = data\n\n    # -- Multi-strategy API --\n\n    def add_strategy(\n        self,\n        strategy: Strategy,\n        weight: float,\n        rebalance_freq: int,\n        rebalance_unit: str = 'BMS',\n        check_exits_daily: bool = False,\n        name: str | None = None,\n    ) -> None:\n        \"\"\"Register a strategy slot for multi-strategy mode.\n\n        Args:\n            strategy: The Strategy object (legs + exit thresholds).\n            weight: Fraction of options allocation for this strategy.\n            rebalance_freq: Rebalance every N periods.\n            rebalance_unit: Pandas offset alias (default 'BMS').\n            check_exits_daily: Check exits on non-rebalance days.\n            name: Human-readable name (auto-generated if omitted).\n        \"\"\"\n        if not hasattr(self, '_strategy_slots'):\n            self._strategy_slots: list[_StrategySlot] = []\n        slot_name = name or f\"strategy_{len(self._strategy_slots)}\"\n        self._strategy_slots.append(_StrategySlot(\n            strategy=strategy,\n            weight=weight,\n            rebalance_freq=rebalance_freq,\n            rebalance_unit=rebalance_unit,\n            check_exits_daily=check_exits_daily,\n            name=slot_name,\n        ))\n\n    @property\n    def _is_multi_strategy(self) -> bool:\n        return hasattr(self, '_strategy_slots') and len(self._strategy_slots) > 0\n\n    # -- Main entry point --\n\n    def run(self, rebalance_freq: int = 0, monthly: bool = False,\n            sma_days: int | None = None,\n            rebalance_unit: str = 'BMS',\n            check_exits_daily: bool = False) -> pd.DataFrame:\n        \"\"\"Run the backtest. Returns the trade log DataFrame.\n\n        Args:\n            check_exits_daily: When True, evaluate exit filters on every trading\n                day (not just rebalancing days).  Positions that match the exit\n                filter are closed and cash is updated, but no new entries or\n                stock reallocation occurs outside rebalancing days.\n        \"\"\"\n        self._event_log_rows = []\n        for algo in self.algos:\n            if hasattr(algo, \"reset\"):\n                algo.reset()\n        assert self._stocks_data, \"Stock data not set\"\n        assert all(\n            stock.symbol in self._stocks_data[\"symbol\"].values\n            for stock in self._stocks\n        ), \"Ensure all stocks in portfolio are present in the data\"\n        assert self._options_data, \"Options data not set\"\n\n        # Multi-strategy mode\n        if self._is_multi_strategy:\n            total_weight = sum(s.weight for s in self._strategy_slots)\n            assert abs(total_weight - 1.0) < 1e-6, (\n                f\"Strategy weights must sum to 1.0, got {total_weight}\"\n            )\n            for slot in self._strategy_slots:\n                assert self._options_data.schema == slot.strategy.schema\n            return self._run_rust_multi(\n                monthly=monthly, sma_days=sma_days,\n                check_exits_daily=check_exits_daily,\n            )\n\n        assert self._options_strategy, \"Options Strategy not set\"\n        assert self._options_data.schema == self._options_strategy.schema\n\n        option_dates = self._options_data[\"date\"].unique()\n        stock_dates = self.stocks_data[\"date\"].unique()\n        assert np.array_equal(stock_dates, option_dates)\n\n        # Translate algos to Rust-compatible config fields before dispatch.\n        if self.algos:\n            self._translate_algos_to_config()\n\n        return self._run_rust(\n            rebalance_freq,\n            monthly=monthly,\n            sma_days=sma_days,\n            rebalance_unit=rebalance_unit,\n            check_exits_daily=check_exits_daily,\n        )\n\n    def events_dataframe(self) -> pd.DataFrame:\n        \"\"\"Structured execution event log for debugging and audit.\n\n        The ``data`` dict from each event is flattened into top-level columns\n        so that the result can be filtered directly (e.g.\n        ``df[df[\"cash\"] > 0]``).\n        \"\"\"\n        if not self._event_log_rows:\n            return pd.DataFrame(columns=[\"date\", \"event\", \"status\"])\n        flat = []\n        for row in self._event_log_rows:\n            entry = {\"date\": row[\"date\"], \"event\": row[\"event\"], \"status\": row[\"status\"]}\n            entry.update(row.get(\"data\", {}))\n            flat.append(entry)\n        return pd.DataFrame(flat)\n\n    def _translate_algos_to_config(self) -> None:\n        \"\"\"Translate algo pipeline into Rust-compatible engine config fields.\n\n        Each algo type maps to an existing Rust feature:\n          - EngineRunMonthly → rebalance_unit='BMS' + rebalance_freq=1 (already handled)\n          - BudgetPercent → options_budget_pct\n          - RangeFilter/SelectByDelta/SelectByDTE/IVRankFilter → entry filter conjunction\n          - MaxGreekExposure → risk_constraints (MaxDelta/MaxVega)\n          - ExitOnThreshold → profit_pct/loss_pct on strategy\n\n        After translation, self.algos is cleared so the Rust gate passes.\n        \"\"\"\n        from options_portfolio_backtester.engine.algo_adapters import (\n            EngineRunMonthly, BudgetPercent, RangeFilter,\n            MaxGreekExposure, ExitOnThreshold,\n        )\n        from options_portfolio_backtester.portfolio.risk import RiskManager\n\n        for algo in self.algos:\n            if isinstance(algo, EngineRunMonthly):\n                # Already handled by rebalance_unit='BMS' + rebalance_freq=1.\n                # If user set algos=[EngineRunMonthly()], it's a no-op for Rust.\n                pass\n            elif isinstance(algo, BudgetPercent):\n                self.options_budget_pct = algo.pct\n            elif isinstance(algo, RangeFilter):\n                # Append range condition to each leg's entry filter as conjunction.\n                col, lo, hi = algo.column, algo.min_val, algo.max_val\n                clause = f\"({col} >= {lo}) & ({col} <= {hi})\"\n                for leg in self._options_strategy.legs:\n                    existing = leg.entry_filter.query\n                    if existing:\n                        leg.entry_filter.query = f\"({existing}) & ({clause})\"\n                    else:\n                        leg.entry_filter.query = clause\n            elif isinstance(algo, MaxGreekExposure):\n                if algo.max_abs_delta is not None:\n                    self.risk_manager.add_constraint(\n                        type(\"MaxDelta\", (), {\n                            \"to_rust_config\": lambda self_: {\"type\": \"MaxDelta\", \"limit\": algo.max_abs_delta},\n                            \"is_allowed\": lambda self_, cg, pg, pv, pk: (\n                                abs(cg.delta + pg.delta) <= algo.max_abs_delta, \"\"\n                            ),\n                        })()\n                    )\n                if algo.max_abs_vega is not None:\n                    self.risk_manager.add_constraint(\n                        type(\"MaxVega\", (), {\n                            \"to_rust_config\": lambda self_: {\"type\": \"MaxVega\", \"limit\": algo.max_abs_vega},\n                            \"is_allowed\": lambda self_, cg, pg, pv, pk: (\n                                abs(cg.vega + pg.vega) <= algo.max_abs_vega, \"\"\n                            ),\n                        })()\n                    )\n            elif isinstance(algo, ExitOnThreshold):\n                import math\n                if not math.isinf(algo.profit_pct):\n                    self._options_strategy.add_exit_thresholds(\n                        profit_pct=algo.profit_pct,\n                        loss_pct=self._options_strategy.exit_thresholds[1],\n                    )\n                if not math.isinf(algo.loss_pct):\n                    self._options_strategy.add_exit_thresholds(\n                        profit_pct=self._options_strategy.exit_thresholds[0],\n                        loss_pct=algo.loss_pct,\n                    )\n            else:\n                raise ValueError(\n                    f\"Unsupported algo type for Rust dispatch: {type(algo).__name__}. \"\n                    f\"All execution runs through Rust; translate to config fields.\"\n                )\n        self.algos.clear()\n\n    def _run_rust(\n        self,\n        rebalance_freq: int,\n        monthly: bool,\n        sma_days: int | None,\n        rebalance_unit: str = 'BMS',\n        check_exits_daily: bool = False,\n    ) -> pd.DataFrame:\n        \"\"\"Run the backtest using the Rust full-loop implementation.\"\"\"\n        import math\n        import pyarrow as pa\n        import polars as pl\n\n        strategy = self._options_strategy\n\n        # Compute rebalance dates for the Rust backtest loop.\n        dates_df = (\n            pd.DataFrame(self.options_data._data[[\"quotedate\", \"volume\"]])\n            .drop_duplicates(\"quotedate\")\n            .set_index(\"quotedate\")\n        )\n        if rebalance_freq:\n            rebalancing_days = pd.to_datetime(\n                dates_df.groupby(pd.Grouper(freq=f\"{rebalance_freq}{rebalance_unit}\"))\n                .apply(lambda x: x.index.min())\n                .values\n            )\n            # Pass rebalance dates as i64 nanoseconds (matching Polars Datetime(ns))\n            rb_date_ns = [int(d.value) for d in rebalancing_days if not pd.isna(d)]\n        else:\n            rb_date_ns = []\n\n        opts_date_col = self._options_schema[\"date\"]\n        stocks_date_col = self._stocks_schema[\"date\"]\n        exp_col = self._options_schema[\"expiration\"]\n\n        # Drop columns Rust never accesses to reduce Arrow conversion cost.\n        _drop_cols = {\"underlying_last\", \"last\", \"optionalias\", \"impliedvol\"}\n        # Also drop openinterest unless MaxOpenInterest selector is in use\n        if not (hasattr(self.signal_selector, '__class__')\n                and self.signal_selector.__class__.__name__ == 'MaxOpenInterest'):\n            _drop_cols.add(\"openinterest\")\n        opts_df = self._options_data._data\n        to_drop = [c for c in _drop_cols if c in opts_df.columns]\n        opts_src = opts_df.drop(columns=to_drop) if to_drop else opts_df\n\n        # Convert pandas → PyArrow → Polars (avoids intermediate copies).\n        opts_pl = pl.from_arrow(pa.Table.from_pandas(opts_src, preserve_index=False))\n        stocks_pl = pl.from_arrow(\n            pa.Table.from_pandas(self._stocks_data._data, preserve_index=False)\n        )\n\n        leg_configs = []\n        for leg in strategy.legs:\n            lc = {\n                \"name\": leg.name,\n                \"entry_filter\": leg.entry_filter.query,\n                \"exit_filter\": leg.exit_filter.query,\n                \"direction\": leg.direction.price_column,\n                \"type\": leg.type.value,\n                \"entry_sort_col\": leg.entry_sort[0] if leg.entry_sort else None,\n                \"entry_sort_asc\": leg.entry_sort[1] if leg.entry_sort else True,\n            }\n            # Per-leg overrides\n            leg_sel = getattr(leg, 'signal_selector', None)\n            if leg_sel is not None and hasattr(leg_sel, 'to_rust_config'):\n                lc[\"signal_selector\"] = leg_sel.to_rust_config()\n            leg_fill = getattr(leg, 'fill_model', None)\n            if leg_fill is not None and hasattr(leg_fill, 'to_rust_config'):\n                lc[\"fill_model\"] = leg_fill.to_rust_config()\n            leg_configs.append(lc)\n\n        config = {\n            \"allocation\": self.allocation,\n            \"initial_capital\": float(self.initial_capital),\n            \"shares_per_contract\": self.shares_per_contract,\n            \"rebalance_dates\": rb_date_ns,\n            \"legs\": leg_configs,\n            \"profit_pct\": (\n                strategy.exit_thresholds[0]\n                if strategy.exit_thresholds[0] != math.inf else None\n            ),\n            \"loss_pct\": (\n                strategy.exit_thresholds[1]\n                if strategy.exit_thresholds[1] != math.inf else None\n            ),\n            \"stocks\": [(s.symbol, s.percentage) for s in self._stocks],\n            \"cost_model\": self.cost_model.to_rust_config(),\n            \"fill_model\": self.fill_model.to_rust_config(),\n            \"signal_selector\": self.signal_selector.to_rust_config(),\n            \"risk_constraints\": [c.to_rust_config() for c in self.risk_manager.constraints],\n            \"sma_days\": sma_days,\n            \"options_budget_pct\": self.options_budget_pct,\n            \"options_budget_annual_pct\": self.options_budget_annual_pct,\n            \"stop_if_broke\": self.stop_if_broke,\n            \"max_notional_pct\": self.max_notional_pct,\n            \"check_exits_daily\": check_exits_daily,\n        }\n\n        schema_mapping = {\n            \"contract\": self._options_schema[\"contract\"],\n            \"date\": opts_date_col,\n            \"stocks_date\": stocks_date_col,\n            \"stocks_symbol\": self._stocks_schema[\"symbol\"],\n            \"stocks_price\": self._stocks_schema[\"adjClose\"],\n            \"underlying\": self._options_schema[\"underlying\"],\n            \"expiration\": self._options_schema[\"expiration\"],\n            \"type\": self._options_schema[\"type\"],\n            \"strike\": self._options_schema[\"strike\"],\n        }\n\n        balance_pl, trade_log_pl, stats = _ob_rust.run_backtest_py(\n            opts_pl, stocks_pl, config, schema_mapping,\n        )\n\n        # Convert trade log from flat columns to MultiIndex\n        trade_log_pd = trade_log_pl.to_pandas()\n        self.trade_log = self._flat_trade_log_to_multiindex(trade_log_pd)\n\n        # Convert balance\n        self.balance = balance_pl.to_pandas()\n        if \"date\" in self.balance.columns:\n            self.balance[\"date\"] = pd.to_datetime(self.balance[\"date\"])\n            self.balance.set_index(\"date\", inplace=True)\n\n        # Add initial balance row (day before first rebalance) — matches Python\n        initial_date = self.stocks_data.start_date - pd.Timedelta(1, unit=\"day\")\n        initial_row = pd.DataFrame(\n            {\"total capital\": self.initial_capital, \"cash\": float(self.initial_capital)},\n            index=[initial_date],\n        )\n        self.balance = pd.concat([initial_row, self.balance], sort=False)\n        for col_name in self.balance.columns:\n            self.balance[col_name] = pd.to_numeric(self.balance[col_name], errors=\"coerce\")\n\n        # Ensure per-stock columns exist (match Python's balance format)\n        for stock in self._stocks:\n            sym = stock.symbol\n            if sym not in self.balance.columns:\n                self.balance[sym] = 0.0\n            if f\"{sym} qty\" not in self.balance.columns:\n                self.balance[f\"{sym} qty\"] = 0.0\n        for col_name in [\"options qty\", \"stocks qty\", \"calls capital\", \"puts capital\"]:\n            if col_name not in self.balance.columns:\n                self.balance[col_name] = 0.0\n\n        # Add derived columns matching Python output\n        self.balance[\"options capital\"] = (\n            self.balance[\"calls capital\"] + self.balance[\"puts capital\"]\n        ).fillna(0)\n        stock_cols = [s.symbol for s in self._stocks]\n        self.balance[\"stocks capital\"] = sum(\n            self.balance.get(c, 0) for c in stock_cols\n        )\n        first_idx = self.balance.index[0]\n        self.balance.loc[first_idx, \"stocks capital\"] = 0\n        self.balance.loc[first_idx, \"options capital\"] = 0\n        self.balance[\"total capital\"] = (\n            self.balance[\"cash\"]\n            + self.balance[\"stocks capital\"]\n            + self.balance[\"options capital\"]\n        )\n        self.balance[\"% change\"] = self.balance[\"total capital\"].pct_change()\n        self.balance[\"accumulated return\"] = (1.0 + self.balance[\"% change\"]).cumprod()\n\n        # Set current_cash to match Python loop's final state after rebalancing\n        # (after the loop, all capital is allocated to stocks/options/cash per allocation)\n        final_total = self.balance[\"total capital\"].iloc[-1]\n        self.current_cash = self.allocation[\"cash\"] * final_total\n        self._initialize_inventories()\n        self._portfolio = Portfolio(initial_cash=self.current_cash)\n        self._attach_run_metadata(\n            rebalance_freq=rebalance_freq,\n            monthly=monthly,\n            sma_days=sma_days,\n        )\n\n        return self.trade_log\n\n    def _run_rust_multi(\n        self,\n        monthly: bool = False,\n        sma_days: int | None = None,\n        check_exits_daily: bool = False,\n    ) -> pd.DataFrame:\n        \"\"\"Run multi-strategy backtest using Rust backend.\"\"\"\n        import math\n        import pyarrow as pa\n        import polars as pl\n\n        opts_date_col = self._options_schema[\"date\"]\n        stocks_date_col = self._stocks_schema[\"date\"]\n\n        # Drop unused columns for Arrow conversion speed\n        _drop_cols = {\"underlying_last\", \"last\", \"optionalias\", \"impliedvol\"}\n        opts_df = self._options_data._data\n        to_drop = [c for c in _drop_cols if c in opts_df.columns]\n        opts_src = opts_df.drop(columns=to_drop) if to_drop else opts_df\n\n        opts_pl = pl.from_arrow(pa.Table.from_pandas(opts_src, preserve_index=False))\n        stocks_pl = pl.from_arrow(\n            pa.Table.from_pandas(self._stocks_data._data, preserve_index=False)\n        )\n\n        # Compute per-slot rebalance dates\n        dates_df = (\n            pd.DataFrame(self.options_data._data[[\"quotedate\", \"volume\"]])\n            .drop_duplicates(\"quotedate\")\n            .set_index(\"quotedate\")\n        )\n\n        slot_configs = []\n        for slot in self._strategy_slots:\n            if slot.rebalance_freq:\n                rb_dates = pd.to_datetime(\n                    dates_df.groupby(\n                        pd.Grouper(freq=f\"{slot.rebalance_freq}{slot.rebalance_unit}\")\n                    ).apply(lambda x: x.index.min()).values\n                )\n                rb_date_ns = [int(d.value) for d in rb_dates if not pd.isna(d)]\n            else:\n                rb_date_ns = []\n\n            leg_configs = []\n            for leg in slot.strategy.legs:\n                lc = {\n                    \"name\": leg.name,\n                    \"entry_filter\": leg.entry_filter.query,\n                    \"exit_filter\": leg.exit_filter.query,\n                    \"direction\": leg.direction.price_column,\n                    \"type\": leg.type.value,\n                    \"entry_sort_col\": leg.entry_sort[0] if leg.entry_sort else None,\n                    \"entry_sort_asc\": leg.entry_sort[1] if leg.entry_sort else True,\n                }\n                leg_sel = getattr(leg, 'signal_selector', None)\n                if leg_sel is not None and hasattr(leg_sel, 'to_rust_config'):\n                    lc[\"signal_selector\"] = leg_sel.to_rust_config()\n                leg_fill = getattr(leg, 'fill_model', None)\n                if leg_fill is not None and hasattr(leg_fill, 'to_rust_config'):\n                    lc[\"fill_model\"] = leg_fill.to_rust_config()\n                leg_configs.append(lc)\n\n            slot_configs.append({\n                \"name\": slot.name,\n                \"legs\": leg_configs,\n                \"weight\": slot.weight,\n                \"rebalance_dates\": rb_date_ns,\n                \"profit_pct\": (\n                    slot.strategy.exit_thresholds[0]\n                    if slot.strategy.exit_thresholds[0] != math.inf else None\n                ),\n                \"loss_pct\": (\n                    slot.strategy.exit_thresholds[1]\n                    if slot.strategy.exit_thresholds[1] != math.inf else None\n                ),\n                \"check_exits_daily\": slot.check_exits_daily,\n            })\n\n        config = {\n            \"allocation\": self.allocation,\n            \"initial_capital\": float(self.initial_capital),\n            \"shares_per_contract\": self.shares_per_contract,\n            \"rebalance_dates\": [],  # Not used for multi-strategy; per-slot instead\n            \"legs\": [],  # Not used for multi-strategy; per-slot instead\n            \"stocks\": [(s.symbol, s.percentage) for s in self._stocks],\n            \"cost_model\": self.cost_model.to_rust_config(),\n            \"fill_model\": self.fill_model.to_rust_config(),\n            \"signal_selector\": self.signal_selector.to_rust_config(),\n            \"risk_constraints\": [c.to_rust_config() for c in self.risk_manager.constraints],\n            \"sma_days\": sma_days,\n            \"options_budget_pct\": self.options_budget_pct,\n            \"options_budget_annual_pct\": self.options_budget_annual_pct,\n            \"stop_if_broke\": self.stop_if_broke,\n            \"max_notional_pct\": self.max_notional_pct,\n            \"check_exits_daily\": check_exits_daily,\n        }\n\n        schema_mapping = {\n            \"contract\": self._options_schema[\"contract\"],\n            \"date\": opts_date_col,\n            \"stocks_date\": stocks_date_col,\n            \"stocks_symbol\": self._stocks_schema[\"symbol\"],\n            \"stocks_price\": self._stocks_schema[\"adjClose\"],\n            \"underlying\": self._options_schema[\"underlying\"],\n            \"expiration\": self._options_schema[\"expiration\"],\n            \"type\": self._options_schema[\"type\"],\n            \"strike\": self._options_schema[\"strike\"],\n        }\n\n        balance_pl, trade_log_pl, stats = _ob_rust.run_multi_strategy_py(\n            opts_pl, stocks_pl, config, schema_mapping, slot_configs,\n        )\n\n        # Convert trade log\n        trade_log_pd = trade_log_pl.to_pandas()\n        self.trade_log = self._flat_trade_log_to_multiindex(trade_log_pd)\n\n        # Convert balance\n        self.balance = balance_pl.to_pandas()\n        if \"date\" in self.balance.columns:\n            self.balance[\"date\"] = pd.to_datetime(self.balance[\"date\"])\n            self.balance.set_index(\"date\", inplace=True)\n\n        # Add initial balance row\n        initial_date = self.stocks_data.start_date - pd.Timedelta(1, unit=\"day\")\n        initial_row = pd.DataFrame(\n            {\"total capital\": self.initial_capital, \"cash\": float(self.initial_capital)},\n            index=[initial_date],\n        )\n        self.balance = pd.concat([initial_row, self.balance], sort=False)\n        for col_name in self.balance.columns:\n            self.balance[col_name] = pd.to_numeric(self.balance[col_name], errors=\"coerce\")\n\n        # Ensure per-stock columns exist\n        for stock in self._stocks:\n            sym = stock.symbol\n            if sym not in self.balance.columns:\n                self.balance[sym] = 0.0\n            if f\"{sym} qty\" not in self.balance.columns:\n                self.balance[f\"{sym} qty\"] = 0.0\n        for col_name in [\"options qty\", \"stocks qty\", \"calls capital\", \"puts capital\"]:\n            if col_name not in self.balance.columns:\n                self.balance[col_name] = 0.0\n\n        # Add derived columns\n        self.balance[\"options capital\"] = (\n            self.balance[\"calls capital\"] + self.balance[\"puts capital\"]\n        ).fillna(0)\n        stock_cols = [s.symbol for s in self._stocks]\n        self.balance[\"stocks capital\"] = sum(\n            self.balance.get(c, 0) for c in stock_cols\n        )\n        first_idx = self.balance.index[0]\n        self.balance.loc[first_idx, \"stocks capital\"] = 0\n        self.balance.loc[first_idx, \"options capital\"] = 0\n        self.balance[\"total capital\"] = (\n            self.balance[\"cash\"]\n            + self.balance[\"stocks capital\"]\n            + self.balance[\"options capital\"]\n        )\n        self.balance[\"% change\"] = self.balance[\"total capital\"].pct_change()\n        self.balance[\"accumulated return\"] = (1.0 + self.balance[\"% change\"]).cumprod()\n\n        final_total = self.balance[\"total capital\"].iloc[-1]\n        self.current_cash = self.allocation[\"cash\"] * final_total\n        self._attach_run_metadata(\n            rebalance_freq=0,\n            monthly=monthly,\n            sma_days=sma_days,\n        )\n\n        return self.trade_log\n\n    def _attach_run_metadata(\n        self,\n        rebalance_freq: int,\n        monthly: bool,\n        sma_days: int | None,\n    ) -> None:\n        metadata = self._build_run_metadata(\n            rebalance_freq=rebalance_freq,\n            monthly=monthly,\n            sma_days=sma_days,\n        )\n        self.run_metadata = metadata\n        self.balance.attrs[\"run_metadata\"] = metadata\n        self.trade_log.attrs[\"run_metadata\"] = metadata\n\n    def _build_run_metadata(\n        self,\n        rebalance_freq: int,\n        monthly: bool,\n        sma_days: int | None,\n    ) -> dict[str, Any]:\n        stocks = [\n            {\"symbol\": stock.symbol, \"percentage\": float(stock.percentage)}\n            for stock in self._stocks\n        ]\n        run_config = {\n            \"allocation\": {k: float(v) for k, v in self.allocation.items()},\n            \"initial_capital\": float(self.initial_capital),\n            \"shares_per_contract\": int(self.shares_per_contract),\n            \"rebalance_freq\": int(rebalance_freq),\n            \"monthly\": bool(monthly),\n            \"sma_days\": int(sma_days) if sma_days is not None else None,\n            \"stocks\": stocks,\n        }\n        data_snapshot = self._data_snapshot()\n        return {\n            \"framework\": \"options_portfolio_backtester.engine.BacktestEngine\",\n            \"git_sha\": self._git_sha(),\n            \"run_at_utc\": datetime.now(timezone.utc).isoformat(),\n            \"config_hash\": self._sha256_json(run_config),\n            \"data_snapshot_hash\": self._sha256_json(data_snapshot),\n            \"data_snapshot\": data_snapshot,\n        }\n\n    def _data_snapshot(self) -> dict[str, Any]:\n        options_dates = self._options_data[\"date\"]\n        stocks_dates = self._stocks_data[\"date\"]\n        return {\n            \"options_rows\": int(len(self._options_data._data)),\n            \"stocks_rows\": int(len(self._stocks_data._data)),\n            \"options_date_start\": pd.Timestamp(options_dates.min()).isoformat(),\n            \"options_date_end\": pd.Timestamp(options_dates.max()).isoformat(),\n            \"stocks_date_start\": pd.Timestamp(stocks_dates.min()).isoformat(),\n            \"stocks_date_end\": pd.Timestamp(stocks_dates.max()).isoformat(),\n            \"options_columns\": list(self._options_data._data.columns),\n            \"stocks_columns\": list(self._stocks_data._data.columns),\n        }\n\n    @staticmethod\n    def _sha256_json(payload: dict[str, Any]) -> str:\n        blob = json.dumps(payload, sort_keys=True, separators=(\",\", \":\"), default=str)\n        return hashlib.sha256(blob.encode(\"utf-8\")).hexdigest()\n\n    @staticmethod\n    def _git_sha() -> str:\n        repo_root = Path(__file__).resolve().parents[2]\n        try:\n            proc = subprocess.run(\n                [\"git\", \"rev-parse\", \"HEAD\"],\n                cwd=repo_root,\n                check=True,\n                capture_output=True,\n                text=True,\n            )\n            return proc.stdout.strip()\n        except Exception:\n            return \"unknown\"\n\n    def _flat_trade_log_to_multiindex(self, flat_df: pd.DataFrame) -> pd.DataFrame:\n        \"\"\"Convert flat 'leg__field' columns from Rust to MultiIndex DataFrame.\"\"\"\n        if flat_df.empty:\n            return pd.DataFrame()\n        tuples = []\n        for c in flat_df.columns:\n            if \"__\" in c:\n                parts = c.split(\"__\", 1)\n                tuples.append((parts[0], parts[1]))\n            else:\n                tuples.append((\"\", c))\n        flat_df.columns = pd.MultiIndex.from_tuples(tuples)\n        return flat_df\n\n    # -- Internals (same logic as original, with pluggable components) --\n\n    def _initialize_inventories(self) -> None:\n        columns = pd.MultiIndex.from_product(\n            [\n                [leg.name for leg in self._options_strategy.legs],\n                [\"contract\", \"underlying\", \"expiration\", \"type\", \"strike\", \"cost\", \"order\"],\n            ]\n        )\n        totals = pd.MultiIndex.from_product([[\"totals\"], [\"cost\", \"qty\", \"date\"]])\n        self._options_inventory: pd.DataFrame = pd.DataFrame(\n            columns=pd.Index(columns.tolist() + totals.tolist())\n        )\n        self._stocks_inventory: pd.DataFrame = pd.DataFrame(\n            columns=[\"symbol\", \"price\", \"qty\"]\n        )\n        # Portfolio dataclass — dual-write alongside legacy DataFrames\n        self._portfolio = Portfolio(initial_cash=0.0)\n\n    def _current_options_capital(self, options, stocks):\n        options_value = self._get_current_option_quotes(options)\n        values_by_row: Any = [0] * len(options_value[0])\n        if len(options_value[0]) != 0:\n            sym_col = self._stocks_schema[\"symbol\"]\n            # Use unadjusted close for intrinsic value — strikes are raw prices\n            _close_col = self._stocks_schema[\"close\"] if \"close\" in self._stocks_schema else None\n            price_col = _close_col if (_close_col and _close_col in stocks.columns) else self._stocks_schema[\"adjClose\"]\n            for i, leg in enumerate(self._options_strategy.legs):\n                cost_series = options_value[i][\"cost\"].copy()\n                # Replace NaN (missing contracts) with intrinsic value\n                if cost_series.isna().any():\n                    inv_leg = self._options_inventory[leg.name]\n                    for idx in cost_series.index[cost_series.isna()]:\n                        opt_type = inv_leg.at[idx, \"type\"]\n                        strike = inv_leg.at[idx, \"strike\"]\n                        underlying = inv_leg.at[idx, \"underlying\"]\n                        spot_match = stocks.loc[stocks[sym_col] == underlying, price_col]\n                        spot = spot_match.iloc[0] if len(spot_match) > 0 else 0.0\n                        iv = _intrinsic_value(opt_type, float(strike), float(spot))\n                        cash_sign = -1.0 if ~leg.direction == Direction.SELL else 1.0\n                        cost_series.at[idx] = cash_sign * iv * self.shares_per_contract\n                values_by_row += cost_series.values\n            total: float = -sum(values_by_row * self._options_inventory[\"totals\"][\"qty\"].values)\n        else:\n            total = 0\n        return total\n\n    def _get_current_option_quotes(self, options):\n        current_options_quotes: list[pd.DataFrame] = []\n        for leg in self._options_strategy.legs:\n            inventory_leg = self._options_inventory[leg.name]\n            leg_options = inventory_leg[[\"contract\"]].merge(\n                options, how=\"left\",\n                left_on=\"contract\", right_on=leg.schema[\"contract\"],\n            )\n            leg_options.index = self._options_inventory.index\n            leg_options[\"order\"] = get_order(leg.direction, Signal.EXIT)\n            leg_options[\"cost\"] = leg_options[self._options_schema[(~leg.direction).price_column]]\n\n            if ~leg.direction == Direction.SELL:\n                leg_options[\"cost\"] = -leg_options[\"cost\"]\n            leg_options[\"cost\"] *= self.shares_per_contract\n            current_options_quotes.append(leg_options)\n        return current_options_quotes\n\n    def __repr__(self) -> str:\n        return (\n            f\"BacktestEngine(capital={self.initial_capital}, \"\n            f\"allocation={self.allocation}, \"\n            f\"cost_model={self.cost_model.__class__.__name__})\"\n        )\n\n"
  },
  {
    "path": "options_portfolio_backtester/engine/multi_strategy.py",
    "content": "\"\"\"Multi-strategy engine — run N strategies with shared capital and risk budget.\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import Any\n\nimport pandas as pd\n\nfrom options_portfolio_backtester.engine.engine import BacktestEngine\nfrom options_portfolio_backtester.execution.cost_model import TransactionCostModel, NoCosts\nfrom options_portfolio_backtester.portfolio.risk import RiskManager\n\nfrom options_portfolio_backtester.core.types import Stock\n\n\nclass StrategyAllocation:\n    \"\"\"Configuration for one strategy within a multi-strategy engine.\"\"\"\n\n    def __init__(\n        self,\n        name: str,\n        engine: BacktestEngine,\n        weight: float = 1.0,\n    ) -> None:\n        self.name = name\n        self.engine = engine\n        self.weight = weight\n\n\nclass MultiStrategyEngine:\n    \"\"\"Run multiple strategies with shared capital allocation.\n\n    Each strategy gets a fraction of total capital proportional to its weight.\n    Results are combined into a single balance sheet.\n    \"\"\"\n\n    def __init__(\n        self,\n        strategies: list[StrategyAllocation],\n        initial_capital: int = 1_000_000,\n    ) -> None:\n        self.strategies = strategies\n        self.initial_capital = initial_capital\n        total_weight = sum(s.weight for s in strategies)\n        self._weights = {s.name: s.weight / total_weight for s in strategies}\n\n    def run(self, rebalance_freq: int = 0, monthly: bool = False,\n            sma_days: int | None = None) -> dict[str, pd.DataFrame]:\n        \"\"\"Run all strategies and return per-strategy trade logs.\n\n        Returns:\n            Dict mapping strategy name to its trade log DataFrame.\n        \"\"\"\n        results: dict[str, pd.DataFrame] = {}\n\n        for sa in self.strategies:\n            capital_share = int(self.initial_capital * self._weights[sa.name])\n            # Override the engine's initial capital with its share\n            sa.engine.initial_capital = capital_share\n            trade_log = sa.engine.run(\n                rebalance_freq=rebalance_freq,\n                monthly=monthly,\n                sma_days=sma_days,\n            )\n            results[sa.name] = trade_log\n\n        # Build combined balance\n        self._build_combined_balance()\n        return results\n\n    def _build_combined_balance(self) -> None:\n        \"\"\"Combine balance sheets from all strategies.\"\"\"\n        balances = []\n        for sa in self.strategies:\n            if hasattr(sa.engine, \"balance\"):\n                b = sa.engine.balance[[\"total capital\", \"% change\"]].copy()\n                b.columns = [f\"{sa.name}_capital\", f\"{sa.name}_pct_change\"]\n                balances.append(b)\n\n        if balances:\n            self.balance = pd.concat(balances, axis=1)\n            capital_cols = [f\"{sa.name}_capital\" for sa in self.strategies]\n            self.balance[\"total capital\"] = self.balance[capital_cols].sum(axis=1)\n            self.balance[\"% change\"] = self.balance[\"total capital\"].pct_change()\n            self.balance[\"accumulated return\"] = (\n                1.0 + self.balance[\"% change\"]\n            ).cumprod()\n        else:\n            self.balance = pd.DataFrame()\n"
  },
  {
    "path": "options_portfolio_backtester/engine/pipeline.py",
    "content": "\"\"\"Composable algo pipeline for stock portfolio workflows.\n\nProvides bt-compatible scheduling, selection, weighting, and rebalancing algos.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport re as _re\nimport random as _random\nfrom dataclasses import dataclass, field\nfrom typing import Callable, Literal, Protocol, Sequence\n\nimport numpy as np\nimport pandas as pd\n\n\nStepStatus = Literal[\"continue\", \"skip_day\", \"stop\"]\n\n\n@dataclass(frozen=True)\nclass StepDecision:\n    \"\"\"Outcome returned by a pipeline step.\"\"\"\n\n    status: StepStatus = \"continue\"\n    message: str = \"\"\n\n\n@dataclass\nclass PipelineContext:\n    \"\"\"Mutable state shared across pipeline steps for one date.\"\"\"\n\n    date: pd.Timestamp\n    prices: pd.Series\n    total_capital: float\n    cash: float\n    positions: dict[str, float]\n    selected_symbols: list[str] = field(default_factory=list)\n    target_weights: dict[str, float] = field(default_factory=dict)\n    # Price history up to current date (set by AlgoPipelineBacktester).\n    price_history: pd.DataFrame | None = None\n\n\n@dataclass(frozen=True)\nclass PipelineLogRow:\n    date: pd.Timestamp\n    step: str\n    status: StepStatus\n    message: str\n\n\nclass Algo(Protocol):\n    \"\"\"Protocol for a pipeline step.\"\"\"\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        ...\n\n\n# ---------------------------------------------------------------------------\n# Scheduling algos\n# ---------------------------------------------------------------------------\n\nclass RunMonthly:\n    \"\"\"Gate pipeline execution to month starts.\"\"\"\n\n    def __init__(self) -> None:\n        self._last_month: tuple[int, int] | None = None\n\n    def reset(self) -> None:\n        self._last_month = None\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        key = (ctx.date.year, ctx.date.month)\n        if self._last_month == key:\n            return StepDecision(status=\"skip_day\", message=\"not month-start\")\n        self._last_month = key\n        return StepDecision()\n\n\nclass RunWeekly:\n    \"\"\"Gate pipeline execution to week starts (Monday).\"\"\"\n\n    def __init__(self) -> None:\n        self._last_week: tuple[int, int] | None = None\n\n    def reset(self) -> None:\n        self._last_week = None\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        key = (ctx.date.isocalendar()[0], ctx.date.isocalendar()[1])\n        if self._last_week == key:\n            return StepDecision(status=\"skip_day\", message=\"not week-start\")\n        self._last_week = key\n        return StepDecision()\n\n\nclass RunQuarterly:\n    \"\"\"Gate pipeline execution to quarter starts.\"\"\"\n\n    def __init__(self) -> None:\n        self._last_quarter: tuple[int, int] | None = None\n\n    def reset(self) -> None:\n        self._last_quarter = None\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        key = (ctx.date.year, (ctx.date.month - 1) // 3)\n        if self._last_quarter == key:\n            return StepDecision(status=\"skip_day\", message=\"not quarter-start\")\n        self._last_quarter = key\n        return StepDecision()\n\n\nclass RunYearly:\n    \"\"\"Gate pipeline execution to year starts.\"\"\"\n\n    def __init__(self) -> None:\n        self._last_year: int | None = None\n\n    def reset(self) -> None:\n        self._last_year = None\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        if self._last_year == ctx.date.year:\n            return StepDecision(status=\"skip_day\", message=\"not year-start\")\n        self._last_year = ctx.date.year\n        return StepDecision()\n\n\nclass RunDaily:\n    \"\"\"Allow pipeline execution on every date (no gating).\"\"\"\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        return StepDecision()\n\n\nclass RunOnce:\n    \"\"\"Execute pipeline only on the first date, skip all subsequent dates.\"\"\"\n\n    def __init__(self) -> None:\n        self._ran = False\n\n    def reset(self) -> None:\n        self._ran = False\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        if self._ran:\n            return StepDecision(status=\"skip_day\", message=\"already ran\")\n        self._ran = True\n        return StepDecision()\n\n\nclass RunOnDate:\n    \"\"\"Execute pipeline only on specific dates.\"\"\"\n\n    def __init__(self, dates: Sequence[str | pd.Timestamp]) -> None:\n        self._dates = {pd.Timestamp(d).normalize() for d in dates}\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        if ctx.date.normalize() not in self._dates:\n            return StepDecision(status=\"skip_day\", message=\"not a target date\")\n        return StepDecision()\n\n\nclass RunAfterDate:\n    \"\"\"Execute pipeline only after a specific date (inclusive).\"\"\"\n\n    def __init__(self, date: str | pd.Timestamp) -> None:\n        self._date = pd.Timestamp(date).normalize()\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        if ctx.date.normalize() < self._date:\n            return StepDecision(status=\"skip_day\", message=\"before start date\")\n        return StepDecision()\n\n\nclass RunEveryNPeriods:\n    \"\"\"Execute pipeline every N trading days.\"\"\"\n\n    def __init__(self, n: int) -> None:\n        self._n = int(n)\n        self._count = 0\n\n    def reset(self) -> None:\n        self._count = 0\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        self._count += 1\n        if self._count % self._n != 1 and self._count != 1:\n            return StepDecision(status=\"skip_day\", message=f\"period {self._count}, not every {self._n}\")\n        return StepDecision()\n\n\nclass RunAfterDays:\n    \"\"\"Warmup gate: skip the first *n* trading days.\"\"\"\n\n    def __init__(self, n: int) -> None:\n        self._n = int(n)\n        self._count = 0\n\n    def reset(self) -> None:\n        self._count = 0\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        self._count += 1\n        if self._count <= self._n:\n            return StepDecision(status=\"skip_day\", message=f\"warmup day {self._count}/{self._n}\")\n        return StepDecision()\n\n\nclass RunIfOutOfBounds:\n    \"\"\"Trigger rebalance when any position drifts beyond *tolerance* from target.\n\n    Typically used with ``Or``: ``Or(RunQuarterly(), RunIfOutOfBounds(0.05))``.\n    Requires ``target_weights`` to have been set by a prior weighting algo\n    on the *previous* rebalance (stored internally).\n    \"\"\"\n\n    def __init__(self, tolerance: float = 0.05) -> None:\n        self._tolerance = float(tolerance)\n        self._last_target: dict[str, float] = {}\n\n    def reset(self) -> None:\n        self._last_target = {}\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        if not self._last_target:\n            # No previous target — let downstream algos set it, then remember\n            return StepDecision(status=\"skip_day\", message=\"no prior target weights\")\n\n        total = float(ctx.total_capital)\n        if total <= 0:\n            return StepDecision(status=\"skip_day\", message=\"no capital\")\n\n        for sym, target_w in self._last_target.items():\n            qty = ctx.positions.get(sym, 0.0)\n            if sym in ctx.prices.index and pd.notna(ctx.prices[sym]):\n                actual_w = float(qty) * float(ctx.prices[sym]) / total\n            else:\n                actual_w = 0.0\n            if abs(actual_w - target_w) > self._tolerance:\n                return StepDecision()  # out of bounds → allow rebalance\n\n        return StepDecision(status=\"skip_day\", message=\"all weights within bounds\")\n\n    def update_target(self, weights: dict[str, float]) -> None:\n        \"\"\"Call after a successful rebalance to remember the new target.\"\"\"\n        self._last_target = dict(weights)\n\n\nclass Or:\n    \"\"\"Logical OR combinator: pass if any child algo passes.\"\"\"\n\n    def __init__(self, *algos: Algo) -> None:\n        self._algos = algos\n\n    def reset(self) -> None:\n        for algo in self._algos:\n            if hasattr(algo, \"reset\"):\n                algo.reset()\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        for algo in self._algos:\n            decision = algo(ctx)\n            if decision.status == \"continue\":\n                return StepDecision()\n        return StepDecision(status=\"skip_day\", message=\"all sub-algos skipped\")\n\n\nclass Not:\n    \"\"\"Logical NOT combinator: invert the child algo's decision.\"\"\"\n\n    def __init__(self, algo: Algo) -> None:\n        self._algo = algo\n\n    def reset(self) -> None:\n        if hasattr(self._algo, \"reset\"):\n            self._algo.reset()\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        decision = self._algo(ctx)\n        if decision.status == \"skip_day\":\n            return StepDecision()\n        return StepDecision(status=\"skip_day\", message=\"inverted\")\n\n\n# ---------------------------------------------------------------------------\n# Selection algos\n# ---------------------------------------------------------------------------\n\nclass SelectThese:\n    \"\"\"Select a fixed list of symbols if priced on current date.\"\"\"\n\n    def __init__(self, symbols: list[str]) -> None:\n        self.symbols = [s.upper() for s in symbols]\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        available = [s for s in self.symbols if s in ctx.prices.index and pd.notna(ctx.prices[s])]\n        ctx.selected_symbols = available\n        if not available:\n            return StepDecision(status=\"skip_day\", message=\"no selected symbols with valid prices\")\n        return StepDecision()\n\n\nclass SelectAll:\n    \"\"\"Select all symbols with valid prices on current date.\"\"\"\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        available = [s for s in ctx.prices.index if pd.notna(ctx.prices[s]) and float(ctx.prices[s]) > 0]\n        ctx.selected_symbols = sorted(available)\n        if not available:\n            return StepDecision(status=\"skip_day\", message=\"no symbols with valid prices\")\n        return StepDecision()\n\n\nclass SelectHasData:\n    \"\"\"Select symbols that have at least *min_days* of price history.\"\"\"\n\n    def __init__(self, min_days: int = 1) -> None:\n        self._min_days = int(min_days)\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        if ctx.price_history is None or ctx.price_history.empty:\n            return StepDecision(status=\"skip_day\", message=\"no price history\")\n        keep = []\n        for s in ctx.selected_symbols or list(ctx.prices.index):\n            if s in ctx.price_history.columns:\n                valid = ctx.price_history[s].dropna()\n                if len(valid) >= self._min_days:\n                    keep.append(s)\n        ctx.selected_symbols = keep\n        if not keep:\n            return StepDecision(status=\"skip_day\", message=f\"no symbols with {self._min_days}+ days\")\n        return StepDecision()\n\n\nclass SelectMomentum:\n    \"\"\"Select top *n* symbols by trailing momentum (total return over *lookback* days).\"\"\"\n\n    def __init__(self, n: int, lookback: int = 252, sort_descending: bool = True) -> None:\n        self._n = int(n)\n        self._lookback = int(lookback)\n        self._sort_desc = sort_descending\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        if ctx.price_history is None or ctx.price_history.empty:\n            return StepDecision(status=\"skip_day\", message=\"no price history for momentum\")\n        candidates = ctx.selected_symbols or [\n            s for s in ctx.prices.index if pd.notna(ctx.prices[s])\n        ]\n        scores: dict[str, float] = {}\n        for s in candidates:\n            if s not in ctx.price_history.columns:\n                continue\n            series = ctx.price_history[s].dropna()\n            if len(series) < 2:\n                continue\n            window = series.iloc[-self._lookback:]\n            if len(window) < 2 or float(window.iloc[0]) <= 0:\n                continue\n            scores[s] = float(window.iloc[-1] / window.iloc[0] - 1)\n        ranked = sorted(scores, key=scores.get, reverse=self._sort_desc)  # type: ignore[arg-type]\n        ctx.selected_symbols = ranked[: self._n]\n        if not ctx.selected_symbols:\n            return StepDecision(status=\"skip_day\", message=\"no symbols with enough momentum data\")\n        return StepDecision()\n\n\nclass SelectN:\n    \"\"\"Keep the first *n* symbols from current selection (stable order).\"\"\"\n\n    def __init__(self, n: int) -> None:\n        self._n = int(n)\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        ctx.selected_symbols = ctx.selected_symbols[: self._n]\n        if not ctx.selected_symbols:\n            return StepDecision(status=\"skip_day\", message=\"no symbols after SelectN\")\n        return StepDecision()\n\n\nclass SelectRandomly:\n    \"\"\"Select *n* symbols at random from the current selection.\"\"\"\n\n    def __init__(self, n: int, seed: int | None = None) -> None:\n        self._n = int(n)\n        self._rng = _random.Random(seed)\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        candidates = ctx.selected_symbols or [\n            s for s in ctx.prices.index if pd.notna(ctx.prices[s])\n        ]\n        if not candidates:\n            return StepDecision(status=\"skip_day\", message=\"no candidates for random selection\")\n        k = min(self._n, len(candidates))\n        ctx.selected_symbols = sorted(self._rng.sample(candidates, k))\n        return StepDecision()\n\n\nclass SelectActive:\n    \"\"\"Filter out symbols whose price is zero or NaN (dead/expired).\"\"\"\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        candidates = ctx.selected_symbols or list(ctx.prices.index)\n        active = [\n            s for s in candidates\n            if s in ctx.prices.index and pd.notna(ctx.prices[s]) and float(ctx.prices[s]) > 0\n        ]\n        ctx.selected_symbols = active\n        if not active:\n            return StepDecision(status=\"skip_day\", message=\"no active symbols\")\n        return StepDecision()\n\n\nclass SelectRegex:\n    \"\"\"Select symbols whose name matches a regex pattern.\"\"\"\n\n    def __init__(self, pattern: str) -> None:\n        self._pattern = _re.compile(pattern)\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        candidates = ctx.selected_symbols or list(ctx.prices.index)\n        matched = [s for s in candidates if self._pattern.search(s)]\n        ctx.selected_symbols = matched\n        if not matched:\n            return StepDecision(status=\"skip_day\", message=f\"no symbols match {self._pattern.pattern!r}\")\n        return StepDecision()\n\n\nclass SelectWhere:\n    \"\"\"Select symbols where a user-defined function returns True.\"\"\"\n\n    def __init__(self, fn: Callable[[str, PipelineContext], bool]) -> None:\n        self._fn = fn\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        candidates = ctx.selected_symbols or [\n            s for s in ctx.prices.index if pd.notna(ctx.prices[s])\n        ]\n        ctx.selected_symbols = [s for s in candidates if self._fn(s, ctx)]\n        if not ctx.selected_symbols:\n            return StepDecision(status=\"skip_day\", message=\"no symbols passed filter\")\n        return StepDecision()\n\n\n# ---------------------------------------------------------------------------\n# Weighting algos\n# ---------------------------------------------------------------------------\n\nclass WeighSpecified:\n    \"\"\"Set fixed target weights, normalized over selected symbols.\"\"\"\n\n    def __init__(self, weights: dict[str, float]) -> None:\n        self.weights = {k.upper(): float(v) for k, v in weights.items()}\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        if not ctx.selected_symbols:\n            return StepDecision(status=\"skip_day\", message=\"no selected symbols\")\n        raw = {s: self.weights.get(s, 0.0) for s in ctx.selected_symbols}\n        total = float(sum(raw.values()))\n        if total <= 0:\n            return StepDecision(status=\"skip_day\", message=\"target weights sum to zero\")\n        ctx.target_weights = {s: w / total for s, w in raw.items()}\n        return StepDecision()\n\n\nclass WeighEqually:\n    \"\"\"Equal-weight all selected symbols.\"\"\"\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        if not ctx.selected_symbols:\n            return StepDecision(status=\"skip_day\", message=\"no selected symbols\")\n        w = 1.0 / len(ctx.selected_symbols)\n        ctx.target_weights = {s: w for s in ctx.selected_symbols}\n        return StepDecision()\n\n\nclass WeighRandomly:\n    \"\"\"Assign random weights to selected symbols (normalized to sum to 1).\n\n    Useful for constructing random benchmark strategies.\n    \"\"\"\n\n    def __init__(self, seed: int | None = None) -> None:\n        self._rng = np.random.RandomState(seed)\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        if not ctx.selected_symbols:\n            return StepDecision(status=\"skip_day\", message=\"no selected symbols\")\n        raw = self._rng.dirichlet(np.ones(len(ctx.selected_symbols)))\n        ctx.target_weights = {s: float(w) for s, w in zip(ctx.selected_symbols, raw)}\n        return StepDecision()\n\n\nclass WeighTarget:\n    \"\"\"Read target weights from a pre-computed DataFrame indexed by date.\n\n    *weights_df* should have dates as index and symbol names as columns.\n    On each date, looks up the closest prior row.\n    \"\"\"\n\n    def __init__(self, weights_df: pd.DataFrame) -> None:\n        self._weights = weights_df.sort_index()\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        if not ctx.selected_symbols:\n            return StepDecision(status=\"skip_day\", message=\"no selected symbols\")\n        # Find the most recent row <= current date\n        mask = self._weights.index <= ctx.date\n        if not mask.any():\n            return StepDecision(status=\"skip_day\", message=\"no weight data for this date\")\n        row = self._weights.loc[mask].iloc[-1]\n        weights = {}\n        for s in ctx.selected_symbols:\n            if s in row.index and pd.notna(row[s]):\n                weights[s] = float(row[s])\n        if not weights:\n            return StepDecision(status=\"skip_day\", message=\"no matching weights\")\n        total = sum(weights.values())\n        if total <= 0:\n            return StepDecision(status=\"skip_day\", message=\"weights sum to zero\")\n        ctx.target_weights = {s: w / total for s, w in weights.items()}\n        return StepDecision()\n\n\nclass WeighInvVol:\n    \"\"\"Inverse-volatility weighting (risk parity lite).\n\n    Weight_i = (1/vol_i) / sum(1/vol_j).\n    Uses trailing *lookback*-day returns standard deviation.\n    \"\"\"\n\n    def __init__(self, lookback: int = 252) -> None:\n        self._lookback = int(lookback)\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        if not ctx.selected_symbols:\n            return StepDecision(status=\"skip_day\", message=\"no selected symbols\")\n        if ctx.price_history is None or ctx.price_history.empty:\n            return StepDecision(status=\"skip_day\", message=\"no price history for inv-vol\")\n        inv_vols: dict[str, float] = {}\n        for s in ctx.selected_symbols:\n            if s not in ctx.price_history.columns:\n                continue\n            series = ctx.price_history[s].dropna()\n            window = series.iloc[-self._lookback:]\n            if len(window) < 3:\n                continue\n            rets = window.pct_change().dropna()\n            vol = float(rets.std())\n            if vol > 0:\n                inv_vols[s] = 1.0 / vol\n        if not inv_vols:\n            return StepDecision(status=\"skip_day\", message=\"no valid vol data\")\n        total = sum(inv_vols.values())\n        ctx.target_weights = {s: v / total for s, v in inv_vols.items()}\n        return StepDecision()\n\n\nclass WeighMeanVar:\n    \"\"\"Mean-variance optimization (max Sharpe ratio portfolio).\n\n    Uses trailing *lookback*-day returns. Falls back to equal weight\n    if optimization fails (singular covariance, etc.).\n    \"\"\"\n\n    def __init__(self, lookback: int = 252, risk_free_rate: float = 0.0) -> None:\n        self._lookback = int(lookback)\n        self._rf = float(risk_free_rate)\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        if not ctx.selected_symbols:\n            return StepDecision(status=\"skip_day\", message=\"no selected symbols\")\n        if ctx.price_history is None or ctx.price_history.empty:\n            return StepDecision(status=\"skip_day\", message=\"no price history for mean-var\")\n        syms = [s for s in ctx.selected_symbols if s in ctx.price_history.columns]\n        if len(syms) < 1:\n            return StepDecision(status=\"skip_day\", message=\"no price history columns match\")\n        prices = ctx.price_history[syms].dropna()\n        if len(prices) < 3:\n            return StepDecision(status=\"skip_day\", message=\"insufficient data for mean-var\")\n        rets = prices.iloc[-self._lookback:].pct_change().dropna()\n        if len(rets) < 3:\n            return StepDecision(status=\"skip_day\", message=\"insufficient returns for mean-var\")\n        mu = rets.mean().values\n        cov = rets.cov().values\n        n = len(syms)\n        try:\n            cov_inv = np.linalg.inv(cov)\n        except np.linalg.LinAlgError:\n            # Singular covariance — fall back to equal weight\n            w = 1.0 / n\n            ctx.target_weights = {s: w for s in syms}\n            return StepDecision()\n        excess = mu - self._rf / 252\n        raw_w = cov_inv @ excess\n        # Normalize to sum to 1, allow short positions only if naturally arising\n        total = float(np.sum(np.abs(raw_w)))\n        if total <= 0:\n            w = 1.0 / n\n            ctx.target_weights = {s: w for s in syms}\n            return StepDecision()\n        # Long-only: clip negatives, renormalize\n        clipped = np.maximum(raw_w, 0.0)\n        clip_sum = float(np.sum(clipped))\n        if clip_sum <= 0:\n            w = 1.0 / n\n            ctx.target_weights = {s: w for s in syms}\n            return StepDecision()\n        weights = clipped / clip_sum\n        ctx.target_weights = {s: float(weights[i]) for i, s in enumerate(syms)}\n        return StepDecision()\n\n\nclass WeighERC:\n    \"\"\"Equal Risk Contribution weighting.\n\n    Each asset contributes equally to portfolio risk.\n    Uses iterative bisection approximation.\n    \"\"\"\n\n    def __init__(self, lookback: int = 252, max_iter: int = 100) -> None:\n        self._lookback = int(lookback)\n        self._max_iter = int(max_iter)\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        if not ctx.selected_symbols:\n            return StepDecision(status=\"skip_day\", message=\"no selected symbols\")\n        if ctx.price_history is None or ctx.price_history.empty:\n            return StepDecision(status=\"skip_day\", message=\"no price history for ERC\")\n        syms = [s for s in ctx.selected_symbols if s in ctx.price_history.columns]\n        if len(syms) < 1:\n            return StepDecision(status=\"skip_day\", message=\"no matching columns\")\n        prices = ctx.price_history[syms].dropna()\n        rets = prices.iloc[-self._lookback:].pct_change().dropna()\n        if len(rets) < 3:\n            return StepDecision(status=\"skip_day\", message=\"insufficient data for ERC\")\n        cov = rets.cov().values\n        n = len(syms)\n        # Start with equal weights\n        w = np.ones(n) / n\n        for _ in range(self._max_iter):\n            sigma = np.sqrt(float(w @ cov @ w))\n            if sigma <= 0:\n                break\n            mrc = (cov @ w) / sigma  # marginal risk contribution\n            rc = w * mrc  # risk contribution\n            target_rc = sigma / n\n            # Adjust: increase weight of under-contributing, decrease over-contributing\n            adj = target_rc / np.maximum(rc, 1e-12)\n            w = w * adj\n            w = np.maximum(w, 0.0)\n            w_sum = float(np.sum(w))\n            if w_sum > 0:\n                w = w / w_sum\n        ctx.target_weights = {s: float(w[i]) for i, s in enumerate(syms)}\n        return StepDecision()\n\n\nclass TargetVol:\n    \"\"\"Scale weights to target a specific annualized portfolio volatility.\n\n    Scales the existing target_weights by (target_vol / realized_vol).\n    Excess weight goes to cash.\n    \"\"\"\n\n    def __init__(self, target: float = 0.10, lookback: int = 252) -> None:\n        self._target = float(target)\n        self._lookback = int(lookback)\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        if not ctx.target_weights:\n            return StepDecision(status=\"skip_day\", message=\"no target weights to scale\")\n        if ctx.price_history is None or ctx.price_history.empty:\n            return StepDecision(status=\"skip_day\", message=\"no price history for vol scaling\")\n        syms = list(ctx.target_weights.keys())\n        available = [s for s in syms if s in ctx.price_history.columns]\n        if not available:\n            return StepDecision(status=\"skip_day\", message=\"no price data for vol scaling\")\n        prices = ctx.price_history[available].dropna()\n        rets = prices.iloc[-self._lookback:].pct_change().dropna()\n        if len(rets) < 3:\n            return StepDecision()  # not enough data, pass through unchanged\n        weights_arr = np.array([ctx.target_weights.get(s, 0.0) for s in available])\n        port_rets = rets.values @ weights_arr\n        realized_vol = float(np.std(port_rets) * np.sqrt(252))\n        if realized_vol <= 0:\n            return StepDecision()\n        scale = min(self._target / realized_vol, 1.0)  # never lever above 1.0\n        ctx.target_weights = {s: w * scale for s, w in ctx.target_weights.items()}\n        return StepDecision()\n\n\n# ---------------------------------------------------------------------------\n# Weight limits\n# ---------------------------------------------------------------------------\n\nclass LimitWeights:\n    \"\"\"Cap individual position weights and renormalize.\"\"\"\n\n    def __init__(self, limit: float = 0.25) -> None:\n        self._limit = float(limit)\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        if not ctx.target_weights:\n            return StepDecision()\n        # Iteratively clip and renormalize (may need multiple passes)\n        weights = dict(ctx.target_weights)\n        for _ in range(10):\n            over = {s: w for s, w in weights.items() if w > self._limit}\n            if not over:\n                break\n            under = {s: w for s, w in weights.items() if w <= self._limit}\n            for s in over:\n                weights[s] = self._limit\n            under_sum = sum(under.values())\n            over_excess = sum(w - self._limit for w in over.values())\n            if under_sum > 0:\n                scale = 1.0 + over_excess / under_sum\n                for s in under:\n                    weights[s] = weights[s] * scale\n        ctx.target_weights = weights\n        return StepDecision()\n\n\nclass LimitDeltas:\n    \"\"\"Cap how much any single weight can change between rebalances.\n\n    On each call, computes the current portfolio weights from positions and\n    clips ``target_weights`` so no weight moves more than *limit* from its\n    current value.  Excess is redistributed proportionally.\n    \"\"\"\n\n    def __init__(self, limit: float = 0.10) -> None:\n        self._limit = float(limit)\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        if not ctx.target_weights:\n            return StepDecision()\n        total = float(ctx.total_capital)\n        if total <= 0:\n            return StepDecision()\n\n        # Compute current weights from positions\n        current: dict[str, float] = {}\n        for sym in ctx.target_weights:\n            qty = ctx.positions.get(sym, 0.0)\n            if sym in ctx.prices.index and pd.notna(ctx.prices[sym]):\n                current[sym] = float(qty) * float(ctx.prices[sym]) / total\n            else:\n                current[sym] = 0.0\n\n        # Clip deltas\n        clipped: dict[str, float] = {}\n        for sym, target_w in ctx.target_weights.items():\n            cur_w = current.get(sym, 0.0)\n            delta = target_w - cur_w\n            clamped = max(-self._limit, min(self._limit, delta))\n            clipped[sym] = cur_w + clamped\n\n        # Renormalize to sum to original target sum\n        orig_sum = sum(ctx.target_weights.values())\n        clip_sum = sum(clipped.values())\n        if clip_sum > 0 and orig_sum > 0:\n            scale = orig_sum / clip_sum\n            clipped = {s: w * scale for s, w in clipped.items()}\n\n        ctx.target_weights = clipped\n        return StepDecision()\n\n\nclass ScaleWeights:\n    \"\"\"Multiply all target weights by a scalar.\n\n    Useful for leverage (scale > 1) or de-leverage (scale < 1).\n    Excess weight goes to cash.\n    \"\"\"\n\n    def __init__(self, scale: float) -> None:\n        self._scale = float(scale)\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        if not ctx.target_weights:\n            return StepDecision()\n        ctx.target_weights = {s: w * self._scale for s, w in ctx.target_weights.items()}\n        return StepDecision()\n\n\n# ---------------------------------------------------------------------------\n# Capital flows\n# ---------------------------------------------------------------------------\n\nclass CapitalFlow:\n    \"\"\"Model periodic capital additions (+) or withdrawals (-).\n\n    *flows* is a dict mapping dates to amounts, or a callable\n    ``(date: pd.Timestamp) -> float`` returning the flow amount.\n    \"\"\"\n\n    def __init__(self, flows: dict[str | pd.Timestamp, float] | Callable[[pd.Timestamp], float]) -> None:\n        if callable(flows):\n            self._fn = flows\n        else:\n            mapping = {pd.Timestamp(k).normalize(): float(v) for k, v in flows.items()}\n            self._fn = lambda d: mapping.get(d.normalize(), 0.0)\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        amount = self._fn(ctx.date)\n        if amount != 0.0:\n            ctx.cash = float(ctx.cash + amount)\n            ctx.total_capital = float(ctx.total_capital + amount)\n        return StepDecision()\n\n\n# ---------------------------------------------------------------------------\n# Risk guards\n# ---------------------------------------------------------------------------\n\nclass MaxDrawdownGuard:\n    \"\"\"Block new rebalances while drawdown exceeds threshold.\"\"\"\n\n    def __init__(self, max_drawdown_pct: float) -> None:\n        self.max_drawdown_pct = float(max_drawdown_pct)\n        self._peak = 0.0\n\n    def reset(self) -> None:\n        self._peak = 0.0\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        self._peak = max(self._peak, float(ctx.total_capital))\n        if self._peak <= 0:\n            return StepDecision()\n        dd = (self._peak - float(ctx.total_capital)) / self._peak\n        if dd > self.max_drawdown_pct:\n            return StepDecision(status=\"skip_day\", message=f\"drawdown {dd:.2%} > {self.max_drawdown_pct:.2%}\")\n        return StepDecision()\n\n\nclass HedgeRisks:\n    \"\"\"Adjust target weights to hedge portfolio Greeks toward targets.\n\n    Uses a Jacobian-based approach: for each hedge instrument, compute\n    partial derivatives (delta/vega per unit weight), then solve the\n    linear system to find weight adjustments that bring portfolio Greeks\n    closest to targets.\n\n    Expects ``ctx.prices`` to contain columns for hedge instruments and\n    ``ctx.price_history`` to be available for estimating betas (used as\n    a proxy for delta when true Greeks are unavailable).\n    \"\"\"\n\n    def __init__(\n        self,\n        target_delta: float = 0.0,\n        target_vega: float = 0.0,\n        hedge_symbols: list[str] | None = None,\n    ) -> None:\n        self.target_delta = float(target_delta)\n        self.target_vega = float(target_vega)\n        self.hedge_symbols = hedge_symbols\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        if not ctx.target_weights:\n            return StepDecision(status=\"skip_day\", message=\"no target weights to hedge\")\n\n        # Determine hedge instruments\n        hedgers = self.hedge_symbols or ctx.selected_symbols\n        if not hedgers:\n            return StepDecision(status=\"skip_day\", message=\"no hedge symbols\")\n\n        hedgers = [s for s in hedgers if s in ctx.prices.index and pd.notna(ctx.prices[s])]\n        if not hedgers:\n            return StepDecision(status=\"skip_day\", message=\"no valid hedge symbols\")\n\n        if ctx.price_history is None or len(ctx.price_history) < 3:\n            return StepDecision(status=\"skip_day\", message=\"insufficient history for hedge\")\n\n        # Estimate current portfolio delta/vega using trailing returns correlation\n        port_syms = [s for s in ctx.target_weights if s in ctx.price_history.columns]\n        if not port_syms:\n            return StepDecision()\n\n        rets = ctx.price_history[list(set(port_syms + hedgers))].pct_change().dropna()\n        if len(rets) < 3:\n            return StepDecision(status=\"skip_day\", message=\"insufficient returns for hedge\")\n\n        # Portfolio delta ~ sum(weight_i * beta_i), using beta = std_i as proxy\n        port_delta = 0.0\n        for s in port_syms:\n            if s in rets.columns:\n                port_delta += ctx.target_weights.get(s, 0.0) * float(rets[s].std())\n\n        delta_gap = self.target_delta - port_delta\n\n        # Build Jacobian: each hedger's marginal delta contribution\n        n = len(hedgers)\n        jacobian = np.zeros((1, n))\n        for j, h in enumerate(hedgers):\n            if h in rets.columns:\n                jacobian[0, j] = float(rets[h].std())\n\n        target_vec = np.array([delta_gap])\n        try:\n            adjustments, _, _, _ = np.linalg.lstsq(jacobian, target_vec, rcond=None)\n        except np.linalg.LinAlgError:\n            return StepDecision(message=\"hedge solve failed, weights unchanged\")\n\n        for j, h in enumerate(hedgers):\n            current = ctx.target_weights.get(h, 0.0)\n            ctx.target_weights[h] = current + float(adjustments[j])\n\n        return StepDecision(message=f\"hedged delta gap={delta_gap:.4f}\")\n\n\nclass Margin:\n    \"\"\"Simulate margin/leverage with interest charges and margin calls.\n\n    Allows total exposure to exceed cash by borrowing. Charges daily\n    interest on borrowed amount. If equity drops below maintenance_pct\n    of total exposure, forces liquidation via \"stop\".\n    \"\"\"\n\n    def __init__(\n        self,\n        leverage: float = 2.0,\n        interest_rate: float = 0.02,\n        maintenance_pct: float = 0.25,\n    ) -> None:\n        self.leverage = float(leverage)\n        self.interest_rate = float(interest_rate)\n        self.maintenance_pct = float(maintenance_pct)\n        self._borrowed = 0.0\n\n    def reset(self) -> None:\n        self._borrowed = 0.0\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        # Compute current stock value from positions\n        stock_value = 0.0\n        for sym, qty in ctx.positions.items():\n            if sym in ctx.prices.index and pd.notna(ctx.prices[sym]):\n                stock_value += float(qty) * float(ctx.prices[sym])\n        self._borrowed = max(0.0, stock_value - float(ctx.cash))\n\n        # Charge daily interest on borrowed amount\n        if self._borrowed > 0:\n            daily_interest = self.interest_rate / 252.0 * self._borrowed\n            ctx.cash = float(ctx.cash) - daily_interest\n            ctx.total_capital = float(ctx.total_capital) - daily_interest\n\n        # Margin call check: equity vs total exposure\n        equity = float(ctx.cash) + stock_value\n        exposure = stock_value\n        if exposure > 0 and equity / exposure < self.maintenance_pct:\n            return StepDecision(status=\"stop\", message=f\"margin call: equity/exposure={equity / exposure:.2%}\")\n\n        # Scale target weights by leverage factor\n        if ctx.target_weights:\n            ctx.target_weights = {s: w * self.leverage for s, w in ctx.target_weights.items()}\n\n        return StepDecision()\n\n\nclass CouponPayingPosition:\n    \"\"\"Inject periodic coupon cash flows into the portfolio.\n\n    Simulates a fixed-income position by adding coupon_amount to cash\n    at the specified frequency. Returns \"stop\" after maturity.\n    \"\"\"\n\n    _FREQUENCY_MONTHS = {\n        \"annual\": 12,\n        \"semi-annual\": 6,\n        \"quarterly\": 3,\n        \"monthly\": 1,\n    }\n\n    def __init__(\n        self,\n        coupon_amount: float,\n        frequency: str = \"semi-annual\",\n        start_date: str | pd.Timestamp | None = None,\n        maturity_date: str | pd.Timestamp | None = None,\n    ) -> None:\n        self.coupon_amount = float(coupon_amount)\n        if frequency not in self._FREQUENCY_MONTHS:\n            raise ValueError(f\"frequency must be one of {list(self._FREQUENCY_MONTHS)}\")\n        self.frequency = frequency\n        self._months = self._FREQUENCY_MONTHS[frequency]\n        self.start_date = pd.Timestamp(start_date) if start_date else None\n        self.maturity_date = pd.Timestamp(maturity_date) if maturity_date else None\n        self._last_coupon_month: tuple[int, int] | None = None\n\n    def reset(self) -> None:\n        self._last_coupon_month = None\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        # Check maturity\n        if self.maturity_date and ctx.date >= self.maturity_date:\n            ctx.cash = float(ctx.cash) + self.coupon_amount  # final coupon\n            ctx.total_capital = float(ctx.total_capital) + self.coupon_amount\n            return StepDecision(status=\"stop\", message=\"bond matured\")\n\n        # Check start date\n        if self.start_date and ctx.date < self.start_date:\n            return StepDecision()\n\n        # Check if this is a coupon month\n        month_key = (ctx.date.year, ctx.date.month)\n        if self._last_coupon_month == month_key:\n            return StepDecision()\n\n        # Determine if enough months have passed since last coupon\n        if self._last_coupon_month is not None:\n            last_y, last_m = self._last_coupon_month\n            months_elapsed = (ctx.date.year - last_y) * 12 + (ctx.date.month - last_m)\n            if months_elapsed < self._months:\n                return StepDecision()\n\n        # Pay coupon\n        self._last_coupon_month = month_key\n        ctx.cash = float(ctx.cash) + self.coupon_amount\n        ctx.total_capital = float(ctx.total_capital) + self.coupon_amount\n        return StepDecision(message=f\"coupon paid: ${self.coupon_amount:.2f}\")\n\n\nclass ReplayTransactions:\n    \"\"\"Replay a pre-recorded trade blotter through the pipeline.\n\n    Takes a DataFrame with columns: date, symbol, quantity\n    (positive=buy, negative=sell). On each matching date, executes\n    the recorded trades at current prices.\n    \"\"\"\n\n    def __init__(self, blotter: pd.DataFrame) -> None:\n        required = {\"date\", \"symbol\", \"quantity\"}\n        missing = required - set(blotter.columns)\n        if missing:\n            raise ValueError(f\"blotter missing columns: {missing}\")\n        self._blotter = blotter.copy()\n        self._blotter[\"date\"] = pd.to_datetime(self._blotter[\"date\"]).dt.normalize()\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        day_trades = self._blotter[self._blotter[\"date\"] == ctx.date.normalize()]\n        if day_trades.empty:\n            return StepDecision()\n\n        executed = 0\n        for _, trade in day_trades.iterrows():\n            sym = str(trade[\"symbol\"]).upper()\n            qty = float(trade[\"quantity\"])\n            if sym not in ctx.prices.index or pd.isna(ctx.prices[sym]):\n                continue\n            price = float(ctx.prices[sym])\n            if price <= 0:\n                continue\n            cost = qty * price\n            ctx.cash = float(ctx.cash) - cost\n            ctx.total_capital = float(ctx.total_capital)  # recalc handled by backtester\n            current_qty = ctx.positions.get(sym, 0.0)\n            new_qty = current_qty + qty\n            if new_qty == 0:\n                ctx.positions.pop(sym, None)\n            else:\n                ctx.positions[sym] = new_qty\n            executed += 1\n\n        return StepDecision(message=f\"replayed {executed} trades\")\n\n\n# ---------------------------------------------------------------------------\n# Position management\n# ---------------------------------------------------------------------------\n\nclass CloseDead:\n    \"\"\"Close positions where price has dropped to zero or is NaN.\n\n    Removes dead positions and frees up the capital (at zero value).\n    \"\"\"\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        dead = []\n        for sym, qty in ctx.positions.items():\n            if sym not in ctx.prices.index or pd.isna(ctx.prices[sym]) or float(ctx.prices[sym]) <= 0:\n                dead.append(sym)\n        for sym in dead:\n            del ctx.positions[sym]\n        if dead:\n            return StepDecision(message=f\"closed dead: {', '.join(dead)}\")\n        return StepDecision()\n\n\nclass ClosePositionsAfterDates:\n    \"\"\"Close specific positions on or after given dates.\n\n    *schedule* maps symbol names to the date after which they should be closed.\n    \"\"\"\n\n    def __init__(self, schedule: dict[str, str | pd.Timestamp]) -> None:\n        self._schedule = {s.upper(): pd.Timestamp(d).normalize() for s, d in schedule.items()}\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        closed = []\n        for sym, close_date in self._schedule.items():\n            if ctx.date.normalize() >= close_date and sym in ctx.positions:\n                del ctx.positions[sym]\n                closed.append(sym)\n        if closed:\n            return StepDecision(message=f\"closed after date: {', '.join(closed)}\")\n        return StepDecision()\n\n\nclass Require:\n    \"\"\"Guard: only continue if the wrapped algo returns 'continue'.\n\n    Unlike normal pipeline flow, ``Require`` runs the inner algo but does\n    NOT break the pipeline on skip — it only checks whether the algo *would*\n    have passed. Use it to conditionally gate downstream steps.\n    \"\"\"\n\n    def __init__(self, algo: Algo) -> None:\n        self._algo = algo\n\n    def reset(self) -> None:\n        if hasattr(self._algo, \"reset\"):\n            self._algo.reset()\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        decision = self._algo(ctx)\n        if decision.status != \"continue\":\n            return StepDecision(status=\"skip_day\", message=f\"requirement not met: {decision.message}\")\n        return StepDecision()\n\n\n# ---------------------------------------------------------------------------\n# Rebalancing algos\n# ---------------------------------------------------------------------------\n\nclass Rebalance:\n    \"\"\"Rebalance positions to target weights at current prices.\n\n    Performs a full liquidate-and-rebuy on each rebalance date.\n    \"\"\"\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        if not ctx.target_weights:\n            return StepDecision(status=\"skip_day\", message=\"no target weights\")\n        new_positions: dict[str, float] = {}\n        spent = 0.0\n        for sym, w in ctx.target_weights.items():\n            price = float(ctx.prices[sym])\n            if price <= 0:\n                continue\n            target_value = float(ctx.total_capital) * w\n            qty = float(np.floor(target_value / price))\n            new_positions[sym] = qty\n            spent += qty * price\n\n        ctx.positions.clear()\n        ctx.positions.update(new_positions)\n        ctx.cash = float(ctx.total_capital - spent)\n        return StepDecision()\n\n\nclass RebalanceOverTime:\n    \"\"\"Spread rebalancing over *n* periods to reduce market impact.\n\n    On each trigger, moves 1/n of the way from current to target weights.\n    Must be preceded by a scheduling algo and a weighting algo.\n    \"\"\"\n\n    def __init__(self, n: int = 5) -> None:\n        self._n = int(n)\n        self._target: dict[str, float] = {}\n        self._remaining = 0\n\n    def reset(self) -> None:\n        self._target = {}\n        self._remaining = 0\n\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        # If new target weights are set, start a new gradual rebalance\n        if ctx.target_weights and ctx.target_weights != self._target:\n            self._target = dict(ctx.target_weights)\n            self._remaining = self._n\n\n        if self._remaining <= 0 or not self._target:\n            return StepDecision(status=\"skip_day\", message=\"no gradual rebalance in progress\")\n\n        # Compute current weights from positions\n        total = float(ctx.total_capital)\n        if total <= 0:\n            return StepDecision(status=\"skip_day\", message=\"no capital\")\n\n        current_weights: dict[str, float] = {}\n        all_syms = set(self._target.keys()) | set(ctx.positions.keys())\n        for sym in all_syms:\n            qty = ctx.positions.get(sym, 0.0)\n            if sym in ctx.prices.index and pd.notna(ctx.prices[sym]):\n                current_weights[sym] = float(qty) * float(ctx.prices[sym]) / total\n            else:\n                current_weights[sym] = 0.0\n\n        # Move fraction of the way toward target\n        frac = 1.0 / self._remaining\n        blended: dict[str, float] = {}\n        for sym in all_syms:\n            cur = current_weights.get(sym, 0.0)\n            tgt = self._target.get(sym, 0.0)\n            blended[sym] = cur + frac * (tgt - cur)\n\n        # Apply blended weights\n        new_positions: dict[str, float] = {}\n        spent = 0.0\n        for sym, w in blended.items():\n            if sym not in ctx.prices.index or pd.isna(ctx.prices[sym]):\n                continue\n            price = float(ctx.prices[sym])\n            if price <= 0:\n                continue\n            target_value = total * w\n            qty = float(np.floor(target_value / price))\n            if qty > 0:\n                new_positions[sym] = qty\n                spent += qty * price\n\n        ctx.positions.clear()\n        ctx.positions.update(new_positions)\n        ctx.cash = float(total - spent)\n        self._remaining -= 1\n        return StepDecision()\n\n\nclass AlgoPipelineBacktester:\n    \"\"\"Simple stock backtester driven by composable pipeline algos.\"\"\"\n\n    def __init__(\n        self,\n        prices: pd.DataFrame,\n        algos: list[Algo],\n        initial_capital: float = 1_000_000.0,\n    ) -> None:\n        self.prices = prices.sort_index()\n        self.algos = algos\n        self.initial_capital = float(initial_capital)\n        self.logs: list[PipelineLogRow] = []\n\n    def run(self) -> pd.DataFrame:\n        self.logs = []\n        for algo in self.algos:\n            if hasattr(algo, \"reset\"):\n                algo.reset()\n        cash = float(self.initial_capital)\n        positions: dict[str, float] = {}\n        rows: list[dict[str, float | pd.Timestamp]] = []\n\n        all_dates = list(self.prices.index)\n        for i, (date, price_row) in enumerate(self.prices.iterrows()):\n            stocks_cap = float(sum(float(qty) * float(price_row[sym])\n                                   for sym, qty in positions.items()\n                                   if sym in price_row.index and pd.notna(price_row[sym])))\n            total_cap = cash + stocks_cap\n            # Price history up to current date (for algos that need lookback)\n            history = self.prices.iloc[:i + 1] if i > 0 else self.prices.iloc[:1]\n            ctx = PipelineContext(\n                date=pd.Timestamp(date),\n                prices=price_row,\n                total_capital=total_cap,\n                cash=cash,\n                positions=dict(positions),\n                price_history=history,\n            )\n\n            stop_all = False\n            for algo in self.algos:\n                decision = algo(ctx)\n                self.logs.append(\n                    PipelineLogRow(\n                        date=pd.Timestamp(date),\n                        step=algo.__class__.__name__,\n                        status=decision.status,\n                        message=decision.message,\n                    )\n                )\n                if decision.status == \"skip_day\":\n                    break\n                if decision.status == \"stop\":\n                    stop_all = True\n                    break\n\n            cash = float(ctx.cash)\n            positions = dict(ctx.positions)\n            stocks_cap = float(sum(float(qty) * float(price_row[sym])\n                                   for sym, qty in positions.items()\n                                   if sym in price_row.index and pd.notna(price_row[sym])))\n            total_cap = cash + stocks_cap\n            row: dict[str, float | pd.Timestamp] = {\n                \"date\": pd.Timestamp(date),\n                \"cash\": cash,\n                \"stocks capital\": stocks_cap,\n                \"total capital\": total_cap,\n            }\n            for sym, qty in positions.items():\n                row[f\"{sym} qty\"] = float(qty)\n            rows.append(row)\n\n            if stop_all:\n                break\n\n        if not rows:\n            balance = pd.DataFrame()\n            self.balance = balance\n            return balance\n        balance = pd.DataFrame(rows).set_index(\"date\")\n        if not balance.empty:\n            balance[\"% change\"] = balance[\"total capital\"].pct_change()\n            balance[\"accumulated return\"] = (1.0 + balance[\"% change\"]).cumprod()\n        self.balance = balance\n        return balance\n\n    def set_date_range(self, start=None, end=None):\n        \"\"\"Filter results to date range, return new BacktestStats.\"\"\"\n        from options_portfolio_backtester.analytics.stats import BacktestStats\n        return BacktestStats.from_balance_range(self.balance, start, end)\n\n    def logs_dataframe(self) -> pd.DataFrame:\n        if not self.logs:\n            return pd.DataFrame(columns=[\"date\", \"step\", \"status\", \"message\"])\n        return pd.DataFrame([{\n            \"date\": r.date,\n            \"step\": r.step,\n            \"status\": r.status,\n            \"message\": r.message,\n        } for r in self.logs])\n\n\n# ---------------------------------------------------------------------------\n# Random benchmarking\n# ---------------------------------------------------------------------------\n\n@dataclass(frozen=True)\nclass RandomBenchmarkResult:\n    \"\"\"Result of ``benchmark_random``: your strategy vs random portfolios.\"\"\"\n\n    strategy_return: float\n    random_returns: list[float]\n    percentile: float  # what % of random runs your strategy beat\n\n    @property\n    def mean_random(self) -> float:\n        return float(np.mean(self.random_returns))\n\n    @property\n    def std_random(self) -> float:\n        return float(np.std(self.random_returns))\n\n\ndef benchmark_random(\n    prices: pd.DataFrame,\n    strategy_algos: list[Algo],\n    n_random: int = 100,\n    initial_capital: float = 1_000_000.0,\n    seed: int = 42,\n) -> RandomBenchmarkResult:\n    \"\"\"Compare a strategy against *n_random* random-weight portfolios.\n\n    Runs the given strategy once, then runs *n_random* simulations with\n    ``SelectAll → WeighRandomly → Rebalance`` on the same price data.\n    Returns a ``RandomBenchmarkResult`` with the strategy's total return,\n    the distribution of random returns, and the percentile rank.\n    \"\"\"\n    # Run the target strategy\n    bt = AlgoPipelineBacktester(prices=prices, algos=strategy_algos, initial_capital=initial_capital)\n    bal = bt.run()\n    if bal.empty:\n        strat_ret = 0.0\n    else:\n        strat_ret = float(bal[\"total capital\"].iloc[-1] / bal[\"total capital\"].iloc[0] - 1)\n\n    # Run random strategies\n    random_rets: list[float] = []\n    for i in range(n_random):\n        random_algos: list[Algo] = [\n            RunMonthly(),\n            SelectAll(),\n            WeighRandomly(seed=seed + i),\n            Rebalance(),\n        ]\n        rbt = AlgoPipelineBacktester(prices=prices, algos=random_algos, initial_capital=initial_capital)\n        rbal = rbt.run()\n        if rbal.empty:\n            random_rets.append(0.0)\n        else:\n            random_rets.append(float(rbal[\"total capital\"].iloc[-1] / rbal[\"total capital\"].iloc[0] - 1))\n\n    beaten = sum(1 for r in random_rets if strat_ret > r)\n    pct = beaten / max(len(random_rets), 1) * 100\n\n    return RandomBenchmarkResult(\n        strategy_return=strat_ret,\n        random_returns=random_rets,\n        percentile=pct,\n    )\n"
  },
  {
    "path": "options_portfolio_backtester/engine/strategy_tree.py",
    "content": "\"\"\"Hierarchical strategy tree runner.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\n\nimport pandas as pd\n\nfrom options_portfolio_backtester.engine.engine import BacktestEngine\n\n\n@dataclass\nclass StrategyTreeNode:\n    \"\"\"Node in a capital-allocation strategy tree.\"\"\"\n\n    name: str\n    weight: float = 1.0\n    max_share: float | None = None\n    engine: BacktestEngine | None = None\n    children: list[\"StrategyTreeNode\"] = field(default_factory=list)\n\n    def __post_init__(self) -> None:\n        if self.engine is not None and self.children:\n            raise ValueError(\n                f\"StrategyTreeNode '{self.name}' has both engine and children; \"\n                \"a node must be either a leaf (engine) or a branch (children), not both\"\n            )\n\n    def is_leaf(self) -> bool:\n        return self.engine is not None\n\n    def to_dot(self) -> str:\n        \"\"\"Generate Graphviz DOT string for this subtree.\"\"\"\n        lines = [\n            \"digraph StrategyTree {\",\n            \"  rankdir=TB;\",\n            '  node [style=filled, fillcolor=lightyellow];',\n        ]\n        self._dot_recursive(lines, parent_id=None)\n        lines.append(\"}\")\n        return \"\\n\".join(lines)\n\n    def _dot_recursive(self, lines: list[str], parent_id: str | None) -> None:\n        node_id = f\"n{id(self)}\"\n        label = f\"{self.name}\\\\nw={self.weight}\"\n        if self.max_share is not None:\n            label += f\"\\\\nmax={self.max_share}\"\n        shape = \"ellipse\" if self.is_leaf() else \"box\"\n        lines.append(f'  {node_id} [label=\"{label}\", shape={shape}];')\n        if parent_id:\n            lines.append(f\"  {parent_id} -> {node_id};\")\n        for child in self.children:\n            child._dot_recursive(lines, node_id)\n\n\nclass StrategyTreeEngine:\n    \"\"\"Run leaf engines with capital shares implied by tree weights.\"\"\"\n\n    def __init__(self, root: StrategyTreeNode, initial_capital: int = 1_000_000) -> None:\n        self.root = root\n        self.initial_capital = initial_capital\n        self.throttles: dict[str, dict[str, float]] = {}\n\n    def to_dot(self) -> str:\n        \"\"\"Generate Graphviz DOT string for the strategy tree.\"\"\"\n        return self.root.to_dot()\n\n    def _leaf_shares(self, node: StrategyTreeNode, parent_share: float) -> list[tuple[StrategyTreeNode, float]]:\n        if node.is_leaf():\n            capped = min(parent_share, node.max_share) if node.max_share is not None else parent_share\n            if capped < parent_share:\n                self.throttles[node.name] = {\"requested_share\": parent_share, \"applied_share\": capped}\n            return [(node, capped)]\n        if not node.children:\n            return []\n        total = sum(c.weight for c in node.children)\n        if total <= 0:\n            return []\n        out: list[tuple[StrategyTreeNode, float]] = []\n        for child in node.children:\n            out.extend(self._leaf_shares(child, parent_share * (child.weight / total)))\n        return out\n\n    def run(self, rebalance_freq: int = 0, monthly: bool = False, sma_days: int | None = None) -> dict[str, pd.DataFrame]:\n        leaf_allocs = self._leaf_shares(self.root, 1.0)\n        results: dict[str, pd.DataFrame] = {}\n        self.leaf_weights = {leaf.name: w for leaf, w in leaf_allocs}\n        self.attribution = {}\n        allocated_share = float(sum(w for _, w in leaf_allocs))\n        unallocated_share = max(0.0, 1.0 - allocated_share)\n\n        balances: list[pd.DataFrame] = []\n        for leaf, share in leaf_allocs:\n            cap = round(self.initial_capital * share)\n            saved_capital = leaf.engine.initial_capital\n            leaf.engine.initial_capital = cap\n            trade_log = leaf.engine.run(rebalance_freq=rebalance_freq, monthly=monthly, sma_days=sma_days)\n            leaf.engine.initial_capital = saved_capital\n            results[leaf.name] = trade_log\n            self.attribution[leaf.name] = {\n                \"weight\": share,\n                \"capital\": cap,\n            }\n            b = leaf.engine.balance[[\"total capital\"]].rename(columns={\"total capital\": f\"{leaf.name}_capital\"})\n            balances.append(b)\n\n        if balances:\n            self.balance = pd.concat(balances, axis=1)\n            cap_cols = [c for c in self.balance.columns if c.endswith(\"_capital\")]\n            self.balance[\"unallocated_cash\"] = float(self.initial_capital * unallocated_share)\n            self.balance[\"total capital\"] = self.balance[cap_cols].sum(axis=1) + self.balance[\"unallocated_cash\"]\n            self.balance[\"% change\"] = self.balance[\"total capital\"].pct_change()\n            self.balance[\"accumulated return\"] = (1.0 + self.balance[\"% change\"]).cumprod()\n        else:\n            self.balance = pd.DataFrame()\n\n        return results\n"
  },
  {
    "path": "options_portfolio_backtester/execution/__init__.py",
    "content": ""
  },
  {
    "path": "options_portfolio_backtester/execution/_rust_bridge.py",
    "content": "\"\"\"Rust execution functions from _ob_rust.\"\"\"\n\nfrom options_portfolio_backtester._ob_rust import (\n    rust_option_cost,\n    rust_stock_cost,\n    rust_fill_price,\n    rust_nearest_delta_index,\n    rust_max_value_index,\n    rust_risk_check,\n)\n"
  },
  {
    "path": "options_portfolio_backtester/execution/cost_model.py",
    "content": "\"\"\"Transaction cost models for options and stocks.\"\"\"\n\nfrom __future__ import annotations\n\nfrom abc import ABC, abstractmethod\n\nfrom options_portfolio_backtester.execution._rust_bridge import (\n    rust_option_cost, rust_stock_cost,\n)\n\n\nclass TransactionCostModel(ABC):\n    \"\"\"Base class for all transaction cost models.\"\"\"\n\n    @abstractmethod\n    def option_cost(self, price: float, quantity: int, shares_per_contract: int) -> float:\n        \"\"\"Return total commission for an options trade.\"\"\"\n        ...\n\n    @abstractmethod\n    def stock_cost(self, price: float, quantity: float) -> float:\n        \"\"\"Return total commission for a stock trade.\"\"\"\n        ...\n\n\nclass NoCosts(TransactionCostModel):\n    \"\"\"Zero transaction costs — matches original behavior.\"\"\"\n\n    def option_cost(self, price: float, quantity: int, shares_per_contract: int) -> float:\n        return 0.0\n\n    def stock_cost(self, price: float, quantity: float) -> float:\n        return 0.0\n\n    def to_rust_config(self) -> dict:\n        return {\"type\": \"NoCosts\"}\n\n\nclass PerContractCommission(TransactionCostModel):\n    \"\"\"Fixed per-contract commission (e.g., $0.65/contract for IBKR).\"\"\"\n\n    def __init__(self, rate: float = 0.65, stock_rate: float = 0.005) -> None:\n        self.rate = rate\n        self.stock_rate = stock_rate  # per-share\n\n    def option_cost(self, price: float, quantity: int, shares_per_contract: int) -> float:\n        return rust_option_cost(\"PerContract\", self.rate, self.stock_rate, [], price, float(quantity), shares_per_contract)\n\n    def stock_cost(self, price: float, quantity: float) -> float:\n        return rust_stock_cost(\"PerContract\", self.rate, self.stock_rate, [], price, float(quantity))\n\n    def to_rust_config(self) -> dict:\n        return {\"type\": \"PerContract\", \"rate\": self.rate, \"stock_rate\": self.stock_rate}\n\n\nclass TieredCommission(TransactionCostModel):\n    \"\"\"Tiered commission schedule with volume discounts.\n\n    Tiers are (max_contracts, rate) pairs sorted by max_contracts ascending.\n    Contracts beyond the last tier use the last tier's rate.\n    \"\"\"\n\n    def __init__(self, tiers: list[tuple[int, float]] | None = None,\n                 stock_rate: float = 0.005) -> None:\n        # Default: IBKR-style tiers\n        self.tiers = tiers or [\n            (10_000, 0.65),\n            (50_000, 0.50),\n            (100_000, 0.25),\n        ]\n        self.stock_rate = stock_rate\n\n    def option_cost(self, price: float, quantity: int, shares_per_contract: int) -> float:\n        return rust_option_cost(\"Tiered\", 0.0, self.stock_rate, self.tiers, price, float(quantity), shares_per_contract)\n\n    def stock_cost(self, price: float, quantity: float) -> float:\n        return rust_stock_cost(\"Tiered\", 0.0, self.stock_rate, self.tiers, price, float(quantity))\n\n    def to_rust_config(self) -> dict:\n        return {\n            \"type\": \"Tiered\",\n            \"tiers\": [(max_qty, rate) for max_qty, rate in self.tiers],\n            \"stock_rate\": self.stock_rate,\n        }\n\n\nclass SpreadSlippage(TransactionCostModel):\n    \"\"\"Model slippage as a fraction of the bid-ask spread.\n\n    Example: SpreadSlippage(pct=0.5) means you pay half the spread on top\n    of the execution price.\n    \"\"\"\n\n    def __init__(self, pct: float = 0.5) -> None:\n        assert 0.0 <= pct <= 1.0\n        self.pct = pct\n\n    def option_cost(self, price: float, quantity: int, shares_per_contract: int) -> float:\n        # Slippage is modeled separately via fill_model; this returns 0\n        # so it can be composed with a commission model.\n        return 0.0\n\n    def stock_cost(self, price: float, quantity: float) -> float:\n        return 0.0\n\n    def slippage(self, bid: float, ask: float, quantity: int, shares_per_contract: int) -> float:\n        \"\"\"Compute dollar slippage from the spread.\"\"\"\n        spread = abs(ask - bid)\n        return self.pct * spread * abs(quantity) * shares_per_contract\n"
  },
  {
    "path": "options_portfolio_backtester/execution/fill_model.py",
    "content": "\"\"\"Fill models — determine the execution price for trades.\"\"\"\n\nfrom __future__ import annotations\n\nfrom abc import ABC, abstractmethod\n\nimport pandas as pd\n\nfrom options_portfolio_backtester.core.types import Direction\nfrom options_portfolio_backtester.execution._rust_bridge import rust_fill_price\n\n\nclass FillModel(ABC):\n    \"\"\"Determines the price at which a trade is filled.\"\"\"\n\n    @abstractmethod\n    def get_fill_price(self, row: pd.Series, direction: Direction) -> float:\n        \"\"\"Return the execution price for a given option quote row and direction.\"\"\"\n        ...\n\n\nclass MarketAtBidAsk(FillModel):\n    \"\"\"Fill at the bid (sell) or ask (buy) — matches original behavior.\"\"\"\n\n    def get_fill_price(self, row: pd.Series, direction: Direction) -> float:\n        return float(row[direction.price_column])\n\n    def to_rust_config(self) -> dict:\n        return {\"type\": \"MarketAtBidAsk\"}\n\n\nclass MidPrice(FillModel):\n    \"\"\"Fill at the midpoint of bid and ask.\"\"\"\n\n    def get_fill_price(self, row: pd.Series, direction: Direction) -> float:\n        bid = float(row[\"bid\"])\n        ask = float(row[\"ask\"])\n        return (bid + ask) / 2.0\n\n    def to_rust_config(self) -> dict:\n        return {\"type\": \"MidPrice\"}\n\n\nclass VolumeAwareFill(FillModel):\n    \"\"\"Fill price that adjusts for volume impact.\n\n    For low-volume contracts, the fill is pushed toward the less favorable\n    price. Above `full_volume_threshold`, the fill is at bid/ask.\n    \"\"\"\n\n    def __init__(self, full_volume_threshold: int = 100) -> None:\n        self.full_volume_threshold = full_volume_threshold\n\n    def get_fill_price(self, row: pd.Series, direction: Direction) -> float:\n        bid = float(row[\"bid\"])\n        ask = float(row[\"ask\"])\n        is_buy = direction == Direction.BUY\n        vol_raw = row.get(\"volume\")\n        volume = None if vol_raw is None or (isinstance(vol_raw, float) and vol_raw != vol_raw) else float(vol_raw)\n        return rust_fill_price(\"VolumeAware\", self.full_volume_threshold, bid, ask, volume, is_buy)\n\n    def to_rust_config(self) -> dict:\n        return {\"type\": \"VolumeAware\", \"full_volume_threshold\": self.full_volume_threshold}\n"
  },
  {
    "path": "options_portfolio_backtester/execution/signal_selector.py",
    "content": "\"\"\"Signal selectors — choose which contract to trade from a set of candidates.\"\"\"\n\nfrom __future__ import annotations\n\nfrom abc import ABC, abstractmethod\n\nimport pandas as pd\n\nfrom options_portfolio_backtester.execution._rust_bridge import (\n    rust_nearest_delta_index, rust_max_value_index,\n)\n\n\nclass SignalSelector(ABC):\n    \"\"\"Picks one entry signal from a DataFrame of candidates.\"\"\"\n\n    @property\n    def column_requirements(self) -> list[str]:\n        \"\"\"Extra columns needed from raw options data beyond standard signal fields.\"\"\"\n        return []\n\n    @abstractmethod\n    def select(self, candidates: pd.DataFrame) -> pd.Series:\n        \"\"\"Return the single row (as Series) to execute from candidates.\n\n        Args:\n            candidates: DataFrame of entry signals, pre-sorted if entry_sort was set.\n\n        Returns:\n            A single row (pd.Series) from candidates.\n        \"\"\"\n        ...\n\n\nclass FirstMatch(SignalSelector):\n    \"\"\"Pick the first row — matches original iloc[0] behavior.\"\"\"\n\n    def select(self, candidates: pd.DataFrame) -> pd.Series:\n        return candidates.iloc[0]\n\n    def to_rust_config(self) -> dict:\n        return {\"type\": \"FirstMatch\"}\n\n\nclass NearestDelta(SignalSelector):\n    \"\"\"Pick the contract whose delta is closest to `target_delta`.\n\n    Requires a 'delta' column in candidates.\n    \"\"\"\n\n    def __init__(self, target_delta: float = -0.30, delta_column: str = \"delta\") -> None:\n        self.target_delta = target_delta\n        self.delta_column = delta_column\n\n    @property\n    def column_requirements(self) -> list[str]:\n        return [self.delta_column]\n\n    def select(self, candidates: pd.DataFrame) -> pd.Series:\n        if self.delta_column not in candidates.columns:\n            return candidates.iloc[0]\n        values = candidates[self.delta_column].tolist()\n        idx = rust_nearest_delta_index(values, self.target_delta)\n        return candidates.iloc[idx]\n\n    def to_rust_config(self) -> dict:\n        return {\"type\": \"NearestDelta\", \"target\": self.target_delta, \"column\": self.delta_column}\n\n\nclass MaxOpenInterest(SignalSelector):\n    \"\"\"Pick the contract with the highest open interest (proxy for liquidity).\n\n    Requires an 'openinterest' or 'open_interest' column.\n    \"\"\"\n\n    def __init__(self, oi_column: str = \"openinterest\") -> None:\n        self.oi_column = oi_column\n\n    @property\n    def column_requirements(self) -> list[str]:\n        return [self.oi_column]\n\n    def select(self, candidates: pd.DataFrame) -> pd.Series:\n        if self.oi_column not in candidates.columns:\n            return candidates.iloc[0]\n        values = candidates[self.oi_column].astype(float).tolist()\n        idx = rust_max_value_index(values)\n        return candidates.iloc[idx]\n\n    def to_rust_config(self) -> dict:\n        return {\"type\": \"MaxOpenInterest\", \"column\": self.oi_column}\n"
  },
  {
    "path": "options_portfolio_backtester/execution/sizer.py",
    "content": "\"\"\"Position sizing models — determine how many contracts to trade.\"\"\"\n\nfrom __future__ import annotations\n\nfrom abc import ABC, abstractmethod\n\n\nclass PositionSizer(ABC):\n    \"\"\"Determines the number of contracts to trade.\"\"\"\n\n    @abstractmethod\n    def size(self, cost_per_contract: float, available_capital: float,\n             total_capital: float) -> int:\n        \"\"\"Return the number of contracts to trade.\n\n        Args:\n            cost_per_contract: Dollar cost for one contract (absolute value).\n            available_capital: Capital allocated to this trade.\n            total_capital: Total portfolio value.\n        \"\"\"\n        ...\n\n\nclass CapitalBased(PositionSizer):\n    \"\"\"Buy as many contracts as the allocation allows — matches original behavior.\n\n    qty = available_capital // cost_per_contract\n    \"\"\"\n\n    def size(self, cost_per_contract: float, available_capital: float,\n             total_capital: float) -> int:\n        if cost_per_contract == 0:\n            return 0\n        return int(available_capital // abs(cost_per_contract))\n\n\nclass FixedQuantity(PositionSizer):\n    \"\"\"Always trade a fixed number of contracts.\"\"\"\n\n    def __init__(self, quantity: int = 1) -> None:\n        self.quantity = quantity\n\n    def size(self, cost_per_contract: float, available_capital: float,\n             total_capital: float) -> int:\n        if abs(cost_per_contract) * self.quantity > available_capital:\n            return int(available_capital // abs(cost_per_contract)) if cost_per_contract != 0 else 0\n        return self.quantity\n\n\nclass FixedDollar(PositionSizer):\n    \"\"\"Size positions to a fixed dollar amount.\"\"\"\n\n    def __init__(self, amount: float = 10_000.0) -> None:\n        self.amount = amount\n\n    def size(self, cost_per_contract: float, available_capital: float,\n             total_capital: float) -> int:\n        if cost_per_contract == 0:\n            return 0\n        target = min(self.amount, available_capital)\n        return int(target // abs(cost_per_contract))\n\n\nclass PercentOfPortfolio(PositionSizer):\n    \"\"\"Size positions as a percentage of total portfolio value.\"\"\"\n\n    def __init__(self, pct: float = 0.01) -> None:\n        assert 0.0 < pct <= 1.0\n        self.pct = pct\n\n    def size(self, cost_per_contract: float, available_capital: float,\n             total_capital: float) -> int:\n        if cost_per_contract == 0:\n            return 0\n        target = min(self.pct * total_capital, available_capital)\n        return int(target // abs(cost_per_contract))\n"
  },
  {
    "path": "options_portfolio_backtester/portfolio/__init__.py",
    "content": ""
  },
  {
    "path": "options_portfolio_backtester/portfolio/greeks.py",
    "content": "\"\"\"Portfolio-level Greeks aggregation.\"\"\"\n\nfrom __future__ import annotations\n\nfrom options_portfolio_backtester.core.types import Greeks\nfrom options_portfolio_backtester.portfolio.position import OptionPosition\n\n\ndef aggregate_greeks(\n    positions: dict[int, OptionPosition],\n    leg_greeks_by_position: dict[int, dict[str, Greeks]],\n) -> Greeks:\n    \"\"\"Compute portfolio-level Greeks by summing across all positions.\n\n    Args:\n        positions: {position_id: OptionPosition}.\n        leg_greeks_by_position: {position_id: {leg_name: Greeks}}.\n\n    Returns:\n        Total portfolio Greeks.\n    \"\"\"\n    total = Greeks()\n    for pid, pos in positions.items():\n        pos_greeks = leg_greeks_by_position.get(pid, {})\n        total = total + pos.greeks(pos_greeks)\n    return total\n"
  },
  {
    "path": "options_portfolio_backtester/portfolio/portfolio.py",
    "content": "\"\"\"Portfolio — clean replacement for MultiIndex DataFrames.\n\nUses plain dicts and dataclasses instead of MultiIndex DataFrames for\ninventory tracking. Simpler, extensible, debuggable.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\nfrom typing import Any\n\nfrom options_portfolio_backtester.core.types import Greeks\nfrom options_portfolio_backtester.portfolio.position import OptionPosition\nfrom options_portfolio_backtester.portfolio.greeks import aggregate_greeks\n\n\n@dataclass\nclass StockHolding:\n    \"\"\"A stock position in the portfolio.\"\"\"\n    symbol: str\n    quantity: float\n    cost_basis: float  # average price paid\n\n\nclass Portfolio:\n    \"\"\"Portfolio state — cash, option positions, stock holdings.\n\n    Replaces the old MultiIndex _options_inventory and _stocks_inventory\n    DataFrames with typed, inspectable data structures.\n    \"\"\"\n\n    def __init__(self, initial_cash: float = 0.0) -> None:\n        self.cash: float = initial_cash\n        self.option_positions: dict[int, OptionPosition] = {}\n        self.stock_holdings: dict[str, StockHolding] = {}\n        self._next_position_id: int = 0\n\n    def next_position_id(self) -> int:\n        pid = self._next_position_id\n        self._next_position_id += 1\n        return pid\n\n    # -- Option positions --\n\n    def add_option_position(self, pos: OptionPosition) -> None:\n        self.option_positions[pos.position_id] = pos\n\n    def remove_option_position(self, position_id: int) -> OptionPosition | None:\n        return self.option_positions.pop(position_id, None)\n\n    def options_value(self, current_prices: dict[int, dict[str, float]],\n                      shares_per_contract: int) -> float:\n        \"\"\"Total mark-to-market value of all option positions.\n\n        Args:\n            current_prices: {position_id: {leg_name: exit_price}}.\n            shares_per_contract: Contract multiplier.\n        \"\"\"\n        total = 0.0\n        for pid, pos in self.option_positions.items():\n            prices = current_prices.get(pid, {})\n            total += pos.current_value(prices, shares_per_contract)\n        return total\n\n    # -- Stock holdings --\n\n    def set_stock_holding(self, symbol: str, quantity: float,\n                          price: float) -> None:\n        self.stock_holdings[symbol] = StockHolding(\n            symbol=symbol, quantity=quantity, cost_basis=price,\n        )\n\n    def clear_stock_holdings(self) -> None:\n        self.stock_holdings.clear()\n\n    def stocks_value(self, current_prices: dict[str, float]) -> float:\n        \"\"\"Total value of stock holdings at current prices.\"\"\"\n        total = 0.0\n        for symbol, holding in self.stock_holdings.items():\n            price = current_prices.get(symbol, holding.cost_basis)\n            total += holding.quantity * price\n        return total\n\n    # -- Portfolio totals --\n\n    def total_value(self, stock_prices: dict[str, float],\n                    option_prices: dict[int, dict[str, float]],\n                    shares_per_contract: int) -> float:\n        \"\"\"Total portfolio value: cash + stocks + options.\"\"\"\n        return (self.cash\n                + self.stocks_value(stock_prices)\n                + self.options_value(option_prices, shares_per_contract))\n\n    def portfolio_greeks(self,\n                         leg_greeks_by_position: dict[int, dict[str, Greeks]]) -> Greeks:\n        \"\"\"Aggregate Greeks across all option positions.\"\"\"\n        return aggregate_greeks(self.option_positions, leg_greeks_by_position)\n"
  },
  {
    "path": "options_portfolio_backtester/portfolio/position.py",
    "content": "\"\"\"Option position and position leg — replaces MultiIndex inventory rows.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\nfrom typing import Any\n\nfrom options_portfolio_backtester.core.types import (\n    Direction, OptionType, Order, Greeks, get_order, Signal,\n)\n\n\n@dataclass\nclass PositionLeg:\n    \"\"\"A single leg within an option position.\"\"\"\n    name: str\n    contract_id: str\n    underlying: str\n    expiration: Any  # pd.Timestamp\n    option_type: OptionType\n    strike: float\n    entry_price: float\n    direction: Direction\n    order: Order\n\n    @property\n    def exit_order(self) -> Order:\n        return ~self.order\n\n    def current_value(self, current_price: float, quantity: int,\n                      shares_per_contract: int) -> float:\n        \"\"\"Mark-to-market value of this leg.\n\n        For a BUY leg, value = current_price * qty * spc (we own it).\n        For a SELL leg, value = -current_price * qty * spc (we owe it).\n        \"\"\"\n        sign = -1 if self.direction == Direction.SELL else 1\n        return sign * current_price * quantity * shares_per_contract\n\n\n@dataclass\nclass OptionPosition:\n    \"\"\"A multi-leg option position.\n\n    Replaces one row in the old MultiIndex _options_inventory DataFrame.\n    \"\"\"\n    position_id: int\n    legs: dict[str, PositionLeg] = field(default_factory=dict)\n    quantity: int = 0\n    entry_cost: float = 0.0  # total cost at entry (negative for debit)\n    entry_date: Any = None  # pd.Timestamp\n\n    def add_leg(self, leg: PositionLeg) -> None:\n        self.legs[leg.name] = leg\n\n    def current_value(self, current_prices: dict[str, float],\n                      shares_per_contract: int) -> float:\n        \"\"\"Total MTM value of this position across all legs.\n\n        Args:\n            current_prices: {leg_name: exit_price} for each leg.\n            shares_per_contract: Contract multiplier.\n        \"\"\"\n        total = 0.0\n        for leg_name, leg in self.legs.items():\n            price = current_prices.get(leg_name, 0.0)\n            total += leg.current_value(price, self.quantity, shares_per_contract)\n        return total\n\n    def greeks(self, leg_greeks: dict[str, Greeks]) -> Greeks:\n        \"\"\"Aggregate Greeks across all legs, scaled by quantity.\n\n        Args:\n            leg_greeks: {leg_name: Greeks} for each leg.\n        \"\"\"\n        total = Greeks()\n        for leg_name, leg in self.legs.items():\n            g = leg_greeks.get(leg_name, Greeks())\n            sign = 1 if leg.direction == Direction.BUY else -1\n            total = total + g * (sign * self.quantity)\n        return total\n"
  },
  {
    "path": "options_portfolio_backtester/portfolio/risk.py",
    "content": "\"\"\"Risk management — constraints checked before entering positions.\"\"\"\n\nfrom __future__ import annotations\n\nfrom abc import ABC, abstractmethod\nfrom dataclasses import dataclass\n\nfrom options_portfolio_backtester.core.types import Greeks\nfrom options_portfolio_backtester.execution._rust_bridge import rust_risk_check\n\n\nclass RiskConstraint(ABC):\n    \"\"\"A single risk constraint.\"\"\"\n\n    @abstractmethod\n    def check(self, current_greeks: Greeks, proposed_greeks: Greeks,\n              portfolio_value: float, peak_value: float) -> bool:\n        \"\"\"Return True if the trade is allowed, False if it violates the constraint.\"\"\"\n        ...\n\n    @abstractmethod\n    def describe(self) -> str:\n        \"\"\"Human-readable description of the constraint.\"\"\"\n        ...\n\n\ndef _greeks_list(g: Greeks) -> list[float]:\n    return [g.delta, g.gamma, g.theta, g.vega]\n\n\nclass MaxDelta(RiskConstraint):\n    \"\"\"Reject trades that would push portfolio delta beyond a limit.\"\"\"\n\n    def __init__(self, limit: float = 100.0) -> None:\n        self.limit = limit\n\n    def check(self, current_greeks: Greeks, proposed_greeks: Greeks,\n              portfolio_value: float, peak_value: float) -> bool:\n        return rust_risk_check(\n            \"MaxDelta\", self.limit,\n            _greeks_list(current_greeks), _greeks_list(proposed_greeks),\n            portfolio_value, peak_value,\n        )\n\n    def describe(self) -> str:\n        return f\"MaxDelta(limit={self.limit})\"\n\n    def to_rust_config(self) -> dict:\n        return {\"type\": \"MaxDelta\", \"limit\": self.limit}\n\n\nclass MaxVega(RiskConstraint):\n    \"\"\"Reject trades that would push portfolio vega beyond a limit.\"\"\"\n\n    def __init__(self, limit: float = 50.0) -> None:\n        self.limit = limit\n\n    def check(self, current_greeks: Greeks, proposed_greeks: Greeks,\n              portfolio_value: float, peak_value: float) -> bool:\n        return rust_risk_check(\n            \"MaxVega\", self.limit,\n            _greeks_list(current_greeks), _greeks_list(proposed_greeks),\n            portfolio_value, peak_value,\n        )\n\n    def describe(self) -> str:\n        return f\"MaxVega(limit={self.limit})\"\n\n    def to_rust_config(self) -> dict:\n        return {\"type\": \"MaxVega\", \"limit\": self.limit}\n\n\nclass MaxDrawdown(RiskConstraint):\n    \"\"\"Reject new entries if portfolio drawdown exceeds a threshold.\"\"\"\n\n    def __init__(self, max_dd_pct: float = 0.20) -> None:\n        self.max_dd_pct = max_dd_pct\n\n    def check(self, current_greeks: Greeks, proposed_greeks: Greeks,\n              portfolio_value: float, peak_value: float) -> bool:\n        return rust_risk_check(\n            \"MaxDrawdown\", self.max_dd_pct,\n            _greeks_list(current_greeks), _greeks_list(proposed_greeks),\n            portfolio_value, peak_value,\n        )\n\n    def describe(self) -> str:\n        return f\"MaxDrawdown(max_dd_pct={self.max_dd_pct})\"\n\n    def to_rust_config(self) -> dict:\n        return {\"type\": \"MaxDrawdown\", \"max_dd_pct\": self.max_dd_pct}\n\n\nclass RiskManager:\n    \"\"\"Evaluates a set of risk constraints before allowing a trade.\"\"\"\n\n    def __init__(self, constraints: list[RiskConstraint] | None = None) -> None:\n        self.constraints = constraints or []\n\n    def add_constraint(self, constraint: RiskConstraint) -> None:\n        self.constraints.append(constraint)\n\n    def is_allowed(self, current_greeks: Greeks, proposed_greeks: Greeks,\n                   portfolio_value: float, peak_value: float) -> tuple[bool, str]:\n        \"\"\"Check all constraints. Returns (allowed, reason).\"\"\"\n        for c in self.constraints:\n            if not c.check(current_greeks, proposed_greeks,\n                          portfolio_value, peak_value):\n                return False, c.describe()\n        return True, \"\"\n"
  },
  {
    "path": "options_portfolio_backtester/strategy/__init__.py",
    "content": ""
  },
  {
    "path": "options_portfolio_backtester/strategy/presets.py",
    "content": "\"\"\"Pre-built strategy constructors for common options strategies.\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING\n\nfrom options_portfolio_backtester.core.types import Direction, OptionType\nfrom options_portfolio_backtester.strategy.strategy import Strategy\nfrom options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\n\nif TYPE_CHECKING:\n    from options_portfolio_backtester.data.schema import Schema\n\n\ndef strangle(\n    schema: \"Schema\",\n    underlying: str,\n    direction: Direction,\n    dte_range: tuple[int, int],\n    dte_exit: int,\n    otm_pct: float = 0.0,\n    pct_tolerance: float = 1.0,\n    exit_thresholds: tuple[float, float] = (float(\"inf\"), float(\"inf\")),\n) -> Strategy:\n    \"\"\"Build a strangle (long or short) strategy.\"\"\"\n    strat = Strategy(schema)\n\n    otm_lo = (otm_pct - pct_tolerance) / 100\n    otm_hi = (otm_pct + pct_tolerance) / 100\n\n    call_leg = StrategyLeg(\"leg_1\", schema, option_type=OptionType.CALL, direction=direction)\n    call_leg.entry_filter = (\n        (schema.underlying == underlying)\n        & (schema.dte >= dte_range[0])\n        & (schema.dte <= dte_range[1])\n        & (schema.strike >= schema.underlying_last * (1 + otm_lo))\n        & (schema.strike <= schema.underlying_last * (1 + otm_hi))\n    )\n    call_leg.exit_filter = schema.dte <= dte_exit\n\n    put_leg = StrategyLeg(\"leg_2\", schema, option_type=OptionType.PUT, direction=direction)\n    put_leg.entry_filter = (\n        (schema.underlying == underlying)\n        & (schema.dte >= dte_range[0])\n        & (schema.dte <= dte_range[1])\n        & (schema.strike <= schema.underlying_last * (1 - otm_lo))\n        & (schema.strike >= schema.underlying_last * (1 - otm_hi))\n    )\n    put_leg.exit_filter = schema.dte <= dte_exit\n\n    strat.add_legs([call_leg, put_leg])\n    strat.add_exit_thresholds(exit_thresholds[0], exit_thresholds[1])\n    return strat\n\n\ndef iron_condor(\n    schema: \"Schema\",\n    underlying: str,\n    dte_range: tuple[int, int],\n    dte_exit: int,\n    short_delta_call: float = 0.30,\n    short_delta_put: float = -0.30,\n    wing_width: float = 5.0,\n    exit_thresholds: tuple[float, float] = (float(\"inf\"), float(\"inf\")),\n) -> Strategy:\n    \"\"\"Build a short iron condor (sell inner, buy outer wings).\n\n    This is a simplified version using strike offsets; for delta-based\n    selection, use a NearestDelta signal_selector on each leg.\n    \"\"\"\n    strat = Strategy(schema)\n\n    # Short call (inner)\n    sc = StrategyLeg(\"leg_1\", schema, option_type=OptionType.CALL, direction=Direction.SELL)\n    sc.entry_filter = (\n        (schema.underlying == underlying)\n        & (schema.dte >= dte_range[0])\n        & (schema.dte <= dte_range[1])\n    )\n    sc.exit_filter = schema.dte <= dte_exit\n\n    # Long call (outer wing)\n    lc = StrategyLeg(\"leg_2\", schema, option_type=OptionType.CALL, direction=Direction.BUY)\n    lc.entry_filter = (\n        (schema.underlying == underlying)\n        & (schema.dte >= dte_range[0])\n        & (schema.dte <= dte_range[1])\n    )\n    lc.exit_filter = schema.dte <= dte_exit\n\n    # Short put (inner)\n    sp = StrategyLeg(\"leg_3\", schema, option_type=OptionType.PUT, direction=Direction.SELL)\n    sp.entry_filter = (\n        (schema.underlying == underlying)\n        & (schema.dte >= dte_range[0])\n        & (schema.dte <= dte_range[1])\n    )\n    sp.exit_filter = schema.dte <= dte_exit\n\n    # Long put (outer wing)\n    lp = StrategyLeg(\"leg_4\", schema, option_type=OptionType.PUT, direction=Direction.BUY)\n    lp.entry_filter = (\n        (schema.underlying == underlying)\n        & (schema.dte >= dte_range[0])\n        & (schema.dte <= dte_range[1])\n    )\n    lp.exit_filter = schema.dte <= dte_exit\n\n    strat.add_legs([sc, lc, sp, lp])\n    strat.add_exit_thresholds(exit_thresholds[0], exit_thresholds[1])\n    return strat\n\n\ndef covered_call(\n    schema: \"Schema\",\n    underlying: str,\n    dte_range: tuple[int, int],\n    dte_exit: int,\n    otm_pct: float = 2.0,\n    pct_tolerance: float = 1.0,\n    exit_thresholds: tuple[float, float] = (float(\"inf\"), float(\"inf\")),\n) -> Strategy:\n    \"\"\"Build a covered call strategy (sell OTM calls against stock).\"\"\"\n    strat = Strategy(schema)\n    otm_lo = (otm_pct - pct_tolerance) / 100\n    otm_hi = (otm_pct + pct_tolerance) / 100\n\n    leg = StrategyLeg(\"leg_1\", schema, option_type=OptionType.CALL, direction=Direction.SELL)\n    leg.entry_filter = (\n        (schema.underlying == underlying)\n        & (schema.dte >= dte_range[0])\n        & (schema.dte <= dte_range[1])\n        & (schema.strike >= schema.underlying_last * (1 + otm_lo))\n        & (schema.strike <= schema.underlying_last * (1 + otm_hi))\n    )\n    leg.exit_filter = schema.dte <= dte_exit\n\n    strat.add_leg(leg)\n    strat.add_exit_thresholds(exit_thresholds[0], exit_thresholds[1])\n    return strat\n\n\ndef cash_secured_put(\n    schema: \"Schema\",\n    underlying: str,\n    dte_range: tuple[int, int],\n    dte_exit: int,\n    otm_pct: float = 2.0,\n    pct_tolerance: float = 1.0,\n    exit_thresholds: tuple[float, float] = (float(\"inf\"), float(\"inf\")),\n) -> Strategy:\n    \"\"\"Build a cash-secured put strategy (sell OTM puts).\"\"\"\n    strat = Strategy(schema)\n    otm_lo = (otm_pct - pct_tolerance) / 100\n    otm_hi = (otm_pct + pct_tolerance) / 100\n\n    leg = StrategyLeg(\"leg_1\", schema, option_type=OptionType.PUT, direction=Direction.SELL)\n    leg.entry_filter = (\n        (schema.underlying == underlying)\n        & (schema.dte >= dte_range[0])\n        & (schema.dte <= dte_range[1])\n        & (schema.strike <= schema.underlying_last * (1 - otm_lo))\n        & (schema.strike >= schema.underlying_last * (1 - otm_hi))\n    )\n    leg.exit_filter = schema.dte <= dte_exit\n\n    strat.add_leg(leg)\n    strat.add_exit_thresholds(exit_thresholds[0], exit_thresholds[1])\n    return strat\n\n\ndef collar(\n    schema: \"Schema\",\n    underlying: str,\n    dte_range: tuple[int, int],\n    dte_exit: int,\n    call_otm_pct: float = 2.0,\n    put_otm_pct: float = 2.0,\n    pct_tolerance: float = 1.0,\n    exit_thresholds: tuple[float, float] = (float(\"inf\"), float(\"inf\")),\n) -> Strategy:\n    \"\"\"Build a collar strategy (long put + short call against stock).\"\"\"\n    strat = Strategy(schema)\n    call_lo = (call_otm_pct - pct_tolerance) / 100\n    call_hi = (call_otm_pct + pct_tolerance) / 100\n    put_lo = (put_otm_pct - pct_tolerance) / 100\n    put_hi = (put_otm_pct + pct_tolerance) / 100\n\n    short_call = StrategyLeg(\"leg_1\", schema, option_type=OptionType.CALL, direction=Direction.SELL)\n    short_call.entry_filter = (\n        (schema.underlying == underlying)\n        & (schema.dte >= dte_range[0])\n        & (schema.dte <= dte_range[1])\n        & (schema.strike >= schema.underlying_last * (1 + call_lo))\n        & (schema.strike <= schema.underlying_last * (1 + call_hi))\n    )\n    short_call.exit_filter = schema.dte <= dte_exit\n\n    long_put = StrategyLeg(\"leg_2\", schema, option_type=OptionType.PUT, direction=Direction.BUY)\n    long_put.entry_filter = (\n        (schema.underlying == underlying)\n        & (schema.dte >= dte_range[0])\n        & (schema.dte <= dte_range[1])\n        & (schema.strike <= schema.underlying_last * (1 - put_lo))\n        & (schema.strike >= schema.underlying_last * (1 - put_hi))\n    )\n    long_put.exit_filter = schema.dte <= dte_exit\n\n    strat.add_legs([short_call, long_put])\n    strat.add_exit_thresholds(exit_thresholds[0], exit_thresholds[1])\n    return strat\n\n\ndef butterfly(\n    schema: \"Schema\",\n    underlying: str,\n    dte_range: tuple[int, int],\n    dte_exit: int,\n    option_type: OptionType = OptionType.CALL,\n    exit_thresholds: tuple[float, float] = (float(\"inf\"), float(\"inf\")),\n) -> Strategy:\n    \"\"\"Build a long butterfly spread (buy 1 lower, sell 2 middle, buy 1 upper).\n\n    Uses entry_sort on strike to pick the legs. The middle leg is a SELL\n    direction with double quantity handled by the sizer.\n    \"\"\"\n    strat = Strategy(schema)\n\n    # Lower wing (buy)\n    lower = StrategyLeg(\"leg_1\", schema, option_type=option_type, direction=Direction.BUY)\n    lower.entry_filter = (\n        (schema.underlying == underlying)\n        & (schema.dte >= dte_range[0])\n        & (schema.dte <= dte_range[1])\n    )\n    lower.entry_sort = (\"strike\", True)  # ascending — lowest strike first\n    lower.exit_filter = schema.dte <= dte_exit\n\n    # Middle (sell 2x)\n    middle = StrategyLeg(\"leg_2\", schema, option_type=option_type, direction=Direction.SELL)\n    middle.entry_filter = (\n        (schema.underlying == underlying)\n        & (schema.dte >= dte_range[0])\n        & (schema.dte <= dte_range[1])\n    )\n    middle.exit_filter = schema.dte <= dte_exit\n\n    # Upper wing (buy)\n    upper = StrategyLeg(\"leg_3\", schema, option_type=option_type, direction=Direction.BUY)\n    upper.entry_filter = (\n        (schema.underlying == underlying)\n        & (schema.dte >= dte_range[0])\n        & (schema.dte <= dte_range[1])\n    )\n    upper.entry_sort = (\"strike\", False)  # descending — highest strike first\n    upper.exit_filter = schema.dte <= dte_exit\n\n    strat.add_legs([lower, middle, upper])\n    strat.add_exit_thresholds(exit_thresholds[0], exit_thresholds[1])\n    return strat\n\n\nclass Strangle(Strategy):\n    \"\"\"Class-based Strangle constructor.\"\"\"\n\n    def __init__(\n        self,\n        schema: \"Schema\",\n        name: str,\n        underlying: str,\n        dte_entry_range: tuple[int, int],\n        dte_exit: int,\n        otm_pct: float = 0,\n        pct_tolerance: float = 1,\n        exit_thresholds: tuple[float, float] = (float('inf'), float('inf')),\n        shares_per_contract: int = 100,\n    ) -> None:\n        assert (name.lower() == 'short' or name.lower() == 'long')\n        super().__init__(schema)\n        direction = Direction.SELL if name.lower() == 'short' else Direction.BUY\n\n        leg1 = StrategyLeg(\n            \"leg_1\",\n            schema,\n            option_type=OptionType.CALL,\n            direction=direction,\n        )\n\n        otm_lower_bound = (otm_pct - pct_tolerance) / 100\n        otm_upper_bound = (otm_pct + pct_tolerance) / 100\n\n        leg1.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_entry_range[0]) & (\n            schema.dte <= dte_entry_range[1]) & (schema.strike >= schema.underlying_last *\n                                                 (1 + otm_lower_bound)) & (schema.strike <= schema.underlying_last *\n                                                                           (1 + otm_upper_bound))\n        leg1.exit_filter = (schema.dte <= dte_exit)\n\n        leg2 = StrategyLeg(\"leg_2\", schema, option_type=OptionType.PUT, direction=direction)\n        leg2.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_entry_range[0]) & (\n            schema.dte <= dte_entry_range[1]) & (schema.strike <= schema.underlying_last *\n                                                 (1 - otm_lower_bound)) & (schema.strike >= schema.underlying_last *\n                                                                           (1 - otm_upper_bound))\n        leg2.exit_filter = (schema.dte <= dte_exit)\n\n        self.add_legs([leg1, leg2])\n        self.add_exit_thresholds(exit_thresholds[0], exit_thresholds[1])\n"
  },
  {
    "path": "options_portfolio_backtester/strategy/strategy.py",
    "content": "\"\"\"Strategy container — preserved interface with richer execution support.\"\"\"\n\nfrom __future__ import annotations\n\nimport math\nfrom typing import TYPE_CHECKING\n\nimport numpy as np\nimport pandas as pd\n\nfrom options_portfolio_backtester.execution.cost_model import TransactionCostModel, NoCosts\nfrom options_portfolio_backtester.execution.sizer import PositionSizer, CapitalBased\nfrom options_portfolio_backtester.execution.signal_selector import SignalSelector, FirstMatch\n\nif TYPE_CHECKING:\n    from options_portfolio_backtester.data.schema import Schema\n    from .strategy_leg import StrategyLeg\n\n\nclass Strategy:\n    \"\"\"Options strategy — collection of legs with exit thresholds.\n\n    API-compatible with backtester.strategy.strategy.Strategy, adding optional\n    cost_model, sizer, and signal_selector at the strategy level.\n    \"\"\"\n\n    def __init__(\n        self,\n        schema: \"Schema\",\n        cost_model: TransactionCostModel | None = None,\n        sizer: PositionSizer | None = None,\n        signal_selector: SignalSelector | None = None,\n    ) -> None:\n        self.schema = schema\n        self.legs: list[StrategyLeg] = []\n        self.conditions: list = []\n        self.exit_thresholds: tuple[float, float] = (math.inf, math.inf)\n        self.cost_model = cost_model or NoCosts()\n        self.sizer = sizer or CapitalBased()\n        self.signal_selector = signal_selector or FirstMatch()\n\n    def add_leg(self, leg: \"StrategyLeg\") -> \"Strategy\":\n        assert self.schema == leg.schema\n        leg.name = f\"leg_{len(self.legs) + 1}\"\n        self.legs.append(leg)\n        return self\n\n    def add_legs(self, legs: list[\"StrategyLeg\"]) -> \"Strategy\":\n        for leg in legs:\n            self.add_leg(leg)\n        return self\n\n    def remove_leg(self, leg_number: int) -> \"Strategy\":\n        self.legs.pop(leg_number)\n        return self\n\n    def clear_legs(self) -> \"Strategy\":\n        self.legs = []\n        return self\n\n    def add_exit_thresholds(self, profit_pct: float = math.inf,\n                            loss_pct: float = math.inf) -> None:\n        assert profit_pct >= 0\n        assert loss_pct >= 0\n        self.exit_thresholds = (profit_pct, loss_pct)\n\n    def filter_thresholds(self, entry_cost: pd.Series,\n                          current_cost: pd.Series) -> pd.Series:\n        profit_pct, loss_pct = self.exit_thresholds\n        excess_return = (current_cost / entry_cost + 1) * -np.sign(entry_cost)\n        return (excess_return >= profit_pct) | (excess_return <= -loss_pct)\n\n    def __repr__(self) -> str:\n        return f\"Strategy(legs={self.legs}, exit_thresholds={self.exit_thresholds})\"\n"
  },
  {
    "path": "options_portfolio_backtester/strategy/strategy_leg.py",
    "content": "\"\"\"Strategy leg — re-exports the original StrategyLeg for now.\n\nThe new StrategyLeg is API-compatible with the original and adds support\nfor the new execution components (signal_selector, fill_model).\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING\n\nfrom options_portfolio_backtester.core.types import Direction, OptionType\nfrom options_portfolio_backtester.execution.signal_selector import SignalSelector, FirstMatch\nfrom options_portfolio_backtester.execution.fill_model import FillModel, MarketAtBidAsk\n\nif TYPE_CHECKING:\n    from options_portfolio_backtester.data.schema import Filter, Schema\n\n\nclass StrategyLeg:\n    \"\"\"A single option leg in a strategy.\n\n    API-compatible with backtester.strategy.strategy_leg.StrategyLeg, adding\n    optional signal_selector and fill_model.\n    \"\"\"\n\n    def __init__(\n        self,\n        name: str,\n        schema: \"Schema\",\n        option_type: OptionType = OptionType.CALL,\n        direction: Direction = Direction.BUY,\n        signal_selector: SignalSelector | None = None,\n        fill_model: FillModel | None = None,\n    ) -> None:\n        self.name = name\n        self.schema = schema\n        self.type = option_type\n        self.direction = direction\n        self.signal_selector = signal_selector  # None = use engine-level default\n        self.fill_model = fill_model  # None = use engine-level default\n\n        self.entry_sort: tuple[str, bool] | None = None\n        self._entry_filter: \"Filter\" = self._base_entry_filter()\n        self._exit_filter: \"Filter\" = self._base_exit_filter()\n\n    @property\n    def entry_filter(self) -> \"Filter\":\n        return self._entry_filter\n\n    @entry_filter.setter\n    def entry_filter(self, flt: \"Filter\") -> None:\n        self._entry_filter = self._base_entry_filter() & flt\n\n    @property\n    def exit_filter(self) -> \"Filter\":\n        return self._exit_filter\n\n    @exit_filter.setter\n    def exit_filter(self, flt: \"Filter\") -> None:\n        self._exit_filter = self._base_exit_filter() & flt\n\n    def _base_entry_filter(self) -> \"Filter\":\n        if self.direction == Direction.BUY:\n            return (self.schema.type == self.type.value) & (self.schema.ask > 0)\n        return (self.schema.type == self.type.value) & (self.schema.bid > 0)\n\n    def _base_exit_filter(self) -> \"Filter\":\n        return self.schema.type == self.type.value\n\n    def __repr__(self) -> str:\n        return (\n            f\"StrategyLeg(name={self.name}, type={self.type}, \"\n            f\"direction={self.direction}, entry_filter={self._entry_filter}, \"\n            f\"exit_filter={self._exit_filter})\"\n        )\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[build-system]\nrequires = [\"maturin>=1.7,<2.0\"]\nbuild-backend = \"maturin\"\n\n[project]\nname = \"options_portfolio_backtester\"\nversion = \"0.3.0\"\ndescription = \"The open-source options backtesting framework\"\nreadme = \"README.md\"\nlicense = {text = \"MIT\"}\nrequires-python = \">=3.11\"\ndependencies = [\n    \"pandas>=2.1\",\n    \"numpy>=1.26\",\n    \"altair>=5.0\",\n    \"pyprind>=2.11\",\n    \"pyarrow>=14.0\",\n]\n\n[project.optional-dependencies]\nrust = [\"polars>=1.0,<1.6\"]\ncharts = [\"seaborn>=0.13\", \"matplotlib>=3.8\"]\ndev = [\n    \"pytest>=8.0\",\n    \"hypothesis>=6.0\",\n    \"pytest-benchmark\",\n    \"mypy>=1.8\",\n    \"ruff>=0.3\",\n    \"pandas-stubs\",\n    \"maturin>=1.7\",\n]\nnotebooks = [\"jupyter\", \"nbconvert\"]\n\n[tool.maturin]\nmanifest-path = \"rust/ob_python/Cargo.toml\"\nmodule-name = \"options_portfolio_backtester._ob_rust\"\npython-source = \".\"\nfeatures = [\"pyo3/extension-module\"]\n\n[tool.setuptools.packages.find]\ninclude = [\"options_portfolio_backtester*\"]\n\n[tool.pytest.ini_options]\ntestpaths = [\"tests\"]\naddopts = \"-m \\\"not bench\\\" --ignore=tests/bench --ignore=tests/convexity --ignore=tests/compat --ignore=tests/test_deep_analytics_convexity.py\"\nmarkers = [\n    \"bench: benchmark/property tests requiring explicit opt-in\",\n    \"slow: full-range stress tests (17-year SPY, minutes to run)\",\n    \"chaos: chaos / fault-injection tests\",\n]\nfilterwarnings = [\"ignore::DeprecationWarning\"]\n\n[tool.mypy]\npython_version = \"3.12\"\nwarn_unused_configs = true\ndisallow_untyped_defs = false\nignore_missing_imports = true\ncheck_untyped_defs = false\n\n[tool.ruff]\nline-length = 119\ntarget-version = \"py312\"\n\n[tool.ruff.lint]\nselect = [\"E\", \"F\", \"W\", \"I\"]\nignore = [\"E126\", \"F403\", \"F405\", \"W504\"]\n"
  },
  {
    "path": "rust/.cargo/config.toml",
    "content": "[env]\nPYO3_USE_ABI3_FORWARD_COMPATIBILITY = \"1\"\n"
  },
  {
    "path": "rust/Cargo.toml",
    "content": "[workspace]\nmembers = [\"ob_core\", \"ob_python\"]\nresolver = \"2\"\n"
  },
  {
    "path": "rust/ob_core/Cargo.toml",
    "content": "[package]\nname = \"ob_core\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\npolars = { version = \"0.48\", features = [\"lazy\", \"parquet\", \"dtype-struct\", \"semi_anti_join\"] }\narrow = { version = \"55\", features = [\"ffi\"] }\nchrono = \"0.4\"\nrayon = \"1.10\"\nthiserror = \"2\"\n\n[dev-dependencies]\ncriterion = { version = \"0.5\", features = [\"html_reports\"] }\n\n[[bench]]\nname = \"hot_paths\"\nharness = false\n"
  },
  {
    "path": "rust/ob_core/benches/hot_paths.rs",
    "content": "use criterion::{black_box, criterion_group, criterion_main, Criterion};\nuse polars::prelude::*;\n\nuse ob_core::entries::{compute_entry_qty, compute_leg_entries};\nuse ob_core::exits::threshold_exit_mask;\nuse ob_core::filter::CompiledFilter;\nuse ob_core::inventory::join_inventory_to_market;\nuse ob_core::stats::compute_stats;\nuse ob_core::types::Direction;\n\nfn make_options_df(n: usize) -> DataFrame {\n    let contracts: Vec<String> = (0..n).map(|i| format!(\"SPX_{i}\")).collect();\n    let underlyings: Vec<&str> = vec![\"SPX\"; n];\n    let types: Vec<&str> = (0..n)\n        .map(|i| if i % 2 == 0 { \"put\" } else { \"call\" })\n        .collect();\n    let expirations: Vec<&str> = vec![\"2024-06-01\"; n];\n    let strikes: Vec<f64> = (0..n).map(|i| 3800.0 + i as f64 * 5.0).collect();\n    let asks: Vec<f64> = (0..n).map(|i| 1.0 + (i % 50) as f64 * 0.5).collect();\n    let bids: Vec<f64> = asks.iter().map(|a| a * 0.95).collect();\n    let dtes: Vec<i32> = (0..n).map(|i| 30 + (i % 180) as i32).collect();\n\n    DataFrame::new(vec![\n        Column::new(\"optionroot\".into(), contracts),\n        Column::new(\"underlying\".into(), underlyings),\n        Column::new(\"type\".into(), types),\n        Column::new(\"expiration\".into(), expirations),\n        Column::new(\"strike\".into(), strikes),\n        Column::new(\"ask\".into(), asks),\n        Column::new(\"bid\".into(), bids),\n        Column::new(\"dte\".into(), dtes),\n    ])\n    .unwrap()\n}\n\nfn bench_inventory_join(c: &mut Criterion) {\n    let opts = make_options_df(10_000);\n    let n_inv = 50;\n    let contracts: Vec<String> = (0..n_inv).map(|i| format!(\"SPX_{i}\")).collect();\n    let qtys: Vec<f64> = vec![10.0; n_inv];\n    let types: Vec<String> = (0..n_inv)\n        .map(|i| {\n            if i % 2 == 0 {\n                \"put\".into()\n            } else {\n                \"call\".into()\n            }\n        })\n        .collect();\n\n    let underlyings: Vec<String> = vec![\"SPX\".into(); n_inv];\n    let strikes: Vec<f64> = (0..n_inv).map(|i| 3800.0 + i as f64 * 5.0).collect();\n\n    c.bench_function(\"inventory_join_50x10k\", |b| {\n        b.iter(|| {\n            let result = join_inventory_to_market(\n                black_box(&contracts),\n                black_box(&qtys),\n                black_box(&types),\n                black_box(&underlyings),\n                black_box(&strikes),\n                black_box(&opts),\n                None,\n                \"optionroot\",\n                \"quotedate\",\n                \"bid\",\n                None,\n                None,\n                Direction::Buy,\n                100,\n            )\n            .unwrap();\n            black_box(result.height());\n        });\n    });\n}\n\nfn bench_filter_compile_and_apply(c: &mut Criterion) {\n    let df = make_options_df(10_000);\n\n    let filter = CompiledFilter::new(\n        \"(type == 'put') & (ask > 0) & (underlying == 'SPX') & (dte >= 60) & (dte <= 120)\",\n    )\n    .unwrap();\n\n    c.bench_function(\"filter_apply_10k\", |b| {\n        b.iter(|| {\n            let result = filter.apply(black_box(&df)).unwrap();\n            black_box(result.height());\n        });\n    });\n}\n\nfn bench_filter_compile(c: &mut Criterion) {\n    c.bench_function(\"filter_compile\", |b| {\n        b.iter(|| {\n            let f = CompiledFilter::new(black_box(\n                \"(type == 'put') & (ask > 0) & (underlying == 'SPX') & (dte >= 60) & (dte <= 120)\",\n            ))\n            .unwrap();\n            black_box(&f);\n        });\n    });\n}\n\nfn bench_entry_computation(c: &mut Criterion) {\n    let opts = make_options_df(10_000);\n    let held: Vec<String> = (0..10).map(|i| format!(\"SPX_{i}\")).collect();\n    let filter = CompiledFilter::new(\"(type == 'put') & (ask > 0) & (dte >= 60)\").unwrap();\n\n    c.bench_function(\"entry_compute_10k\", |b| {\n        b.iter(|| {\n            let result = compute_leg_entries(\n                black_box(&opts),\n                black_box(&held),\n                black_box(&filter),\n                \"optionroot\",\n                \"ask\",\n                Some(\"strike\"),\n                true,\n                100,\n                false,\n            )\n            .unwrap();\n            black_box(result.height());\n        });\n    });\n}\n\nfn bench_exit_mask(c: &mut Criterion) {\n    let n = 1000;\n    let entries: Vec<f64> = (0..n).map(|i| 100.0 + (i % 50) as f64).collect();\n    let currents: Vec<f64> = (0..n).map(|i| 80.0 + (i % 80) as f64).collect();\n\n    let entry_s = Series::new(\"entry\".into(), &entries);\n    let current_s = Series::new(\"current\".into(), &currents);\n\n    c.bench_function(\"exit_mask_1k\", |b| {\n        b.iter(|| {\n            let mask = threshold_exit_mask(\n                black_box(&entry_s),\n                black_box(&current_s),\n                Some(0.5),\n                Some(0.2),\n            )\n            .unwrap();\n            black_box(mask.len());\n        });\n    });\n}\n\nfn bench_stats_computation(c: &mut Criterion) {\n    let n = 2520; // ~10 years of trading days\n    let returns: Vec<f64> = (0..n).map(|i| ((i as f64 * 0.1).sin()) * 0.02).collect();\n    let pnls: Vec<f64> = (0..100)\n        .map(|i| if i % 3 == 0 { -50.0 } else { 100.0 })\n        .collect();\n\n    c.bench_function(\"stats_10yr\", |b| {\n        b.iter(|| {\n            let s = compute_stats(black_box(&returns), black_box(&pnls), 0.02);\n            black_box(s);\n        });\n    });\n}\n\nfn bench_entry_qty(c: &mut Criterion) {\n    let n = 5000;\n    let costs: Vec<f64> = (0..n).map(|i| 50.0 + (i % 200) as f64).collect();\n    let series = Series::new(\"cost\".into(), &costs);\n\n    c.bench_function(\"entry_qty_5k\", |b| {\n        b.iter(|| {\n            let qty = compute_entry_qty(black_box(&series), 1_000_000.0).unwrap();\n            black_box(qty.len());\n        });\n    });\n}\n\ncriterion_group!(\n    benches,\n    bench_inventory_join,\n    bench_filter_compile,\n    bench_filter_compile_and_apply,\n    bench_entry_computation,\n    bench_exit_mask,\n    bench_stats_computation,\n    bench_entry_qty,\n);\ncriterion_main!(benches);\n"
  },
  {
    "path": "rust/ob_core/src/backtest.rs",
    "content": "//! Full backtest loop — mirrors BacktestEngine.run() for parity.\n//!\n//! Pre-partitions all data by date at startup for O(1) lookups instead of\n//! O(n) DataFrame scans on each access. Uses i64 nanosecond timestamps as\n//! HashMap keys to avoid string conversion overhead entirely.\n//!\n//! Key optimizations:\n//!   - filter_by_date()         → HashMap::get()          O(n) → O(1)\n//!   - get_contract_field_f64() → DayOptions::get_f64()   O(n) → O(1)\n//!   - get_contract_field_str() → DayOptions::get_str()   O(n) → O(1)\n//!   - get_symbol_price()       → DayStocks::get_price()  O(n) → O(1)\n//!   - Date keys are i64 (nanoseconds) — no string allocation or comparison.\n\nuse std::collections::HashMap;\n\nuse chrono::DateTime;\nuse polars::prelude::*;\n\nuse crate::cost_model::CostModel;\nuse crate::entries::compute_leg_entries;\nuse crate::fill_model::FillModel;\nuse crate::filter::CompiledFilter;\nuse crate::risk::{self, RiskConstraint};\nuse crate::signal_selector::SignalSelector;\nuse crate::stats;\nuse crate::stats::Stats;\nuse crate::types::{Direction, Greeks, LegConfig};\n\n#[derive(Clone)]\npub struct BacktestConfig {\n    pub allocation_stocks: f64,\n    pub allocation_options: f64,\n    pub allocation_cash: f64,\n    pub initial_capital: f64,\n    pub shares_per_contract: i64,\n    pub legs: Vec<LegConfig>,\n    pub profit_pct: Option<f64>,\n    pub loss_pct: Option<f64>,\n    pub stock_symbols: Vec<String>,\n    pub stock_percentages: Vec<f64>,\n    /// Pre-computed rebalance dates as nanoseconds since epoch.\n    pub rebalance_dates: Vec<i64>,\n    /// Transaction cost model.\n    pub cost_model: CostModel,\n    /// Fill model for execution pricing.\n    pub fill_model: FillModel,\n    /// Engine-level signal selector.\n    pub signal_selector: SignalSelector,\n    /// Risk constraints checked before entries.\n    pub risk_constraints: Vec<RiskConstraint>,\n    /// SMA days for stock gating (None = no SMA gate).\n    pub sma_days: Option<usize>,\n    /// Options budget as a percentage of total capital per rebalance (overrides allocation_options).\n    pub options_budget_pct: Option<f64>,\n    /// Annual options budget as a percentage of total capital, auto-divided by rebalances/year.\n    pub options_budget_annual_pct: Option<f64>,\n    /// Stop the backtest if cash goes negative (mirrors Python's stop_if_broke).\n    pub stop_if_broke: bool,\n    /// Maximum short notional as fraction of total capital (None = no limit).\n    pub max_notional_pct: Option<f64>,\n    /// Check exits on every trading day, not just rebalance dates.\n    pub check_exits_daily: bool,\n    /// When true, spend the full budget each rebalance ignoring existing position value.\n    /// Default (false) uses target model: spend = budget - existing_options_value.\n    pub options_budget_fresh_spend: bool,\n    /// When true, rebalance stocks immediately after daily option exits.\n    /// Allows reinvesting put profits into stocks without waiting for the next rebalance date.\n    pub rebalance_stocks_on_exit: bool,\n}\n\npub struct BacktestResult {\n    pub balance: DataFrame,\n    pub trade_log: DataFrame,\n    pub final_cash: f64,\n    pub stats: Stats,\n}\n\n/// Configuration for one strategy slot in a multi-strategy backtest.\n#[derive(Clone)]\npub struct StrategySlotConfig {\n    pub name: String,\n    pub legs: Vec<LegConfig>,\n    pub weight: f64,\n    pub rebalance_dates: Vec<i64>,\n    pub profit_pct: Option<f64>,\n    pub loss_pct: Option<f64>,\n    pub check_exits_daily: bool,\n}\n\nstruct Position {\n    leg_contracts: Vec<String>,\n    leg_types: Vec<String>,\n    leg_directions: Vec<Direction>,\n    quantity: f64,\n    entry_cost: f64,\n    greeks: Greeks,\n    /// Entry-time metadata per leg, used as fallback when contract is missing from today's data.\n    leg_underlyings: Vec<String>,\n    leg_expirations: Vec<String>,\n    leg_strikes: Vec<f64>,\n}\n\nstruct StockHolding {\n    symbol: String,\n    qty: f64,\n    price: f64,\n}\n\n/// Per-leg per-position entry in trade log (flat, converted to MultiIndex in Python).\nstruct TradeRow {\n    date: i64,\n    leg_data: Vec<LegTradeData>,\n    total_cost: f64,\n    qty: f64,\n}\n\nstruct LegTradeData {\n    contract: String,\n    underlying: String,\n    expiration: String,\n    opt_type: String,\n    strike: f64,\n    cost: f64,\n    order: String,\n}\n\n/// Balance row for a single date range day.\nstruct BalanceDay {\n    date: i64,\n    cash: f64,\n    calls_capital: f64,\n    puts_capital: f64,\n    options_qty: f64,\n    stocks_qty: f64,\n    stock_values: Vec<(String, f64)>,\n    stock_qtys: Vec<(String, f64)>,\n}\n\n// ---------------------------------------------------------------------------\n// Date conversion helpers.\n// ---------------------------------------------------------------------------\n\n/// Convert nanoseconds since epoch to \"YYYY-MM-DD HH:MM:SS\" string.\nfn ns_to_datestring(ns: i64) -> String {\n    let secs = ns.div_euclid(1_000_000_000);\n    let nsec = ns.rem_euclid(1_000_000_000) as u32;\n    DateTime::from_timestamp(secs, nsec)\n        .map(|dt| dt.format(\"%Y-%m-%d %H:%M:%S\").to_string())\n        .unwrap_or_default()\n}\n\n/// Parse \"YYYY-MM-DD HH:MM:SS\" to nanoseconds since epoch.\nfn parse_datestring_to_ns(s: &str) -> Option<i64> {\n    chrono::NaiveDateTime::parse_from_str(s, \"%Y-%m-%d %H:%M:%S\")\n        .ok()\n        .map(|dt| {\n            let ts = dt.and_utc().timestamp();\n            ts * 1_000_000_000\n        })\n}\n\n/// Extract an i64 date key (nanoseconds) from a column value at index.\n/// Handles Datetime (any time unit), Date, and String columns.\nfn extract_date_ns(col: &Column, idx: usize) -> i64 {\n    match col.dtype() {\n        DataType::Datetime(tu, _) => {\n            let val = col.datetime().unwrap().get(idx).unwrap_or(0);\n            match tu {\n                TimeUnit::Nanoseconds => val,\n                TimeUnit::Microseconds => val * 1_000,\n                TimeUnit::Milliseconds => val * 1_000_000,\n            }\n        }\n        DataType::Date => {\n            let days = col.date().unwrap().get(idx).unwrap_or(0);\n            days as i64 * 86_400_000_000_000i64\n        }\n        _ => {\n            col.str().ok()\n                .and_then(|ca| ca.get(idx))\n                .and_then(parse_datestring_to_ns)\n                .unwrap_or(0)\n        }\n    }\n}\n\n/// Read a column value as a String, handling both String and Datetime columns.\nfn column_value_to_string(col: &Column, idx: usize) -> String {\n    if let Ok(ca) = col.str() {\n        return ca.get(idx).unwrap_or(\"\").to_string();\n    }\n    match col.dtype() {\n        DataType::Datetime(tu, _) => {\n            let val = col.datetime().unwrap().get(idx).unwrap_or(0);\n            let ns = match tu {\n                TimeUnit::Nanoseconds => val,\n                TimeUnit::Microseconds => val * 1_000,\n                TimeUnit::Milliseconds => val * 1_000_000,\n            };\n            ns_to_datestring(ns)\n        }\n        DataType::Date => {\n            let days = col.date().unwrap().get(idx).unwrap_or(0);\n            ns_to_datestring(days as i64 * 86_400_000_000_000i64)\n        }\n        _ => String::new(),\n    }\n}\n\n// ---------------------------------------------------------------------------\n// Pre-partitioned data structures — O(1) date and contract lookups.\n// ---------------------------------------------------------------------------\n\n/// Options data for a single date with O(1) contract lookups.\nstruct DayOptions {\n    df: DataFrame,\n    /// contract_string → row index within `df`.\n    contract_idx: HashMap<String, usize>,\n}\n\nimpl DayOptions {\n    fn new(df: DataFrame, contract_col: &str) -> Self {\n        let mut contract_idx = HashMap::with_capacity(df.height());\n        if let Ok(col) = df.column(contract_col) {\n            if let Ok(ca) = col.str() {\n                for (i, val) in ca.into_iter().enumerate() {\n                    if let Some(v) = val {\n                        // Keep first occurrence (matches original filter + iloc[0]).\n                        contract_idx.entry(v.to_string()).or_insert(i);\n                    }\n                }\n            }\n        }\n        DayOptions { df, contract_idx }\n    }\n\n    /// Get a float64 field for a contract — O(1).\n    fn get_f64(&self, contract: &str, field: &str) -> Option<f64> {\n        let &row_idx = self.contract_idx.get(contract)?;\n        let col = self.df.column(field).ok()?;\n        // Fast path: column is already f64.\n        if let Ok(ca) = col.f64() {\n            return ca.get(row_idx);\n        }\n        // Slow path: cast to f64 (e.g. Int64 strike column).\n        let casted = col.cast(&DataType::Float64).ok()?;\n        casted.f64().ok()?.get(row_idx)\n    }\n\n    /// Get a string field for a contract — O(1).\n    /// Handles both String and Datetime columns (for expiration).\n    fn get_str(&self, contract: &str, field: &str) -> Option<String> {\n        let &row_idx = self.contract_idx.get(contract)?;\n        let col = self.df.column(field).ok()?;\n        let s = column_value_to_string(col, row_idx);\n        if s.is_empty() { None } else { Some(s) }\n    }\n\n    fn height(&self) -> usize {\n        self.df.height()\n    }\n}\n\n/// Stocks data for a single date — O(1) price lookups.\nstruct DayStocks {\n    prices: HashMap<String, f64>,\n}\n\nimpl DayStocks {\n    fn get_price(&self, symbol: &str) -> Option<f64> {\n        self.prices.get(symbol).copied()\n    }\n}\n\n/// All data pre-partitioned by date.\npub struct PartitionedData {\n    options: HashMap<i64, DayOptions>,\n    stocks: HashMap<i64, DayStocks>,\n    /// All option dates as nanoseconds, sorted ascending.\n    all_dates_sorted: Vec<i64>,\n}\n\n/// Schema column name mappings passed from Python.\n#[derive(Clone)]\npub struct SchemaMapping {\n    pub contract: String,\n    pub date: String,\n    pub stocks_date: String,\n    pub stocks_sym: String,\n    pub stocks_price: String,\n    pub underlying: String,\n    pub expiration: String,\n    pub option_type: String,\n    pub strike: String,\n}\n\n// ---------------------------------------------------------------------------\n// Main entry point.\n// ---------------------------------------------------------------------------\n\npub fn run_backtest(\n    config: &BacktestConfig,\n    options_data: &DataFrame,\n    stocks_data: &DataFrame,\n    schema: &SchemaMapping,\n) -> PolarsResult<BacktestResult> {\n    let partitioned = prepartition_data(options_data, stocks_data, schema)?;\n    run_backtest_prepartitioned(config, &partitioned, schema)\n}\n\n/// Pre-compiled entry and exit filters for a backtest config.\n/// Avoids redundant filter parsing when running multiple configs in a sweep.\npub struct PrecompiledFilters {\n    pub entry: Vec<Option<CompiledFilter>>,\n    pub exit: Vec<Option<CompiledFilter>>,\n}\n\nimpl PrecompiledFilters {\n    /// Compile filters from a BacktestConfig's legs.\n    pub fn from_config(config: &BacktestConfig) -> Self {\n        let entry = config.legs.iter()\n            .map(|leg| leg.entry_filter_query.as_ref().and_then(|q| CompiledFilter::new(q).ok()))\n            .collect();\n        let exit = config.legs.iter()\n            .map(|leg| leg.exit_filter_query.as_ref().and_then(|q| CompiledFilter::new(q).ok()))\n            .collect();\n        PrecompiledFilters { entry, exit }\n    }\n}\n\n/// Run a backtest using pre-partitioned data (avoids re-partitioning in sweeps).\npub fn run_backtest_prepartitioned(\n    config: &BacktestConfig,\n    partitioned: &PartitionedData,\n    schema: &SchemaMapping,\n) -> PolarsResult<BacktestResult> {\n    let filters = PrecompiledFilters::from_config(config);\n    run_backtest_with_filters(config, partitioned, schema, &filters)\n}\n\n/// Run a backtest with pre-compiled filters (used by sweep to avoid redundant compilation).\npub fn run_backtest_with_filters(\n    config: &BacktestConfig,\n    partitioned: &PartitionedData,\n    schema: &SchemaMapping,\n    filters: &PrecompiledFilters,\n) -> PolarsResult<BacktestResult> {\n    let entry_filters = &filters.entry;\n    let exit_filters = &filters.exit;\n\n    let mut cash = config.initial_capital;\n    let mut positions: Vec<Position> = Vec::new();\n    let mut stock_holdings: Vec<StockHolding> = Vec::new();\n    let mut peak_value: f64 = config.initial_capital;\n    // Initial value is overwritten inside the rebalance_date macro before first read,\n    // but we need a valid initial binding for the macro's mutable capture.\n    #[allow(unused_assignments)]\n    let mut portfolio_greeks = Greeks::default();\n\n    let mut trade_rows: Vec<TradeRow> = Vec::new();\n    let mut balance_days: Vec<BalanceDay> = Vec::new();\n\n    // Pre-compute SMA per stock symbol if sma_days is set\n    let sma_map_by_date = config.sma_days\n        .map(|sma_days| compute_sma_map(&partitioned, &config.stock_symbols, sma_days));\n\n    let rb_dates = &config.rebalance_dates;\n    if rb_dates.is_empty() {\n        return build_result(&trade_rows, &balance_days, &config.legs, cash);\n    }\n\n    // Pre-compute rebalances per year for annual budget conversion.\n    let rebalances_per_year = if rb_dates.len() >= 2 {\n        let first = rb_dates[0];\n        let last = *rb_dates.last().unwrap();\n        let years = (last - first) as f64 / (365.25 * 24.0 * 3600.0 * 1e9);\n        if years > 0.0 { rb_dates.len() as f64 / years } else { rb_dates.len() as f64 }\n    } else {\n        1.0\n    };\n\n    // Rebalance helper: executes full rebalance logic for a single date.\n    // Extracted as a macro to avoid duplicating 60 lines across the two loop\n    // variants (rb-only vs all-dates).\n    macro_rules! rebalance_date {\n        ($rb_date:expr, $prev_rb_date:expr, $partitioned:expr,\n         $config:expr, $entry_filters:expr, $exit_filters:expr,\n         $schema:expr, $sma_map_by_date:expr,\n         $positions:expr, $stock_holdings:expr, $cash:expr,\n         $peak_value:expr, $portfolio_greeks:expr,\n         $trade_rows:expr, $balance_days:expr) => {{\n            let rb_date = $rb_date;\n            let prev_rb_date = $prev_rb_date;\n\n            // _update_balance(prev_rb_date, rb_date)\n            compute_balance_period(\n                &$positions, &$stock_holdings,\n                $partitioned,\n                prev_rb_date, rb_date,\n                $config.shares_per_contract, $cash,\n                &$config.legs,\n                &mut $balance_days,\n            );\n\n            let day_opts = match $partitioned.options.get(&rb_date) {\n                Some(d) if d.height() > 0 => d,\n                _ => { continue; }\n            };\n            let day_stocks = $partitioned.stocks.get(&rb_date);\n\n            // Run exit filters\n            execute_exits(\n                &mut $positions, &mut $cash, day_opts,\n                $config.shares_per_contract,\n                &$config.legs, $exit_filters,\n                $config.profit_pct, $config.loss_pct,\n                $schema, rb_date, &mut $trade_rows,\n                &$config.cost_model, day_stocks,\n            )?;\n\n            // Recompute portfolio greeks from current market data after exits\n            $portfolio_greeks = compute_portfolio_greeks_from_market(\n                &$positions, day_opts, &$config.legs,\n            );\n\n            // Compute total capital including held options\n            let stock_cap = compute_stock_capital(&$stock_holdings, day_stocks);\n            let options_cap = compute_options_capital(\n                &$positions, day_opts, $config.shares_per_contract, day_stocks,\n            );\n            let total_capital = $cash + stock_cap + options_cap;\n            $peak_value = $peak_value.max(total_capital);\n\n            // Rebalance stocks\n            let externally_funded = $config.options_budget_pct.is_some()\n                || $config.options_budget_annual_pct.is_some();\n            let liquid_capital = total_capital - options_cap;\n            let stocks_alloc = if externally_funded {\n                $config.allocation_stocks * liquid_capital\n            } else {\n                // Cap to liquid_capital: can't buy stocks with capital locked in options\n                ($config.allocation_stocks * total_capital).min(liquid_capital)\n            };\n            $stock_holdings.clear();\n            $cash = liquid_capital;\n\n            let sma_prices = $sma_map_by_date.as_ref().and_then(|m| m.get(&rb_date));\n            buy_stocks(\n                &$config.stock_symbols, &$config.stock_percentages,\n                day_stocks, stocks_alloc, &mut $stock_holdings,\n                &$config.cost_model, &mut $cash, sma_prices,\n            );\n\n            // Options: buy with remaining budget only\n            let options_alloc = if let Some(pct) = $config.options_budget_pct {\n                total_capital * pct\n            } else if let Some(annual) = $config.options_budget_annual_pct {\n                total_capital * (annual / rebalances_per_year)\n            } else {\n                $config.allocation_options * total_capital\n            };\n            let remaining_budget = if $config.options_budget_fresh_spend {\n                options_alloc\n            } else {\n                options_alloc - options_cap\n            };\n            if remaining_budget > 0.0 {\n                let held: Vec<String> = $positions.iter()\n                    .flat_map(|p| p.leg_contracts.clone())\n                    .collect();\n\n                if externally_funded {\n                    $cash += remaining_budget;\n                }\n\n                if let Some(pos) = execute_entries(\n                    &$config.legs, $entry_filters, day_opts, &held,\n                    $config.shares_per_contract, remaining_budget,\n                    $schema, rb_date, &mut $trade_rows,\n                    &$config.fill_model, &$config.signal_selector,\n                    &$config.risk_constraints, &$portfolio_greeks,\n                    total_capital, $peak_value,\n                    $config.max_notional_pct, &$positions,\n                )? {\n                    let cost = pos.entry_cost * pos.quantity;\n                    let commission = $config.cost_model.option_cost(\n                        cost.abs(), pos.quantity, $config.shares_per_contract,\n                    );\n                    $cash -= cost + commission;\n                    if externally_funded {\n                        // Claw back unspent portion of externally-funded budget\n                        $cash -= remaining_budget - cost - commission;\n                    }\n                    $portfolio_greeks += pos.greeks;\n                    $positions.push(pos);\n                } else if externally_funded {\n                    $cash -= remaining_budget;\n                }\n            }\n\n            if $config.stop_if_broke && $cash < 0.0 {\n                break;\n            }\n        }};\n    }\n\n    if config.check_exits_daily {\n        // All-dates loop: check exits on every trading day, rebalance on rb dates.\n        use std::collections::HashSet;\n        let rb_set: HashSet<i64> = rb_dates.iter().copied().collect();\n        let mut rb_idx: usize = 0;\n\n        for &date in &partitioned.all_dates_sorted {\n            if rb_set.contains(&date) {\n                let prev_rb_date = if rb_idx == 0 { date } else { rb_dates[rb_idx - 1] };\n                rebalance_date!(\n                    date, prev_rb_date, &partitioned,\n                    config, &entry_filters, &exit_filters,\n                    schema, sma_map_by_date,\n                    positions, stock_holdings, cash,\n                    peak_value, portfolio_greeks,\n                    trade_rows, balance_days\n                );\n                rb_idx += 1;\n            } else if !positions.is_empty() {\n                // Non-rebalance day: only run exits\n                if let Some(day_opts) = partitioned.options.get(&date) {\n                    let day_stocks = partitioned.stocks.get(&date);\n                    let cash_before = cash;\n                    execute_exits(\n                        &mut positions, &mut cash, day_opts,\n                        config.shares_per_contract,\n                        &config.legs, &exit_filters,\n                        config.profit_pct, config.loss_pct,\n                        schema, date, &mut trade_rows,\n                        &config.cost_model, day_stocks,\n                    )?;\n\n                    // Immediately reinvest freed cash into stocks\n                    if config.rebalance_stocks_on_exit && cash > cash_before {\n                        let stock_cap = compute_stock_capital(&stock_holdings, day_stocks);\n                        let options_cap = compute_options_capital(\n                            &positions, day_opts, config.shares_per_contract, day_stocks,\n                        );\n                        let total_capital = cash + stock_cap + options_cap;\n                        peak_value = peak_value.max(total_capital);\n\n                        let externally_funded = config.options_budget_pct.is_some()\n                            || config.options_budget_annual_pct.is_some();\n                        let liquid_capital = total_capital - options_cap;\n                        let stocks_alloc = if externally_funded {\n                            config.allocation_stocks * liquid_capital\n                        } else {\n                            (config.allocation_stocks * total_capital).min(liquid_capital)\n                        };\n                        stock_holdings.clear();\n                        cash = liquid_capital;\n\n                        let sma_prices = sma_map_by_date.as_ref().and_then(|m| m.get(&date));\n                        buy_stocks(\n                            &config.stock_symbols, &config.stock_percentages,\n                            day_stocks, stocks_alloc, &mut stock_holdings,\n                            &config.cost_model, &mut cash, sma_prices,\n                        );\n                    }\n                }\n            }\n        }\n    } else {\n        // Fast path: iterate only rebalance dates (typical: ~200 vs ~4500 all dates).\n        for (rb_idx, &rb_date) in rb_dates.iter().enumerate() {\n            let prev_rb_date = if rb_idx == 0 { rb_date } else { rb_dates[rb_idx - 1] };\n            rebalance_date!(\n                rb_date, prev_rb_date, &partitioned,\n                config, &entry_filters, &exit_filters,\n                schema, sma_map_by_date,\n                positions, stock_holdings, cash,\n                peak_value, portfolio_greeks,\n                trade_rows, balance_days\n            );\n        }\n    }\n\n    // Final balance update: last rebalance date to end of data\n    let last_rb = *rb_dates.last().unwrap();\n    let last_date = partitioned.all_dates_sorted.last().copied().unwrap_or(0);\n\n    if last_date > 0 {\n        compute_balance_period(\n            &positions, &stock_holdings,\n            &partitioned,\n            last_rb, last_date,\n            config.shares_per_contract, cash,\n            &config.legs,\n            &mut balance_days,\n        );\n    }\n\n    build_result(&trade_rows, &balance_days, &config.legs, cash)\n}\n\n// ---------------------------------------------------------------------------\n// Multi-strategy backtest.\n// ---------------------------------------------------------------------------\n\n/// Run a multi-strategy backtest with per-slot inventories, shared stocks/cash.\n///\n/// Each slot has its own legs, rebalance schedule, exit thresholds, and weight.\n/// The shared config provides allocation, stocks, capital, cost/fill/signal models.\npub fn run_multi_strategy(\n    config: &BacktestConfig,\n    slots: &[StrategySlotConfig],\n    partitioned: &PartitionedData,\n    schema: &SchemaMapping,\n) -> PolarsResult<BacktestResult> {\n    use std::collections::HashSet;\n\n    let mut cash = config.initial_capital;\n    let mut stock_holdings: Vec<StockHolding> = Vec::new();\n    let mut peak_value: f64 = config.initial_capital;\n\n    // Per-slot state\n    let mut slot_positions: Vec<Vec<Position>> = slots.iter().map(|_| Vec::new()).collect();\n    let slot_filters: Vec<PrecompiledFilters> = slots.iter().map(|slot| {\n        // Build a temporary config just for filter compilation\n        let tmp = BacktestConfig {\n            legs: slot.legs.clone(),\n            ..config.clone()\n        };\n        PrecompiledFilters::from_config(&tmp)\n    }).collect();\n\n    let mut trade_rows: Vec<TradeRow> = Vec::new();\n    let mut balance_days: Vec<BalanceDay> = Vec::new();\n\n    // Pre-compute SMA\n    let sma_map_by_date = config.sma_days\n        .map(|sma_days| compute_sma_map(partitioned, &config.stock_symbols, sma_days));\n\n    // Build set of all rebalance dates (union across slots) and per-slot sets\n    let mut all_rb_set: HashSet<i64> = HashSet::new();\n    let slot_rb_sets: Vec<HashSet<i64>> = slots.iter().map(|slot| {\n        let set: HashSet<i64> = slot.rebalance_dates.iter().copied().collect();\n        all_rb_set.extend(&set);\n        set\n    }).collect();\n\n    // Pre-compute rebalances per year for annual budget conversion.\n    // Use the union of all slot rebalance dates.\n    let mut all_rb_sorted: Vec<i64> = all_rb_set.iter().copied().collect();\n    all_rb_sorted.sort_unstable();\n    let rebalances_per_year = if all_rb_sorted.len() >= 2 {\n        let first = all_rb_sorted[0];\n        let last = *all_rb_sorted.last().unwrap();\n        let years = (last - first) as f64 / (365.25 * 24.0 * 3600.0 * 1e9);\n        if years > 0.0 { all_rb_sorted.len() as f64 / years } else { all_rb_sorted.len() as f64 }\n    } else {\n        1.0\n    };\n\n    // All rebalance dates sorted\n    let mut all_rb_dates: Vec<i64> = all_rb_set.iter().copied().collect();\n    all_rb_dates.sort_unstable();\n\n    if all_rb_dates.is_empty() {\n        // Use legs from first slot for result columns\n        let legs = if slots.is_empty() { &config.legs } else { &slots[0].legs };\n        return build_result(&trade_rows, &balance_days, legs, cash);\n    }\n\n    // Determine if any slot uses daily exits\n    let any_daily_exits = config.check_exits_daily\n        || slots.iter().any(|s| s.check_exits_daily);\n\n    // Track previous rebalance date for balance computation\n    let mut prev_global_rb: Option<i64> = None;\n\n    for &date in &partitioned.all_dates_sorted {\n        let is_rebalance = all_rb_set.contains(&date);\n\n        // Which slots rebalance on this date?\n        let slots_rebalancing: Vec<usize> = if is_rebalance {\n            (0..slots.len())\n                .filter(|&i| slot_rb_sets[i].contains(&date))\n                .collect()\n        } else {\n            Vec::new()\n        };\n\n        if is_rebalance {\n            // Compute balance since previous rebalance\n            let prev_rb = prev_global_rb.unwrap_or(date);\n            compute_balance_period_multi(\n                &slot_positions, &stock_holdings, partitioned,\n                prev_rb, date, config.shares_per_contract, cash,\n                slots, &mut balance_days,\n            );\n            prev_global_rb = Some(date);\n\n            let day_opts = match partitioned.options.get(&date) {\n                Some(d) if d.height() > 0 => d,\n                _ => continue,\n            };\n            let day_stocks = partitioned.stocks.get(&date);\n\n            // Phase 1: exits for rebalancing slots\n            for &si in &slots_rebalancing {\n                execute_exits(\n                    &mut slot_positions[si], &mut cash, day_opts,\n                    config.shares_per_contract,\n                    &slots[si].legs, &slot_filters[si].exit,\n                    slots[si].profit_pct, slots[si].loss_pct,\n                    schema, date, &mut trade_rows,\n                    &config.cost_model, day_stocks,\n                )?;\n            }\n\n            // Phase 2: compute aggregate capital\n            let stock_cap = compute_stock_capital(&stock_holdings, day_stocks);\n            let options_cap: f64 = slot_positions.iter().map(|positions| {\n                compute_options_capital(positions, day_opts, config.shares_per_contract, day_stocks)\n            }).sum();\n            let total_capital = cash + stock_cap + options_cap;\n            peak_value = peak_value.max(total_capital);\n\n            // Options allocation\n            let options_alloc = if let Some(pct) = config.options_budget_pct {\n                total_capital * pct\n            } else if let Some(annual) = config.options_budget_annual_pct {\n                total_capital * (annual / rebalances_per_year)\n            } else {\n                config.allocation_options * total_capital\n            };\n\n            // Phase 3: buy stocks (shared pool)\n            let externally_funded = config.options_budget_pct.is_some()\n                || config.options_budget_annual_pct.is_some();\n            let liquid_capital = total_capital - options_cap;\n            let stocks_alloc = if externally_funded {\n                config.allocation_stocks * liquid_capital\n            } else {\n                // Cap to liquid_capital: can't buy stocks with capital locked in options\n                (config.allocation_stocks * total_capital).min(liquid_capital)\n            };\n            stock_holdings.clear();\n            cash = liquid_capital;\n\n            let sma_prices = sma_map_by_date.as_ref().and_then(|m| m.get(&date));\n            buy_stocks(\n                &config.stock_symbols, &config.stock_percentages,\n                day_stocks, stocks_alloc, &mut stock_holdings,\n                &config.cost_model, &mut cash, sma_prices,\n            );\n\n            // Phase 4: entries per rebalancing slot\n            for &si in &slots_rebalancing {\n                let slot = &slots[si];\n                let slot_opts_cap = compute_options_capital(\n                    &slot_positions[si], day_opts, config.shares_per_contract, day_stocks,\n                );\n                let slot_allocation = slot.weight * options_alloc;\n                let remaining_budget = if config.options_budget_fresh_spend {\n                    slot_allocation\n                } else {\n                    slot_allocation - slot_opts_cap\n                };\n                if remaining_budget > 0.0 {\n                    let held: Vec<String> = slot_positions[si].iter()\n                        .flat_map(|p| p.leg_contracts.clone())\n                        .collect();\n\n                    if externally_funded {\n                        cash += remaining_budget;\n                    }\n\n                    let portfolio_greeks = compute_portfolio_greeks_from_market(\n                        &slot_positions[si], day_opts, &slot.legs,\n                    );\n\n                    if let Some(pos) = execute_entries(\n                        &slot.legs, &slot_filters[si].entry, day_opts, &held,\n                        config.shares_per_contract, remaining_budget,\n                        schema, date, &mut trade_rows,\n                        &config.fill_model, &config.signal_selector,\n                        &config.risk_constraints, &portfolio_greeks,\n                        total_capital, peak_value,\n                        config.max_notional_pct, &slot_positions[si],\n                    )? {\n                        let cost = pos.entry_cost * pos.quantity;\n                        let commission = config.cost_model.option_cost(\n                            cost.abs(), pos.quantity, config.shares_per_contract,\n                        );\n                        cash -= cost + commission;\n                        if externally_funded {\n                            cash -= remaining_budget - cost - commission;\n                        }\n                        slot_positions[si].push(pos);\n                    } else if externally_funded {\n                        cash -= remaining_budget;\n                    }\n                }\n            }\n\n            if config.stop_if_broke && cash < 0.0 {\n                break;\n            }\n        } else if any_daily_exits {\n            // Non-rebalance day: run exits for slots with check_exits_daily\n            if let Some(day_opts) = partitioned.options.get(&date) {\n                let day_stocks = partitioned.stocks.get(&date);\n                let cash_before = cash;\n                for (si, slot) in slots.iter().enumerate() {\n                    if (slot.check_exits_daily || config.check_exits_daily)\n                        && !slot_positions[si].is_empty()\n                    {\n                        execute_exits(\n                            &mut slot_positions[si], &mut cash, day_opts,\n                            config.shares_per_contract,\n                            &slot.legs, &slot_filters[si].exit,\n                            slot.profit_pct, slot.loss_pct,\n                            schema, date, &mut trade_rows,\n                            &config.cost_model, day_stocks,\n                        )?;\n                    }\n                }\n\n                // If exits freed cash and rebalance_stocks_on_exit is set,\n                // immediately reinvest into stocks (e.g. buy discounted stocks\n                // during a crash after puts pay off).\n                if config.rebalance_stocks_on_exit && cash > cash_before {\n                    let stock_cap = compute_stock_capital(&stock_holdings, day_stocks);\n                    let options_cap: f64 = slot_positions.iter().map(|positions| {\n                        compute_options_capital(positions, day_opts, config.shares_per_contract, day_stocks)\n                    }).sum();\n                    let total_capital = cash + stock_cap + options_cap;\n                    peak_value = peak_value.max(total_capital);\n\n                    let externally_funded = config.options_budget_pct.is_some()\n                        || config.options_budget_annual_pct.is_some();\n                    let liquid_capital = total_capital - options_cap;\n                    let stocks_alloc = if externally_funded {\n                        config.allocation_stocks * liquid_capital\n                    } else {\n                        (config.allocation_stocks * total_capital).min(liquid_capital)\n                    };\n                    stock_holdings.clear();\n                    cash = liquid_capital;\n\n                    let sma_prices = sma_map_by_date.as_ref().and_then(|m| m.get(&date));\n                    buy_stocks(\n                        &config.stock_symbols, &config.stock_percentages,\n                        day_stocks, stocks_alloc, &mut stock_holdings,\n                        &config.cost_model, &mut cash, sma_prices,\n                    );\n                }\n            }\n        }\n    }\n\n    // Final balance update\n    if let Some(last_rb) = prev_global_rb {\n        let last_date = partitioned.all_dates_sorted.last().copied().unwrap_or(0);\n        if last_date > 0 {\n            compute_balance_period_multi(\n                &slot_positions, &stock_holdings, partitioned,\n                last_rb, last_date, config.shares_per_contract, cash,\n                slots, &mut balance_days,\n            );\n        }\n    }\n\n    // Use legs from first slot for result columns\n    let legs = if slots.is_empty() { &config.legs } else { &slots[0].legs };\n    build_result(&trade_rows, &balance_days, legs, cash)\n}\n\n/// Compute balance for multi-strategy across all slot positions.\nfn compute_balance_period_multi(\n    slot_positions: &[Vec<Position>],\n    stock_holdings: &[StockHolding],\n    partitioned: &PartitionedData,\n    start_date: i64,\n    end_date: i64,\n    spc: i64,\n    cash: f64,\n    slots: &[StrategySlotConfig],\n    balance_days: &mut Vec<BalanceDay>,\n) {\n    let dates = &partitioned.all_dates_sorted;\n    let start_idx = dates.partition_point(|&d| d < start_date);\n    let end_idx = dates.partition_point(|&d| d < end_date);\n\n    for &d in &dates[start_idx..end_idx] {\n        let day_opts = partitioned.options.get(&d);\n        let day_stocks = partitioned.stocks.get(&d);\n\n        let mut calls_cap = 0.0;\n        let mut puts_cap = 0.0;\n        let mut options_qty = 0.0;\n\n        if let Some(opts) = day_opts {\n            for (si, positions) in slot_positions.iter().enumerate() {\n                let legs = &slots[si].legs;\n                for pos in positions {\n                    options_qty += pos.quantity;\n                    for (j, leg) in legs.iter().enumerate() {\n                        if j >= pos.leg_contracts.len() { continue; }\n                        let exit_price_col = leg.direction.invert().price_column();\n                        let price = opts.get_f64(&pos.leg_contracts[j], exit_price_col)\n                            .unwrap_or_else(|| {\n                                let spot = day_stocks\n                                    .and_then(|ds| ds.get_price(&pos.leg_underlyings[j]))\n                                    .unwrap_or(0.0);\n                                intrinsic_value(&pos.leg_types[j], pos.leg_strikes[j], spot)\n                            });\n                        let sign = leg.direction.invert().sign();\n                        let value = sign * price * pos.quantity * spc as f64;\n                        if pos.leg_types[j] == \"call\" {\n                            calls_cap += value;\n                        } else {\n                            puts_cap += value;\n                        }\n                    }\n                }\n            }\n        }\n\n        let mut stock_values = Vec::new();\n        let mut stock_qtys = Vec::new();\n        let mut stocks_qty = 0.0;\n        for holding in stock_holdings {\n            let price = day_stocks\n                .and_then(|ds| ds.get_price(&holding.symbol))\n                .unwrap_or(holding.price);\n            stock_values.push((holding.symbol.clone(), holding.qty * price));\n            stock_qtys.push((holding.symbol.clone(), holding.qty));\n            stocks_qty += holding.qty;\n        }\n\n        balance_days.push(BalanceDay {\n            date: d, cash, calls_capital: calls_cap, puts_capital: puts_cap,\n            options_qty, stocks_qty, stock_values, stock_qtys,\n        });\n    }\n}\n\n// ---------------------------------------------------------------------------\n// Data pre-partitioning — called once at startup.\n// ---------------------------------------------------------------------------\n\npub fn prepartition_data(\n    options_data: &DataFrame,\n    stocks_data: &DataFrame,\n    schema: &SchemaMapping,\n) -> PolarsResult<PartitionedData> {\n    let date_col = &schema.date;\n    let contract_col = &schema.contract;\n\n    // Sort options by date (skip if already sorted — common for CSV data).\n    // slice() is zero-copy (shares underlying Arrow arrays via Arc).\n    let date_series_raw = options_data.column(date_col)?;\n    let n_check = options_data.height();\n    let already_sorted = if n_check < 2 {\n        true\n    } else {\n        let first = extract_date_ns(date_series_raw, 0);\n        let last = extract_date_ns(date_series_raw, n_check - 1);\n        if first > last {\n            false\n        } else {\n            // Sample a few points to verify monotonicity cheaply.\n            let step = (n_check / 8).max(1);\n            let mut prev = first;\n            let mut sorted = true;\n            let mut i = step;\n            while i < n_check {\n                let val = extract_date_ns(date_series_raw, i);\n                if val < prev {\n                    sorted = false;\n                    break;\n                }\n                prev = val;\n                i += step;\n            }\n            sorted\n        }\n    };\n    let sorted_opts;\n    let date_series;\n    if already_sorted {\n        sorted_opts = options_data.clone();\n        date_series = date_series_raw;\n    } else {\n        sorted_opts = options_data.sort([date_col.as_str()], SortMultipleOptions::default())?;\n        date_series = sorted_opts.column(date_col)?;\n    };\n    let n_opts = sorted_opts.height();\n\n    // Estimate ~500 unique dates for typical datasets (avoids HashMap reallocations).\n    let mut options_map: HashMap<i64, DayOptions> = HashMap::with_capacity(512);\n    let mut all_dates: Vec<i64> = Vec::with_capacity(512);\n\n    if n_opts > 0 {\n        let mut start = 0;\n        let mut current = extract_date_ns(date_series, 0);\n\n        for i in 1..n_opts {\n            let d = extract_date_ns(date_series, i);\n            if d != current {\n                let part = sorted_opts.slice(start as i64, i - start);\n                all_dates.push(current);\n                options_map.insert(current, DayOptions::new(part, contract_col));\n                current = d;\n                start = i;\n            }\n        }\n        // Last group\n        let part = sorted_opts.slice(start as i64, n_opts - start);\n        all_dates.push(current);\n        options_map.insert(current, DayOptions::new(part, contract_col));\n    }\n\n    // Stocks: iterate once and build price HashMaps directly.\n    // (Small data — typically 4500 rows, so no sort+slice needed.)\n    let stocks_date_col = &schema.stocks_date;\n    let sym_col_name = &schema.stocks_sym;\n    let price_col_name = &schema.stocks_price;\n\n    let stocks_date_series = stocks_data.column(stocks_date_col)?;\n    let sym_ca = stocks_data.column(sym_col_name)?.str()?;\n    let price_raw = stocks_data.column(price_col_name)?;\n    let price_casted = price_raw.cast(&DataType::Float64)?;\n    let price_ca = price_casted.f64()?;\n    let n_stocks = stocks_data.height();\n\n    let mut stocks_map: HashMap<i64, DayStocks> = HashMap::with_capacity(512);\n\n    for i in 0..n_stocks {\n        let date_ns = extract_date_ns(stocks_date_series, i);\n        if let (Some(sym), Some(price)) = (sym_ca.get(i), price_ca.get(i)) {\n            stocks_map.entry(date_ns)\n                .or_insert_with(|| DayStocks { prices: HashMap::new() })\n                .prices\n                .insert(sym.to_string(), price);\n        }\n    }\n\n    Ok(PartitionedData {\n        options: options_map,\n        stocks: stocks_map,\n        all_dates_sorted: all_dates,\n    })\n}\n\n// ---------------------------------------------------------------------------\n// Execute exits.\n// ---------------------------------------------------------------------------\n\nfn execute_exits(\n    positions: &mut Vec<Position>,\n    cash: &mut f64,\n    day_opts: &DayOptions,\n    spc: i64,\n    legs: &[LegConfig],\n    exit_filters: &[Option<CompiledFilter>],\n    profit_pct: Option<f64>,\n    loss_pct: Option<f64>,\n    schema: &SchemaMapping,\n    date: i64,\n    trade_rows: &mut Vec<TradeRow>,\n    cost_model: &CostModel,\n    day_stocks: Option<&DayStocks>,\n) -> PolarsResult<()> {\n    let mut to_remove = Vec::new();\n\n    for (i, pos) in positions.iter().enumerate() {\n        let mut should_exit = false;\n\n        // Check exit filters per leg — direct row evaluation, no Polars lazy overhead.\n        for (j, _leg) in legs.iter().enumerate() {\n            if let Some(ref flt) = exit_filters[j] {\n                let contract = &pos.leg_contracts[j];\n                if let Some(&row_idx) = day_opts.contract_idx.get(contract.as_str()) {\n                    // Contract exists today — check exit filter on its row.\n                    if flt.eval_row(&day_opts.df, row_idx) {\n                        should_exit = true;\n                    }\n                } else {\n                    // Contract not in today's data → exit.\n                    should_exit = true;\n                }\n            }\n        }\n\n        // Check threshold exits — mirrors Python's Strategy.filter_thresholds:\n        //   excess_return = (current_cost / entry_cost + 1) * -sign(entry_cost)\n        if !should_exit {\n            let curr = compute_position_exit_cost(pos, day_opts, spc, day_stocks);\n            let entry = pos.entry_cost;\n            if entry != 0.0 {\n                let excess_return = (curr / entry + 1.0) * -entry.signum();\n                if profit_pct.is_some_and(|p| excess_return >= p)\n                    || loss_pct.is_some_and(|l| excess_return <= -l)\n                {\n                    should_exit = true;\n                }\n            }\n        }\n\n        if should_exit {\n            let exit_cost = compute_position_exit_cost(pos, day_opts, spc, day_stocks);\n            *cash -= exit_cost * pos.quantity;\n\n            // Apply exit commission\n            let commission = cost_model.option_cost(\n                exit_cost.abs(), pos.quantity.abs(), spc,\n            );\n            *cash -= commission;\n\n            // Build trade row for exit\n            let mut leg_data = Vec::new();\n            for (j, leg) in legs.iter().enumerate() {\n                let exit_price_col = leg.direction.invert().price_column();\n                let price = day_opts.get_f64(&pos.leg_contracts[j], exit_price_col)\n                    .unwrap_or_else(|| {\n                        let spot = day_stocks\n                            .and_then(|ds| ds.get_price(&pos.leg_underlyings[j]))\n                            .unwrap_or(0.0);\n                        intrinsic_value(&pos.leg_types[j], pos.leg_strikes[j], spot)\n                    });\n                // Cash flow sign: BUY receives (-1), SELL pays (+1)\n                let cash_sign = if leg.direction == Direction::Buy { -1.0 } else { 1.0 };\n                let cost = cash_sign * price * spc as f64;\n                let order = match leg.direction {\n                    Direction::Buy => \"STC\",\n                    Direction::Sell => \"BTC\",\n                };\n                leg_data.push(LegTradeData {\n                    contract: pos.leg_contracts[j].clone(),\n                    underlying: day_opts.get_str(&pos.leg_contracts[j], &schema.underlying)\n                        .unwrap_or_else(|| pos.leg_underlyings[j].clone()),\n                    expiration: day_opts.get_str(&pos.leg_contracts[j], &schema.expiration)\n                        .unwrap_or_else(|| pos.leg_expirations[j].clone()),\n                    opt_type: pos.leg_types[j].clone(),\n                    strike: day_opts.get_f64(&pos.leg_contracts[j], &schema.strike)\n                        .unwrap_or(pos.leg_strikes[j]),\n                    cost,\n                    order: order.to_string(),\n                });\n            }\n            trade_rows.push(TradeRow {\n                date,\n                leg_data,\n                total_cost: exit_cost,\n                qty: pos.quantity,\n            });\n            to_remove.push(i);\n        }\n    }\n\n    for &i in to_remove.iter().rev() {\n        positions.remove(i);\n    }\n    Ok(())\n}\n\n// ---------------------------------------------------------------------------\n// Liquidate all remaining option positions (full rebalance).\n// ---------------------------------------------------------------------------\n\n#[allow(dead_code)]\nfn liquidate_all_positions(\n    positions: &mut Vec<Position>,\n    cash: &mut f64,\n    day_opts: &DayOptions,\n    spc: i64,\n    legs: &[LegConfig],\n    schema: &SchemaMapping,\n    date: i64,\n    trade_rows: &mut Vec<TradeRow>,\n    cost_model: &CostModel,\n    day_stocks: Option<&DayStocks>,\n) {\n    for pos in positions.iter() {\n        let exit_cost = compute_position_exit_cost(pos, day_opts, spc, day_stocks);\n        *cash -= exit_cost * pos.quantity;\n\n        let commission = cost_model.option_cost(\n            exit_cost.abs(), pos.quantity.abs(), spc,\n        );\n        *cash -= commission;\n\n        let mut leg_data = Vec::new();\n        for (j, leg) in legs.iter().enumerate() {\n            let exit_price_col = leg.direction.invert().price_column();\n            let price = day_opts.get_f64(&pos.leg_contracts[j], exit_price_col)\n                .unwrap_or_else(|| {\n                    let spot = day_stocks\n                        .and_then(|ds| ds.get_price(&pos.leg_underlyings[j]))\n                        .unwrap_or(0.0);\n                    intrinsic_value(&pos.leg_types[j], pos.leg_strikes[j], spot)\n                });\n            let cash_sign = if leg.direction == Direction::Buy { -1.0 } else { 1.0 };\n            let cost = cash_sign * price * spc as f64;\n            let order = match leg.direction {\n                Direction::Buy => \"STC\",\n                Direction::Sell => \"BTC\",\n            };\n            leg_data.push(LegTradeData {\n                contract: pos.leg_contracts[j].clone(),\n                underlying: day_opts.get_str(&pos.leg_contracts[j], &schema.underlying)\n                    .unwrap_or_else(|| pos.leg_underlyings[j].clone()),\n                expiration: day_opts.get_str(&pos.leg_contracts[j], &schema.expiration)\n                    .unwrap_or_else(|| pos.leg_expirations[j].clone()),\n                opt_type: pos.leg_types[j].clone(),\n                strike: day_opts.get_f64(&pos.leg_contracts[j], &schema.strike)\n                    .unwrap_or(pos.leg_strikes[j]),\n                cost,\n                order: order.to_string(),\n            });\n        }\n        trade_rows.push(TradeRow {\n            date,\n            leg_data,\n            total_cost: exit_cost,\n            qty: pos.quantity,\n        });\n    }\n    positions.clear();\n}\n\n// ---------------------------------------------------------------------------\n// Execute entries.\n// ---------------------------------------------------------------------------\n\n#[allow(clippy::too_many_arguments)]\nfn execute_entries(\n    legs: &[LegConfig],\n    entry_filters: &[Option<CompiledFilter>],\n    day_opts: &DayOptions,\n    held_contracts: &[String],\n    spc: i64,\n    budget: f64,\n    schema: &SchemaMapping,\n    date: i64,\n    trade_rows: &mut Vec<TradeRow>,\n    fill_model: &FillModel,\n    signal_selector: &SignalSelector,\n    risk_constraints: &[RiskConstraint],\n    portfolio_greeks: &Greeks,\n    total_capital: f64,\n    peak_value: f64,\n    max_notional_pct: Option<f64>,\n    existing_positions: &[Position],\n) -> PolarsResult<Option<Position>> {\n    let contract_col = &schema.contract;\n    if legs.is_empty() || budget <= 0.0 {\n        return Ok(None);\n    }\n\n    // Determine extra columns needed by selectors\n    let mut extra_cols: Vec<String> = Vec::new();\n    for col_name in signal_selector.column_requirements() {\n        extra_cols.push(col_name.to_string());\n    }\n    for leg in legs {\n        if let Some(ref sel) = leg.signal_selector {\n            for col_name in sel.column_requirements() {\n                if !extra_cols.contains(&col_name.to_string()) {\n                    extra_cols.push(col_name.to_string());\n                }\n            }\n        }\n    }\n\n    let mut leg_results: Vec<DataFrame> = Vec::new();\n    for (i, leg) in legs.iter().enumerate() {\n        let filter = match &entry_filters[i] {\n            Some(f) => f,\n            None => return Ok(None),\n        };\n        let entries = compute_leg_entries(\n            &day_opts.df, held_contracts, filter, contract_col,\n            leg.direction.price_column(),\n            leg.entry_sort_col.as_deref(), leg.entry_sort_asc,\n            spc, leg.direction == Direction::Sell,\n            &extra_cols,\n        )?;\n        if entries.height() == 0 {\n            return Ok(None);\n        }\n        leg_results.push(entries);\n    }\n\n    // Pre-filter candidates by affordability (mirrors Python's qty > 0 filter).\n    // Python computes qty = allocation // abs(total_cost) for every candidate row\n    // and removes rows where qty == 0 before the signal selector runs.\n    {\n        let min_len = leg_results.iter().map(|df| df.height()).min().unwrap_or(0);\n        if min_len == 0 {\n            return Ok(None);\n        }\n\n        // Compute per-row total cost (sum of costs across all legs at each row index)\n        let mut affordable = vec![false; min_len];\n        for row in 0..min_len {\n            let mut combined_cost = 0.0;\n            for leg_df in &leg_results {\n                if let Ok(col) = leg_df.column(\"cost\") {\n                    if let Ok(ca) = col.f64() {\n                        combined_cost += ca.get(row).unwrap_or(0.0);\n                    }\n                }\n            }\n            let abs_cost = combined_cost.abs();\n            if abs_cost > 0.0 && (budget / abs_cost).floor() >= 1.0 {\n                affordable[row] = true;\n            }\n        }\n\n        // Build a boolean mask and filter all legs to only affordable rows\n        let mask = BooleanChunked::from_slice(\"mask\".into(), &affordable);\n        for leg_df in &mut leg_results {\n            // Truncate to min_len first (align by position like Python's reset_index)\n            if leg_df.height() > min_len {\n                *leg_df = leg_df.slice(0, min_len);\n            }\n            *leg_df = leg_df.filter(&mask)?;\n        }\n\n        if leg_results.iter().any(|df| df.height() == 0) {\n            return Ok(None);\n        }\n    }\n\n    // Apply signal selector per leg to pick the best row\n    let mut leg_contracts = Vec::new();\n    let mut leg_types = Vec::new();\n    let mut leg_directions = Vec::new();\n    let mut leg_underlyings = Vec::new();\n    let mut leg_expirations = Vec::new();\n    let mut leg_strikes = Vec::new();\n    let mut total_cost = 0.0;\n    let mut original_total_cost = 0.0;\n    let mut leg_data = Vec::new();\n    let mut entry_greeks = Greeks::default();\n\n    for (i, leg_df) in leg_results.iter().enumerate() {\n        // Per-leg selector override, or engine-level selector\n        let sel = legs[i].signal_selector.as_ref().unwrap_or(signal_selector);\n        let row_idx = sel.select_index(leg_df);\n\n        let contract = leg_df.column(\"contract\")?.str()?.get(row_idx).unwrap_or(\"\").to_string();\n        let opt_type = leg_df.column(\"type\")?.str()?.get(row_idx).unwrap_or(\"\").to_string();\n        let original_cost = leg_df.column(\"cost\")?.f64()?.get(row_idx).unwrap_or(0.0);\n        let mut cost = original_cost;\n        let underlying = leg_df.column(\"underlying\")?.str()?.get(row_idx).unwrap_or(\"\").to_string();\n        // Handle expiration as either String or Datetime\n        let expiration = column_value_to_string(leg_df.column(\"expiration\")?, row_idx);\n        let strike = leg_df.column(\"strike\")?.f64()?.get(row_idx).unwrap_or(0.0);\n\n        // Apply fill model to re-price if not MarketAtBidAsk\n        let leg_fill = legs[i].fill_model.as_ref().unwrap_or(fill_model);\n        if !matches!(leg_fill, FillModel::MarketAtBidAsk) {\n            // Look up bid/ask/volume from day data for this contract\n            if let (Some(bid), Some(ask)) = (\n                day_opts.get_f64(&contract, \"bid\"),\n                day_opts.get_f64(&contract, \"ask\"),\n            ) {\n                let volume = day_opts.get_f64(&contract, \"volume\");\n                let is_buy = legs[i].direction == Direction::Buy;\n                let fill_price = leg_fill.fill_price(bid, ask, volume, is_buy);\n                let sign = if is_buy { 1.0 } else { -1.0 };\n                cost = sign * fill_price * spc as f64;\n            }\n        }\n\n        // Collect Greeks from the entry row (for risk checking)\n        let dir_sign = if legs[i].direction == Direction::Buy { 1.0 } else { -1.0 };\n        let delta = day_opts.get_f64(&contract, \"delta\").unwrap_or(0.0);\n        let gamma = day_opts.get_f64(&contract, \"gamma\").unwrap_or(0.0);\n        let theta = day_opts.get_f64(&contract, \"theta\").unwrap_or(0.0);\n        let vega = day_opts.get_f64(&contract, \"vega\").unwrap_or(0.0);\n\n        let order = match legs[i].direction {\n            Direction::Buy => \"BTO\",\n            Direction::Sell => \"STO\",\n        };\n\n        leg_contracts.push(contract.clone());\n        leg_types.push(opt_type.clone());\n        leg_directions.push(legs[i].direction);\n        leg_underlyings.push(underlying.clone());\n        leg_expirations.push(expiration.clone());\n        leg_strikes.push(strike);\n\n        leg_data.push(LegTradeData {\n            contract,\n            underlying,\n            expiration,\n            opt_type,\n            strike,\n            cost,\n            order: order.to_string(),\n        });\n        total_cost += cost;\n        original_total_cost += original_cost;\n\n        // Accumulate greeks (will be scaled by qty later)\n        entry_greeks.delta += delta * dir_sign;\n        entry_greeks.gamma += gamma * dir_sign;\n        entry_greeks.theta += theta * dir_sign;\n        entry_greeks.vega += vega * dir_sign;\n    }\n\n    if original_total_cost.abs() == 0.0 {\n        return Ok(None);\n    }\n\n    // Qty is computed from the original (pre-fill-model) cost, matching Python behavior\n    // where qty = allocation // abs(original_cost) is computed before fill model repricing.\n    let mut qty = (budget / original_total_cost.abs()).floor();\n    if qty <= 0.0 {\n        return Ok(None);\n    }\n\n    // max_notional_pct: cap qty so total short notional stays under limit\n    if let Some(max_pct) = max_notional_pct {\n        let mut short_notional_per_contract = 0.0;\n        for (i, leg) in legs.iter().enumerate() {\n            if leg.direction == Direction::Sell {\n                short_notional_per_contract += leg_strikes[i] * spc as f64;\n            }\n        }\n        if short_notional_per_contract > 0.0 {\n            let existing_short_notional: f64 = existing_positions.iter().map(|pos| {\n                pos.leg_strikes.iter().enumerate()\n                    .filter(|(j, _)| pos.leg_directions[*j] == Direction::Sell)\n                    .map(|(_, &strike)| strike * pos.quantity * spc as f64)\n                    .sum::<f64>()\n            }).sum();\n            let max_notional = max_pct * total_capital;\n            let available = (max_notional - existing_short_notional).max(0.0);\n            let max_qty = (available / short_notional_per_contract).floor();\n            qty = qty.min(max_qty);\n            if qty <= 0.0 {\n                return Ok(None);\n            }\n        }\n    }\n\n    // Scale greeks by quantity\n    let scaled_greeks = entry_greeks.scale(qty);\n\n    // Risk check: reject entry if any constraint fails\n    if !risk_constraints.is_empty()\n        && !risk::check_all(risk_constraints, portfolio_greeks, &scaled_greeks, total_capital, peak_value)\n    {\n        return Ok(None);\n    }\n\n    trade_rows.push(TradeRow {\n        date,\n        leg_data,\n        total_cost,\n        qty,\n    });\n\n    Ok(Some(Position {\n        leg_contracts,\n        leg_types,\n        leg_directions,\n        quantity: qty,\n        entry_cost: total_cost,\n        greeks: scaled_greeks,\n        leg_underlyings,\n        leg_expirations,\n        leg_strikes,\n    }))\n}\n\n// ---------------------------------------------------------------------------\n// Compute balance for a date range — uses pre-partitioned data.\n// ---------------------------------------------------------------------------\n\nfn compute_balance_period(\n    positions: &[Position],\n    stock_holdings: &[StockHolding],\n    partitioned: &PartitionedData,\n    start_date: i64,\n    end_date: i64,\n    spc: i64,\n    cash: f64,\n    legs: &[LegConfig],\n    balance_days: &mut Vec<BalanceDay>,\n) {\n    // Binary search for dates in [start_date, end_date).\n    let dates = &partitioned.all_dates_sorted;\n    let start_idx = dates.partition_point(|&d| d < start_date);\n    let end_idx = dates.partition_point(|&d| d < end_date);\n\n    for &d in &dates[start_idx..end_idx] {\n        let day_opts = partitioned.options.get(&d);\n        let day_stocks = partitioned.stocks.get(&d);\n\n        // Compute calls/puts capital for each position\n        let mut calls_cap = 0.0;\n        let mut puts_cap = 0.0;\n        let mut options_qty = 0.0;\n\n        if let Some(opts) = day_opts {\n            for pos in positions {\n                options_qty += pos.quantity;\n                for (j, leg) in legs.iter().enumerate() {\n                    if j >= pos.leg_contracts.len() { continue; }\n                    let exit_price_col = leg.direction.invert().price_column();\n                    let price = opts.get_f64(&pos.leg_contracts[j], exit_price_col)\n                        .unwrap_or_else(|| {\n                            let spot = day_stocks\n                                .and_then(|ds| ds.get_price(&pos.leg_underlyings[j]))\n                                .unwrap_or(0.0);\n                            intrinsic_value(&pos.leg_types[j], pos.leg_strikes[j], spot)\n                        });\n                    let sign = leg.direction.invert().sign();\n                    let value = sign * price * pos.quantity * spc as f64;\n\n                    if pos.leg_types[j] == \"call\" {\n                        calls_cap += value;\n                    } else {\n                        puts_cap += value;\n                    }\n                }\n            }\n        }\n\n        // Compute stock values\n        let mut stock_values = Vec::new();\n        let mut stock_qtys = Vec::new();\n        let mut stocks_qty = 0.0;\n        for holding in stock_holdings {\n            let price = day_stocks\n                .and_then(|ds| ds.get_price(&holding.symbol))\n                .unwrap_or(holding.price);\n            stock_values.push((holding.symbol.clone(), holding.qty * price));\n            stock_qtys.push((holding.symbol.clone(), holding.qty));\n            stocks_qty += holding.qty;\n        }\n\n        balance_days.push(BalanceDay {\n            date: d,\n            cash,\n            calls_capital: calls_cap,\n            puts_capital: puts_cap,\n            options_qty,\n            stocks_qty,\n            stock_values,\n            stock_qtys,\n        });\n    }\n}\n\n// ---------------------------------------------------------------------------\n// SMA computation — uses pre-partitioned stocks data.\n// ---------------------------------------------------------------------------\n\nfn compute_sma_map(\n    partitioned: &PartitionedData,\n    symbols: &[String],\n    sma_days: usize,\n) -> HashMap<i64, HashMap<String, f64>> {\n    let mut result: HashMap<i64, HashMap<String, f64>> = HashMap::new();\n\n    for symbol in symbols {\n        // Collect (date_ns, price) pairs for this symbol from pre-partitioned data.\n        let mut date_prices: Vec<(i64, f64)> = Vec::new();\n        for &date_ns in &partitioned.all_dates_sorted {\n            if let Some(ds) = partitioned.stocks.get(&date_ns) {\n                if let Some(price) = ds.get_price(symbol) {\n                    date_prices.push((date_ns, price));\n                }\n            }\n        }\n\n        // Compute rolling SMA\n        for (i, &(date_ns, _)) in date_prices.iter().enumerate() {\n            if i + 1 < sma_days {\n                continue; // Not enough data yet\n            }\n            let start = i + 1 - sma_days;\n            let sum: f64 = date_prices[start..=i].iter().map(|&(_, p)| p).sum();\n            let sma = sum / sma_days as f64;\n\n            result.entry(date_ns)\n                .or_default()\n                .insert(symbol.clone(), sma);\n        }\n    }\n\n    result\n}\n\n// ---------------------------------------------------------------------------\n// Build result DataFrames.\n// ---------------------------------------------------------------------------\n\nfn build_result(\n    trade_rows: &[TradeRow],\n    balance_days: &[BalanceDay],\n    legs: &[LegConfig],\n    final_cash: f64,\n) -> PolarsResult<BacktestResult> {\n    // Build trade log as flat DataFrame (Python converts to MultiIndex)\n    let n_trades = trade_rows.len();\n    let mut trade_dates: Vec<String> = Vec::with_capacity(n_trades);\n    let mut trade_total_costs: Vec<f64> = Vec::with_capacity(n_trades);\n    let mut trade_qtys: Vec<f64> = Vec::with_capacity(n_trades);\n\n    // Per-leg columns\n    let mut leg_columns: Vec<Vec<(String, String, String, String, f64, f64, String)>> =\n        legs.iter().map(|_| Vec::with_capacity(n_trades)).collect();\n\n    for tr in trade_rows {\n        trade_dates.push(ns_to_datestring(tr.date));\n        trade_total_costs.push(tr.total_cost);\n        trade_qtys.push(tr.qty);\n        for (j, ld) in tr.leg_data.iter().enumerate() {\n            if j < leg_columns.len() {\n                leg_columns[j].push((\n                    ld.contract.clone(), ld.underlying.clone(), ld.expiration.clone(),\n                    ld.opt_type.clone(), ld.strike, ld.cost, ld.order.clone(),\n                ));\n            }\n        }\n    }\n\n    let mut trade_cols: Vec<Column> = vec![\n        Column::new(\"totals__date\".into(), &trade_dates),\n        Column::new(\"totals__cost\".into(), &trade_total_costs),\n        Column::new(\"totals__qty\".into(), &trade_qtys),\n    ];\n    for (j, leg) in legs.iter().enumerate() {\n        if j < leg_columns.len() {\n            let data = &leg_columns[j];\n            let prefix = &leg.name;\n            trade_cols.push(Column::new(format!(\"{prefix}__contract\").into(),\n                data.iter().map(|d| d.0.as_str()).collect::<Vec<_>>()));\n            trade_cols.push(Column::new(format!(\"{prefix}__underlying\").into(),\n                data.iter().map(|d| d.1.as_str()).collect::<Vec<_>>()));\n            trade_cols.push(Column::new(format!(\"{prefix}__expiration\").into(),\n                data.iter().map(|d| d.2.as_str()).collect::<Vec<_>>()));\n            trade_cols.push(Column::new(format!(\"{prefix}__type\").into(),\n                data.iter().map(|d| d.3.as_str()).collect::<Vec<_>>()));\n            trade_cols.push(Column::new(format!(\"{prefix}__strike\").into(),\n                data.iter().map(|d| d.4).collect::<Vec<_>>()));\n            trade_cols.push(Column::new(format!(\"{prefix}__cost\").into(),\n                data.iter().map(|d| d.5).collect::<Vec<_>>()));\n            trade_cols.push(Column::new(format!(\"{prefix}__order\").into(),\n                data.iter().map(|d| d.6.as_str()).collect::<Vec<_>>()));\n        }\n    }\n    let trade_log = DataFrame::new(trade_cols)?;\n\n    // Build balance DataFrame\n    let n_days = balance_days.len();\n    let mut bal_dates: Vec<String> = Vec::with_capacity(n_days);\n    let mut bal_cash: Vec<f64> = Vec::with_capacity(n_days);\n    let mut bal_calls: Vec<f64> = Vec::with_capacity(n_days);\n    let mut bal_puts: Vec<f64> = Vec::with_capacity(n_days);\n    let mut bal_opts_qty: Vec<f64> = Vec::with_capacity(n_days);\n    let mut bal_stocks_qty: Vec<f64> = Vec::with_capacity(n_days);\n\n    // Collect all stock symbols\n    let mut stock_symbols: Vec<String> = Vec::new();\n    if let Some(first) = balance_days.first() {\n        stock_symbols = first.stock_values.iter().map(|(s, _)| s.clone()).collect();\n    }\n    let mut stock_val_cols: Vec<Vec<f64>> = stock_symbols.iter().map(|_| Vec::with_capacity(n_days)).collect();\n    let mut stock_qty_cols: Vec<Vec<f64>> = stock_symbols.iter().map(|_| Vec::with_capacity(n_days)).collect();\n\n    for day in balance_days {\n        bal_dates.push(ns_to_datestring(day.date));\n        bal_cash.push(day.cash);\n        bal_calls.push(day.calls_capital);\n        bal_puts.push(day.puts_capital);\n        bal_opts_qty.push(day.options_qty);\n        bal_stocks_qty.push(day.stocks_qty);\n        for (k, sym) in stock_symbols.iter().enumerate() {\n            let val = day.stock_values.iter().find(|(s, _)| s == sym).map(|(_, v)| *v).unwrap_or(0.0);\n            let qty = day.stock_qtys.iter().find(|(s, _)| s == sym).map(|(_, q)| *q).unwrap_or(0.0);\n            stock_val_cols[k].push(val);\n            stock_qty_cols[k].push(qty);\n        }\n    }\n\n    let mut bal_cols: Vec<Column> = vec![\n        Column::new(\"date\".into(), &bal_dates),\n        Column::new(\"cash\".into(), &bal_cash),\n        Column::new(\"calls capital\".into(), &bal_calls),\n        Column::new(\"puts capital\".into(), &bal_puts),\n        Column::new(\"options qty\".into(), &bal_opts_qty),\n        Column::new(\"stocks qty\".into(), &bal_stocks_qty),\n    ];\n    for (k, sym) in stock_symbols.iter().enumerate() {\n        bal_cols.push(Column::new(sym.as_str().into(), &stock_val_cols[k]));\n        bal_cols.push(Column::new(format!(\"{sym} qty\").into(), &stock_qty_cols[k]));\n    }\n    let balance = DataFrame::new(bal_cols)?;\n\n    // Stats from balance\n    let totals: Vec<f64> = balance_days.iter().map(|d| {\n        let stock_val: f64 = d.stock_values.iter().map(|(_, v)| *v).sum();\n        d.cash + d.calls_capital + d.puts_capital + stock_val\n    }).collect();\n    let daily_returns = compute_daily_returns(&totals);\n    let result_stats = stats::compute_stats(&daily_returns, &[], 0.0);\n\n    Ok(BacktestResult { balance, trade_log, final_cash, stats: result_stats })\n}\n\n// ---------------------------------------------------------------------------\n// Small helpers.\n// ---------------------------------------------------------------------------\n\n/// Compute intrinsic value of an option: max(0, strike - spot) for puts,\n/// max(0, spot - strike) for calls.\nfn intrinsic_value(opt_type: &str, strike: f64, underlying_price: f64) -> f64 {\n    if opt_type == \"call\" {\n        (underlying_price - strike).max(0.0)\n    } else {\n        (strike - underlying_price).max(0.0)\n    }\n}\n\n/// Compute the total exit cost for a position — O(1) per leg via DayOptions.\nfn compute_position_exit_cost(pos: &Position, day_opts: &DayOptions, spc: i64, day_stocks: Option<&DayStocks>) -> f64 {\n    let mut total = 0.0;\n    for (i, contract) in pos.leg_contracts.iter().enumerate() {\n        let dir = pos.leg_directions[i];\n        let price = day_opts.get_f64(contract, dir.invert().price_column())\n            .unwrap_or_else(|| {\n                let spot = day_stocks\n                    .and_then(|ds| ds.get_price(&pos.leg_underlyings[i]))\n                    .unwrap_or(0.0);\n                intrinsic_value(&pos.leg_types[i], pos.leg_strikes[i], spot)\n            });\n        let cash_sign = if dir == Direction::Buy { -1.0 } else { 1.0 };\n        total += cash_sign * price * spc as f64;\n    }\n    total\n}\n\nfn compute_stock_capital(holdings: &[StockHolding], day_stocks: Option<&DayStocks>) -> f64 {\n    let ds = match day_stocks {\n        Some(ds) => ds,\n        None => return 0.0,\n    };\n    holdings.iter().map(|h| {\n        ds.get_price(&h.symbol).unwrap_or(0.0) * h.qty\n    }).sum()\n}\n\nfn compute_options_capital(\n    positions: &[Position], day_opts: &DayOptions, spc: i64, day_stocks: Option<&DayStocks>,\n) -> f64 {\n    positions.iter().map(|pos| {\n        -compute_position_exit_cost(pos, day_opts, spc, day_stocks) * pos.quantity\n    }).sum()\n}\n\n/// Compute aggregate portfolio greeks from CURRENT market data, matching\n/// Python's `_compute_portfolio_greeks(options)` which merges inventory\n/// contracts with today's options to get current delta/gamma/theta/vega.\nfn compute_portfolio_greeks_from_market(\n    positions: &[Position],\n    day_opts: &DayOptions,\n    legs: &[LegConfig],\n) -> Greeks {\n    let mut total = Greeks::default();\n    for pos in positions {\n        for (j, leg) in legs.iter().enumerate() {\n            if j >= pos.leg_contracts.len() { continue; }\n            let contract = &pos.leg_contracts[j];\n            let dir_sign = if leg.direction == Direction::Buy { 1.0 } else { -1.0 };\n            let delta = day_opts.get_f64(contract, \"delta\").unwrap_or(0.0);\n            let gamma = day_opts.get_f64(contract, \"gamma\").unwrap_or(0.0);\n            let theta = day_opts.get_f64(contract, \"theta\").unwrap_or(0.0);\n            let vega = day_opts.get_f64(contract, \"vega\").unwrap_or(0.0);\n\n            total.delta += delta * dir_sign * pos.quantity;\n            total.gamma += gamma * dir_sign * pos.quantity;\n            total.theta += theta * dir_sign * pos.quantity;\n            total.vega += vega * dir_sign * pos.quantity;\n        }\n    }\n    total\n}\n\nfn buy_stocks(\n    symbols: &[String], percentages: &[f64],\n    day_stocks: Option<&DayStocks>,\n    allocation: f64, holdings: &mut Vec<StockHolding>,\n    cost_model: &CostModel,\n    cash: &mut f64,\n    sma_prices: Option<&HashMap<String, f64>>,\n) {\n    let ds = match day_stocks {\n        Some(ds) => ds,\n        None => return,\n    };\n    let mut stock_cost_total = 0.0;\n    let mut commission_total = 0.0;\n    for (symbol, pct) in symbols.iter().zip(percentages) {\n        if let Some(price) = ds.get_price(symbol) {\n            if price > 0.0 {\n                // SMA gating: only buy if sma < price\n                if let Some(sma_map) = sma_prices {\n                    if let Some(&sma_val) = sma_map.get(symbol) {\n                        if sma_val >= price {\n                            holdings.push(StockHolding { symbol: symbol.clone(), qty: 0.0, price });\n                            continue;\n                        }\n                    }\n                }\n                let qty = (allocation * pct / price).floor();\n                commission_total += cost_model.stock_cost(price, qty);\n                stock_cost_total += qty * price;\n                holdings.push(StockHolding { symbol: symbol.clone(), qty, price });\n            }\n        }\n    }\n    *cash -= stock_cost_total + commission_total;\n}\n\nfn compute_daily_returns(totals: &[f64]) -> Vec<f64> {\n    if totals.len() < 2 { return Vec::new(); }\n    totals.windows(2).map(|w| if w[0] != 0.0 { (w[1] - w[0]) / w[0] } else { 0.0 }).collect()\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn daily_returns_basic() {\n        let totals = vec![100.0, 110.0, 105.0];\n        let returns = compute_daily_returns(&totals);\n        assert_eq!(returns.len(), 2);\n        assert!((returns[0] - 0.1).abs() < 1e-10);\n        assert!((returns[1] - (-5.0 / 110.0)).abs() < 1e-10);\n    }\n\n    #[test]\n    fn daily_returns_empty() {\n        assert!(compute_daily_returns(&[]).is_empty());\n        assert!(compute_daily_returns(&[100.0]).is_empty());\n    }\n\n    #[test]\n    fn ns_to_datestring_epoch() {\n        assert_eq!(ns_to_datestring(0), \"1970-01-01 00:00:00\");\n    }\n\n    #[test]\n    fn ns_roundtrip() {\n        let s = \"2024-06-15 00:00:00\";\n        let ns = parse_datestring_to_ns(s).unwrap();\n        assert_eq!(ns_to_datestring(ns), s);\n    }\n\n    #[test]\n    fn intrinsic_value_put_itm() {\n        // Put with strike 400, spot 380 → intrinsic = 20\n        assert!((intrinsic_value(\"put\", 400.0, 380.0) - 20.0).abs() < 1e-10);\n    }\n\n    #[test]\n    fn intrinsic_value_put_otm() {\n        // Put with strike 400, spot 420 → intrinsic = 0\n        assert!((intrinsic_value(\"put\", 400.0, 420.0)).abs() < 1e-10);\n    }\n\n    #[test]\n    fn intrinsic_value_call_itm() {\n        // Call with strike 400, spot 420 → intrinsic = 20\n        assert!((intrinsic_value(\"call\", 400.0, 420.0) - 20.0).abs() < 1e-10);\n    }\n\n    #[test]\n    fn intrinsic_value_call_otm() {\n        // Call with strike 400, spot 380 → intrinsic = 0\n        assert!((intrinsic_value(\"call\", 400.0, 380.0)).abs() < 1e-10);\n    }\n\n    #[test]\n    fn intrinsic_value_atm() {\n        // ATM: strike == spot → intrinsic = 0 for both\n        assert!((intrinsic_value(\"put\", 400.0, 400.0)).abs() < 1e-10);\n        assert!((intrinsic_value(\"call\", 400.0, 400.0)).abs() < 1e-10);\n    }\n\n    #[test]\n    fn exit_cost_uses_intrinsic_when_missing() {\n        // Position with a put at strike 400, spot at 380\n        // Contract not in day options → should use intrinsic (20.0)\n        let pos = Position {\n            leg_contracts: vec![\"MISSING_CONTRACT\".into()],\n            leg_types: vec![\"put\".into()],\n            leg_directions: vec![Direction::Sell],\n            quantity: 1.0,\n            entry_cost: -100.0,\n            greeks: Greeks::default(),\n            leg_underlyings: vec![\"SPY\".into()],\n            leg_expirations: vec![\"2024-06-01\".into()],\n            leg_strikes: vec![400.0],\n        };\n\n        let empty_opts = DayOptions {\n            df: DataFrame::default(),\n            contract_idx: HashMap::new(),\n        };\n\n        let mut prices = HashMap::new();\n        prices.insert(\"SPY\".to_string(), 380.0);\n        let day_stocks = DayStocks { prices };\n\n        // Sell direction → exit price col is \"ask\" (invert of Sell = Buy → price_column = \"ask\")\n        // cash_sign for Sell = +1\n        // exit_cost = +1 * 20.0 * 100 = 2000.0\n        let exit_cost = compute_position_exit_cost(&pos, &empty_opts, 100, Some(&day_stocks));\n        assert!((exit_cost - 2000.0).abs() < 1e-10,\n            \"Expected exit cost 2000.0 for ITM short put, got {exit_cost}\");\n    }\n}\n"
  },
  {
    "path": "rust/ob_core/src/balance.rs",
    "content": "//! Full _update_balance orchestration in Rust.\n//!\n//! Mirrors Python's BacktestEngine._update_balance: for a date range,\n//! join inventory to market data, compute calls/puts capital, stock values,\n//! and assemble balance rows.\n\nuse polars::prelude::*;\n\nuse crate::inventory::{aggregate_by_type, join_inventory_to_market};\nuse crate::types::Direction;\n\n/// Leg inventory data needed for balance computation.\npub struct LegInventory {\n    pub contracts: Vec<String>,\n    pub qtys: Vec<f64>,\n    pub types: Vec<String>,\n    pub direction: Direction,\n    pub underlyings: Vec<String>,\n    pub strikes: Vec<f64>,\n}\n\n/// Stock inventory data.\npub struct StockInventory {\n    pub symbols: Vec<String>,\n    pub qtys: Vec<f64>,\n}\n\n/// Compute balance for a date range.\n///\n/// This is the full orchestration of the hot path:\n/// 1. For each leg, join inventory to market data\n/// 2. Aggregate calls/puts capital by date\n/// 3. Compute stock values\n/// 4. Assemble balance rows\npub fn compute_balance(\n    legs: &[LegInventory],\n    stocks: &StockInventory,\n    options_data: &DataFrame,\n    stocks_data: &DataFrame,\n    contract_col: &str,\n    date_col: &str,\n    stocks_date_col: &str,\n    stocks_sym_col: &str,\n    stocks_price_col: &str,\n    shares_per_contract: i64,\n    cash: f64,\n) -> PolarsResult<DataFrame> {\n    // Build a stocks snapshot for intrinsic value fallback:\n    // For balance computation we pass the full stocks_data to inventory join\n    // so it can look up spot prices for missing contracts.\n    // Get unique dates from options\n    let dates = options_data\n        .column(date_col)?\n        .unique()?;\n\n    let mut calls_total = Series::new(\"calls_capital\".into(), vec![0.0f64; dates.len()]);\n    let mut puts_total = Series::new(\"puts_capital\".into(), vec![0.0f64; dates.len()]);\n\n    // Process each leg\n    for leg in legs {\n        if leg.contracts.is_empty() {\n            continue;\n        }\n\n        let cost_field = match leg.direction {\n            Direction::Buy => \"bid\",   // exit price for buy = bid\n            Direction::Sell => \"ask\",  // exit price for sell = ask\n        };\n\n        let joined = join_inventory_to_market(\n            &leg.contracts,\n            &leg.qtys,\n            &leg.types,\n            &leg.underlyings,\n            &leg.strikes,\n            options_data,\n            Some(stocks_data),\n            contract_col,\n            date_col,\n            cost_field,\n            Some(stocks_sym_col),\n            Some(stocks_price_col),\n            leg.direction.invert(),\n            shares_per_contract,\n        )?;\n\n        let (calls_df, puts_df) = aggregate_by_type(&joined, date_col)?;\n\n        // Add to running totals (would need date alignment in production)\n        if calls_df.height() > 0 {\n            if let Ok(col) = calls_df.column(\"calls_capital\") {\n                let vals = col.f64()?;\n                let total_vals = calls_total.f64()?;\n                let new: Float64Chunked = total_vals\n                    .into_iter()\n                    .zip(vals.into_iter())\n                    .map(|(a, b)| Some(a.unwrap_or(0.0) + b.unwrap_or(0.0)))\n                    .collect();\n                calls_total = new.into_series();\n            }\n        }\n\n        if puts_df.height() > 0 {\n            if let Ok(col) = puts_df.column(\"puts_capital\") {\n                let vals = col.f64()?;\n                let total_vals = puts_total.f64()?;\n                let new: Float64Chunked = total_vals\n                    .into_iter()\n                    .zip(vals.into_iter())\n                    .map(|(a, b)| Some(a.unwrap_or(0.0) + b.unwrap_or(0.0)))\n                    .collect();\n                puts_total = new.into_series();\n            }\n        }\n    }\n\n    // Compute stock values\n    let stock_values = compute_stock_values(\n        stocks,\n        stocks_data,\n        stocks_date_col,\n        stocks_sym_col,\n        stocks_price_col,\n    )?;\n\n    // Assemble balance DataFrame\n    let cash_series = Series::new(\"cash\".into(), vec![cash; dates.len()]);\n    let options_qty: f64 = legs.iter().flat_map(|l| &l.qtys).sum();\n    let options_qty_series = Series::new(\"options_qty\".into(), vec![options_qty; dates.len()]);\n    let stocks_qty: f64 = stocks.qtys.iter().sum();\n    let stocks_qty_series = Series::new(\"stocks_qty\".into(), vec![stocks_qty; dates.len()]);\n\n    let mut columns = vec![\n        dates.clone().into_column(),\n        cash_series.into_column(),\n        options_qty_series.into_column(),\n        calls_total.with_name(\"calls_capital\".into()).into_column(),\n        puts_total.with_name(\"puts_capital\".into()).into_column(),\n        stocks_qty_series.into_column(),\n    ];\n\n    // Add stock value columns\n    for col in stock_values.get_columns() {\n        columns.push(col.clone());\n    }\n\n    DataFrame::new(columns)\n}\n\nfn compute_stock_values(\n    stocks: &StockInventory,\n    stocks_data: &DataFrame,\n    date_col: &str,\n    sym_col: &str,\n    price_col: &str,\n) -> PolarsResult<DataFrame> {\n    if stocks.symbols.is_empty() {\n        return Ok(DataFrame::default());\n    }\n\n    let mut result_cols: Vec<Column> = Vec::new();\n\n    for (symbol, qty) in stocks.symbols.iter().zip(stocks.qtys.iter()) {\n        let filtered = stocks_data\n            .clone()\n            .lazy()\n            .filter(col(sym_col).eq(lit(symbol.as_str())))\n            .select([\n                col(date_col),\n                (col(price_col) * lit(*qty)).alias(symbol.as_str()),\n            ])\n            .collect()?;\n\n        if let Ok(val_col) = filtered.column(symbol.as_str()) {\n            result_cols.push(val_col.clone());\n        }\n    }\n\n    if result_cols.is_empty() {\n        return Ok(DataFrame::default());\n    }\n\n    DataFrame::new(result_cols)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn compute_balance_empty_legs() {\n        let opts = df!(\n            \"optionroot\" => &[\"A\"],\n            \"quotedate\" => &[\"2024-01-01\"],\n            \"ask\" => &[1.0],\n            \"bid\" => &[0.9],\n        )\n        .unwrap();\n\n        let stocks_df = df!(\n            \"date\" => &[\"2024-01-01\"],\n            \"symbol\" => &[\"SPY\"],\n            \"adjClose\" => &[450.0],\n        )\n        .unwrap();\n\n        let result = compute_balance(\n            &[],  // no legs\n            &StockInventory {\n                symbols: vec![\"SPY\".into()],\n                qtys: vec![100.0],\n            },\n            &opts,\n            &stocks_df,\n            \"optionroot\",\n            \"quotedate\",\n            \"date\",\n            \"symbol\",\n            \"adjClose\",\n            100,\n            1_000_000.0,\n        );\n\n        assert!(result.is_ok());\n    }\n}\n"
  },
  {
    "path": "rust/ob_core/src/convexity_backtest.rs",
    "content": "/// Backtest engine: monthly rebalance loop for tail hedge overlay.\n///\n/// Model: 100% invested in equity (SPY). Each month, sell a fixed budget\n/// worth of equity to buy ~10-delta puts. Put proceeds are reinvested\n/// into equity at settlement. Budget is fixed at initial_capital * budget_pct\n/// (not scaled with portfolio growth) to avoid unrealistic compounding.\n\nuse chrono::{DateTime, Datelike};\n\nuse crate::convexity_scoring;\n\nstruct Position {\n    strike: f64,\n    expiration_ns: i64,\n    entry_ask: f64,\n    contracts: i32,\n}\n\npub struct MonthRecord {\n    pub date_ns: i64,\n    pub shares: f64,\n    pub stock_price: f64,\n    pub equity_value: f64,\n    pub put_cost: f64,\n    pub put_exit_value: f64,\n    pub put_pnl: f64,\n    pub portfolio_value: f64,\n    pub convexity_ratio: f64,\n    pub strike: f64,\n    pub contracts: i32,\n}\n\npub struct BacktestResult {\n    pub records: Vec<MonthRecord>,\n    pub daily_dates_ns: Vec<i64>,\n    pub daily_balances: Vec<f64>,\n}\n\nfn ns_to_year_month(ns: i64) -> (i32, u32) {\n    let secs = ns / 1_000_000_000;\n    let dt = DateTime::from_timestamp(secs, 0).expect(\"valid timestamp\");\n    (dt.year(), dt.month())\n}\n\n/// Extract monthly rebalance dates (first trading day of each month).\nfn monthly_rebalance_dates(stock_dates_ns: &[i64]) -> Vec<i64> {\n    let mut dates = Vec::new();\n    if stock_dates_ns.is_empty() {\n        return dates;\n    }\n\n    let mut prev_ym = ns_to_year_month(stock_dates_ns[0]);\n    dates.push(stock_dates_ns[0]);\n\n    for &d in &stock_dates_ns[1..] {\n        let ym = ns_to_year_month(d);\n        if ym != prev_ym {\n            dates.push(d);\n            prev_ym = ym;\n        }\n    }\n\n    dates\n}\n\n/// Binary search for first index where arr[i] >= target.\nfn lower_bound(arr: &[i64], target: i64) -> usize {\n    arr.partition_point(|&x| x < target)\n}\n\n/// Find the index range [start, end) for a specific date in sorted data.\nfn find_date_range(dates_ns: &[i64], target: i64) -> (usize, usize) {\n    let start = lower_bound(dates_ns, target);\n    if start >= dates_ns.len() || dates_ns[start] != target {\n        return (start, start);\n    }\n    let mut end = start + 1;\n    while end < dates_ns.len() && dates_ns[end] == target {\n        end += 1;\n    }\n    (start, end)\n}\n\n/// Find stock price on or before a given date.\nfn stock_price_on(stock_dates_ns: &[i64], stock_prices: &[f64], target_ns: i64) -> Option<f64> {\n    let idx = lower_bound(stock_dates_ns, target_ns);\n    if idx < stock_dates_ns.len() && stock_dates_ns[idx] == target_ns {\n        Some(stock_prices[idx])\n    } else if idx > 0 {\n        Some(stock_prices[idx - 1])\n    } else {\n        None\n    }\n}\n\n/// Close a position: find exit value from options data or use intrinsic.\nfn close_position(\n    pos: &Position,\n    rebal_date_ns: i64,\n    put_dates_ns: &[i64],\n    put_expirations_ns: &[i64],\n    put_strikes: &[f64],\n    put_bids: &[f64],\n    stock_dates_ns: &[i64],\n    stock_prices: &[f64],\n    current_stock_price: f64,\n) -> f64 {\n    if pos.expiration_ns <= rebal_date_ns {\n        let exp_price = stock_price_on(stock_dates_ns, stock_prices, pos.expiration_ns)\n            .unwrap_or(current_stock_price);\n        let intrinsic = (pos.strike - exp_price).max(0.0);\n        intrinsic * 100.0 * pos.contracts as f64\n    } else {\n        let (start, end) = find_date_range(put_dates_ns, rebal_date_ns);\n        for j in start..end {\n            if (put_strikes[j] - pos.strike).abs() < 0.001\n                && put_expirations_ns[j] == pos.expiration_ns\n            {\n                return put_bids[j] * 100.0 * pos.contracts as f64;\n            }\n        }\n        let intrinsic = (pos.strike - current_stock_price).max(0.0);\n        intrinsic * 100.0 * pos.contracts as f64\n    }\n}\n\n#[allow(clippy::too_many_arguments)]\npub fn run_backtest(\n    put_dates_ns: &[i64],\n    put_expirations_ns: &[i64],\n    put_strikes: &[f64],\n    put_bids: &[f64],\n    put_asks: &[f64],\n    put_deltas: &[f64],\n    put_underlying: &[f64],\n    put_dtes: &[i32],\n    _put_ivs: &[f64],\n    stock_dates_ns: &[i64],\n    stock_prices: &[f64],\n    initial_capital: f64,\n    budget_pct: f64,\n    target_delta: f64,\n    dte_min: i32,\n    dte_max: i32,\n    tail_drop: f64,\n) -> BacktestResult {\n    let rebalance_dates = monthly_rebalance_dates(stock_dates_ns);\n\n    let mut records = Vec::with_capacity(rebalance_dates.len());\n    let mut daily_dates: Vec<i64> = Vec::with_capacity(stock_dates_ns.len());\n    let mut daily_balances: Vec<f64> = Vec::with_capacity(stock_dates_ns.len());\n\n    if rebalance_dates.is_empty() || stock_dates_ns.is_empty() {\n        return BacktestResult {\n            records,\n            daily_dates_ns: daily_dates,\n            daily_balances,\n        };\n    }\n\n    let first_price = stock_price_on(stock_dates_ns, stock_prices, rebalance_dates[0])\n        .unwrap_or(stock_prices[0]);\n    let mut shares = initial_capital / first_price;\n    let mut position: Option<Position> = None;\n    let fixed_budget = initial_capital * budget_pct;\n\n    for (i, &rebal_date) in rebalance_dates.iter().enumerate() {\n        let stock_price =\n            stock_price_on(stock_dates_ns, stock_prices, rebal_date).unwrap_or(first_price);\n\n        // 1. Close existing position — reinvest proceeds into equity\n        let (put_exit_value, prev_put_cost) = if let Some(ref pos) = position {\n            let cost = pos.entry_ask * 100.0 * pos.contracts as f64;\n            let exit_val = close_position(\n                pos,\n                rebal_date,\n                put_dates_ns,\n                put_expirations_ns,\n                put_strikes,\n                put_bids,\n                stock_dates_ns,\n                stock_prices,\n                stock_price,\n            );\n            if stock_price > 0.0 {\n                shares += exit_val / stock_price;\n            }\n            (exit_val, cost)\n        } else {\n            (0.0, 0.0)\n        };\n        let put_pnl = put_exit_value - prev_put_cost;\n\n        // 2. Fixed budget — sell equity worth fixed_budget to fund puts\n        let budget = fixed_budget;\n        if stock_price > 0.0 {\n            shares -= budget / stock_price;\n        }\n\n        // 3. Open new position\n        let (opt_start, opt_end) = find_date_range(put_dates_ns, rebal_date);\n\n        let mut new_cost = 0.0;\n        let mut new_contracts = 0i32;\n        let mut new_strike = 0.0;\n        let mut new_ratio = 0.0;\n\n        if opt_start < opt_end {\n            let slice_deltas = &put_deltas[opt_start..opt_end];\n            let slice_dtes = &put_dtes[opt_start..opt_end];\n            let slice_asks = &put_asks[opt_start..opt_end];\n\n            if let Some(rel_idx) = convexity_scoring::find_target_put(\n                slice_deltas,\n                slice_dtes,\n                slice_asks,\n                target_delta,\n                dte_min,\n                dte_max,\n            ) {\n                let idx = opt_start + rel_idx;\n                let ask = put_asks[idx];\n                let strike = put_strikes[idx];\n                let underlying = put_underlying[idx];\n\n                if ask > 0.0 {\n                    let contracts = (budget / (ask * 100.0)) as i32;\n                    if contracts > 0 {\n                        let cost = ask * 100.0 * contracts as f64;\n                        new_cost = cost;\n                        new_contracts = contracts;\n                        new_strike = strike;\n\n                        let (ratio, _, _) =\n                            convexity_scoring::convexity_ratio(strike, underlying, ask, tail_drop);\n                        new_ratio = ratio;\n\n                        position = Some(Position {\n                            strike,\n                            expiration_ns: put_expirations_ns[idx],\n                            entry_ask: ask,\n                            contracts,\n                        });\n\n                        // Reinvest leftover\n                        let leftover = budget - cost;\n                        if stock_price > 0.0 {\n                            shares += leftover / stock_price;\n                        }\n                    } else {\n                        position = None;\n                        if stock_price > 0.0 {\n                            shares += budget / stock_price;\n                        }\n                    }\n                } else {\n                    position = None;\n                    if stock_price > 0.0 {\n                        shares += budget / stock_price;\n                    }\n                }\n            } else {\n                position = None;\n                if stock_price > 0.0 {\n                    shares += budget / stock_price;\n                }\n            }\n        } else {\n            position = None;\n            if stock_price > 0.0 {\n                shares += budget / stock_price;\n            }\n        }\n\n        let final_value = shares * stock_price;\n\n        records.push(MonthRecord {\n            date_ns: rebal_date,\n            shares,\n            stock_price,\n            equity_value: final_value,\n            put_cost: new_cost,\n            put_exit_value,\n            put_pnl,\n            portfolio_value: final_value,\n            convexity_ratio: new_ratio,\n            strike: new_strike,\n            contracts: new_contracts,\n        });\n\n        // 4. Record daily balances until next rebalance\n        let stock_idx = lower_bound(stock_dates_ns, rebal_date);\n        let next_rebal = if i + 1 < rebalance_dates.len() {\n            rebalance_dates[i + 1]\n        } else {\n            i64::MAX\n        };\n\n        for si in stock_idx..stock_dates_ns.len() {\n            if stock_dates_ns[si] >= next_rebal {\n                break;\n            }\n            daily_dates.push(stock_dates_ns[si]);\n            daily_balances.push(shares * stock_prices[si]);\n        }\n    }\n\n    BacktestResult {\n        records,\n        daily_dates_ns: daily_dates,\n        daily_balances,\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    fn make_ts(year: i32, month: u32, day: u32) -> i64 {\n        use chrono::NaiveDate;\n        let dt = NaiveDate::from_ymd_opt(year, month, day)\n            .unwrap()\n            .and_hms_opt(0, 0, 0)\n            .unwrap();\n        dt.and_utc().timestamp_nanos_opt().unwrap()\n    }\n\n    #[test]\n    fn test_monthly_rebalance_dates() {\n        let dates = vec![\n            make_ts(2020, 1, 2),\n            make_ts(2020, 1, 3),\n            make_ts(2020, 1, 6),\n            make_ts(2020, 2, 3),\n            make_ts(2020, 2, 4),\n            make_ts(2020, 3, 2),\n        ];\n        let rebal = monthly_rebalance_dates(&dates);\n        assert_eq!(rebal.len(), 3);\n        assert_eq!(rebal[0], dates[0]);\n        assert_eq!(rebal[1], dates[3]);\n        assert_eq!(rebal[2], dates[5]);\n    }\n\n    #[test]\n    fn test_stock_price_on_exact() {\n        let dates = vec![100, 200, 300];\n        let prices = vec![10.0, 20.0, 30.0];\n        assert_eq!(stock_price_on(&dates, &prices, 200), Some(20.0));\n    }\n\n    #[test]\n    fn test_stock_price_on_before() {\n        let dates = vec![100, 200, 300];\n        let prices = vec![10.0, 20.0, 30.0];\n        assert_eq!(stock_price_on(&dates, &prices, 250), Some(20.0));\n    }\n\n    #[test]\n    fn test_run_backtest_no_options() {\n        let stock_dates = vec![make_ts(2020, 1, 2), make_ts(2020, 2, 3)];\n        let stock_prices = vec![100.0, 105.0];\n\n        let result = run_backtest(\n            &[], &[], &[], &[], &[], &[], &[], &[], &[],\n            &stock_dates, &stock_prices,\n            100_000.0, 0.005, -0.10, 14, 60, 0.20,\n        );\n\n        assert_eq!(result.records.len(), 2);\n        assert!(result.records[0].contracts == 0);\n    }\n}\n"
  },
  {
    "path": "rust/ob_core/src/convexity_scoring.rs",
    "content": "/// Convexity ratio scoring: find cheapest tail protection per day.\n\npub struct DailyScore {\n    pub date_ns: i64,\n    pub convexity_ratio: f64,\n    pub strike: f64,\n    pub ask: f64,\n    pub bid: f64,\n    pub delta: f64,\n    pub underlying_price: f64,\n    pub implied_vol: f64,\n    pub dte: i32,\n    pub annual_cost: f64,\n    pub tail_payoff: f64,\n}\n\n/// Find the put closest to target_delta within DTE range.\n/// Returns the index within the provided slices, or None.\npub fn find_target_put(\n    deltas: &[f64],\n    dtes: &[i32],\n    asks: &[f64],\n    target_delta: f64,\n    dte_min: i32,\n    dte_max: i32,\n) -> Option<usize> {\n    let mut best_idx: Option<usize> = None;\n    let mut best_delta_diff = f64::MAX;\n\n    for i in 0..deltas.len() {\n        if dtes[i] < dte_min || dtes[i] > dte_max {\n            continue;\n        }\n        if asks[i] <= 0.0 || asks[i].is_nan() {\n            continue;\n        }\n        if deltas[i].is_nan() {\n            continue;\n        }\n\n        let delta_diff = (deltas[i] - target_delta).abs();\n        if delta_diff < best_delta_diff {\n            best_delta_diff = delta_diff;\n            best_idx = Some(i);\n        }\n    }\n\n    best_idx\n}\n\n/// Compute convexity ratio for a single put.\n/// Returns (ratio, tail_payoff, annual_cost).\npub fn convexity_ratio(strike: f64, underlying: f64, ask: f64, tail_drop: f64) -> (f64, f64, f64) {\n    let tail_price = underlying * (1.0 - tail_drop);\n    let tail_payoff = (strike - tail_price).max(0.0) * 100.0;\n    let annual_cost = ask * 100.0 * 12.0;\n    let ratio = if annual_cost > 0.0 {\n        tail_payoff / annual_cost\n    } else {\n        0.0\n    };\n    (ratio, tail_payoff, annual_cost)\n}\n\n/// Compute daily convexity scores from sorted puts data.\n/// Input arrays must be sorted by date. Only put options should be passed.\npub fn compute_daily_scores(\n    dates_ns: &[i64],\n    strikes: &[f64],\n    bids: &[f64],\n    asks: &[f64],\n    deltas: &[f64],\n    underlying_prices: &[f64],\n    dtes: &[i32],\n    implied_vols: &[f64],\n    target_delta: f64,\n    dte_min: i32,\n    dte_max: i32,\n    tail_drop: f64,\n) -> Vec<DailyScore> {\n    let n = dates_ns.len();\n    let mut results = Vec::new();\n\n    if n == 0 {\n        return results;\n    }\n\n    // Walk through date groups (consecutive rows with same date)\n    let mut start = 0;\n    while start < n {\n        let current_date = dates_ns[start];\n        let mut end = start + 1;\n        while end < n && dates_ns[end] == current_date {\n            end += 1;\n        }\n\n        // Find target put in this date's options\n        if let Some(rel_idx) = find_target_put(\n            &deltas[start..end],\n            &dtes[start..end],\n            &asks[start..end],\n            target_delta,\n            dte_min,\n            dte_max,\n        ) {\n            let idx = start + rel_idx;\n            let (ratio, tail_payoff, annual_cost) =\n                convexity_ratio(strikes[idx], underlying_prices[idx], asks[idx], tail_drop);\n\n            results.push(DailyScore {\n                date_ns: current_date,\n                convexity_ratio: ratio,\n                strike: strikes[idx],\n                ask: asks[idx],\n                bid: bids[idx],\n                delta: deltas[idx],\n                underlying_price: underlying_prices[idx],\n                implied_vol: implied_vols[idx],\n                dte: dtes[idx],\n                annual_cost,\n                tail_payoff,\n            });\n        }\n\n        start = end;\n    }\n\n    results\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_convexity_ratio_basic() {\n        let (ratio, payoff, cost) = convexity_ratio(360.0, 400.0, 3.0, 0.20);\n        assert!((payoff - 4000.0).abs() < 0.01);\n        assert!((cost - 3600.0).abs() < 0.01);\n        assert!((ratio - 1.111).abs() < 0.01);\n    }\n\n    #[test]\n    fn test_convexity_ratio_otm_after_crash() {\n        let (ratio, payoff, _) = convexity_ratio(300.0, 400.0, 3.0, 0.20);\n        assert_eq!(payoff, 0.0);\n        assert_eq!(ratio, 0.0);\n    }\n\n    #[test]\n    fn test_find_target_put() {\n        let deltas = vec![-0.05, -0.10, -0.15, -0.25, -0.50];\n        let dtes = vec![30, 30, 30, 30, 30];\n        let asks = vec![1.0, 2.0, 3.0, 5.0, 10.0];\n\n        let idx = find_target_put(&deltas, &dtes, &asks, -0.10, 20, 45);\n        assert_eq!(idx, Some(1));\n    }\n\n    #[test]\n    fn test_find_target_put_dte_filter() {\n        let deltas = vec![-0.10, -0.10, -0.10];\n        let dtes = vec![10, 30, 60];\n        let asks = vec![1.0, 2.0, 3.0];\n\n        let idx = find_target_put(&deltas, &dtes, &asks, -0.10, 20, 45);\n        assert_eq!(idx, Some(1));\n    }\n\n    #[test]\n    fn test_find_target_put_skips_zero_ask() {\n        let deltas = vec![-0.10, -0.11];\n        let dtes = vec![30, 30];\n        let asks = vec![0.0, 2.0];\n\n        let idx = find_target_put(&deltas, &dtes, &asks, -0.10, 20, 45);\n        assert_eq!(idx, Some(1));\n    }\n\n    #[test]\n    fn test_compute_daily_scores() {\n        let dates_ns = vec![100, 100, 100, 200, 200, 200];\n        let strikes = vec![360.0, 370.0, 380.0, 360.0, 370.0, 380.0];\n        let bids = vec![2.5, 3.5, 5.0, 2.0, 3.0, 4.5];\n        let asks = vec![3.0, 4.0, 5.5, 2.5, 3.5, 5.0];\n        let deltas = vec![-0.08, -0.12, -0.18, -0.09, -0.11, -0.17];\n        let underlying = vec![400.0; 6];\n        let dtes = vec![30, 30, 30, 30, 30, 30];\n        let ivs = vec![0.20, 0.22, 0.25, 0.19, 0.21, 0.24];\n\n        let scores = compute_daily_scores(\n            &dates_ns, &strikes, &bids, &asks, &deltas, &underlying, &dtes, &ivs, -0.10, 20, 45,\n            0.20,\n        );\n\n        assert_eq!(scores.len(), 2);\n        assert_eq!(scores[0].date_ns, 100);\n        assert_eq!(scores[1].date_ns, 200);\n    }\n}\n"
  },
  {
    "path": "rust/ob_core/src/cost_model.rs",
    "content": "//! Transaction cost models for options and stocks.\n//!\n//! Mirrors Python's `options_portfolio_backtester.execution.cost_model`.\n\n#[derive(Debug, Clone, Default)]\npub enum CostModel {\n    /// Zero transaction costs.\n    #[default]\n    NoCosts,\n    /// Fixed per-contract commission (e.g., $0.65/contract for IBKR).\n    PerContract { rate: f64, stock_rate: f64 },\n    /// Tiered commission schedule with volume discounts.\n    /// Tiers are (max_contracts, rate) pairs sorted by max_contracts ascending.\n    Tiered { tiers: Vec<(i64, f64)>, stock_rate: f64 },\n}\n\nimpl CostModel {\n    /// Compute option trade commission.\n    #[inline]\n    pub fn option_cost(&self, _price: f64, quantity: f64, _spc: i64) -> f64 {\n        let qty = quantity.abs();\n        match self {\n            CostModel::NoCosts => 0.0,\n            CostModel::PerContract { rate, .. } => rate * qty,\n            CostModel::Tiered { tiers, .. } => {\n                let mut total = 0.0;\n                let mut remaining = qty;\n                let mut prev_bound: i64 = 0;\n                for &(max_qty, rate) in tiers {\n                    let tier_qty = remaining.min((max_qty - prev_bound) as f64);\n                    if tier_qty <= 0.0 {\n                        prev_bound = max_qty;\n                        continue;\n                    }\n                    total += tier_qty * rate;\n                    remaining -= tier_qty;\n                    prev_bound = max_qty;\n                    if remaining <= 0.0 {\n                        break;\n                    }\n                }\n                if remaining > 0.0 {\n                    if let Some(&(_, last_rate)) = tiers.last() {\n                        total += remaining * last_rate;\n                    }\n                }\n                total\n            }\n        }\n    }\n\n    /// Compute stock trade commission.\n    #[inline]\n    pub fn stock_cost(&self, _price: f64, quantity: f64) -> f64 {\n        let qty = quantity.abs();\n        match self {\n            CostModel::NoCosts => 0.0,\n            CostModel::PerContract { stock_rate, .. } => stock_rate * qty,\n            CostModel::Tiered { stock_rate, .. } => stock_rate * qty,\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn no_costs() {\n        let m = CostModel::NoCosts;\n        assert_eq!(m.option_cost(10.0, 5.0, 100), 0.0);\n        assert_eq!(m.stock_cost(150.0, 100.0), 0.0);\n    }\n\n    #[test]\n    fn per_contract() {\n        let m = CostModel::PerContract { rate: 0.65, stock_rate: 0.005 };\n        assert!((m.option_cost(10.0, 10.0, 100) - 6.5).abs() < 1e-10);\n        assert!((m.option_cost(10.0, -10.0, 100) - 6.5).abs() < 1e-10);\n        assert!((m.stock_cost(150.0, 100.0) - 0.5).abs() < 1e-10);\n    }\n\n    #[test]\n    fn tiered_within_first_tier() {\n        let m = CostModel::Tiered {\n            tiers: vec![(10_000, 0.65), (50_000, 0.50), (100_000, 0.25)],\n            stock_rate: 0.005,\n        };\n        // 100 contracts, all in first tier\n        assert!((m.option_cost(10.0, 100.0, 100) - 65.0).abs() < 1e-10);\n    }\n\n    #[test]\n    fn tiered_spanning_tiers() {\n        let m = CostModel::Tiered {\n            tiers: vec![(10_000, 0.65), (50_000, 0.50), (100_000, 0.25)],\n            stock_rate: 0.005,\n        };\n        // 15000 contracts: 10000 * 0.65 + 5000 * 0.50\n        let expected = 10_000.0 * 0.65 + 5_000.0 * 0.50;\n        assert!((m.option_cost(10.0, 15_000.0, 100) - expected).abs() < 1e-10);\n    }\n\n    #[test]\n    fn tiered_beyond_all() {\n        let m = CostModel::Tiered {\n            tiers: vec![(10_000, 0.65), (50_000, 0.50), (100_000, 0.25)],\n            stock_rate: 0.005,\n        };\n        // 120_000: 10k*0.65 + 40k*0.50 + 50k*0.25 + 20k*0.25\n        let expected = 10_000.0 * 0.65 + 40_000.0 * 0.50 + 50_000.0 * 0.25 + 20_000.0 * 0.25;\n        assert!((m.option_cost(10.0, 120_000.0, 100) - expected).abs() < 1e-10);\n    }\n\n    #[test]\n    fn tiered_stock_cost() {\n        let m = CostModel::Tiered {\n            tiers: vec![(10_000, 0.65)],\n            stock_rate: 0.005,\n        };\n        assert!((m.stock_cost(150.0, 100.0) - 0.5).abs() < 1e-10);\n    }\n}\n"
  },
  {
    "path": "rust/ob_core/src/entries.rs",
    "content": "//! Entry signal computation in Rust.\n//!\n//! Mirrors Python's _execute_option_entries:\n//! 1. Anti-join to exclude held contracts\n//! 2. Apply entry filter\n//! 3. Sort by entry_sort\n//! 4. Select signal fields\n//! 5. Compute totals (cost, qty)\n\nuse polars::prelude::*;\n\nuse crate::filter::CompiledFilter;\n\n/// Compute entry candidates for a single leg.\n///\n/// Steps:\n/// 1. Anti-join options with inventory contracts\n/// 2. Apply compiled entry filter\n/// 3. Sort if entry_sort specified\n/// 4. Select and rename signal fields\npub fn compute_leg_entries(\n    options: &DataFrame,\n    inventory_contracts: &[String],\n    entry_filter: &CompiledFilter,\n    contract_col: &str,\n    cost_field: &str,\n    entry_sort_col: Option<&str>,\n    entry_sort_asc: bool,\n    shares_per_contract: i64,\n    is_sell: bool,\n    extra_columns: &[String],\n) -> PolarsResult<DataFrame> {\n    // Anti-join: exclude already-held contracts\n    let inv_contracts = Series::new(\"_held\".into(), inventory_contracts);\n    let inv_df = DataFrame::new(vec![inv_contracts.into_column()])?;\n\n    let mut lazy = options\n        .clone()\n        .lazy()\n        .join(\n            inv_df.lazy(),\n            [col(contract_col)],\n            [col(\"_held\")],\n            JoinArgs::new(JoinType::Anti),\n        );\n\n    // Apply entry filter\n    lazy = lazy.filter(entry_filter.polars_expr.clone());\n\n    // Sort if specified\n    if let Some(sort_col) = entry_sort_col {\n        lazy = lazy.sort(\n            [sort_col],\n            SortMultipleOptions::default().with_order_descending(!entry_sort_asc),\n        );\n    }\n\n    // Select signal fields and compute cost\n    let sign = if is_sell { lit(-1.0) } else { lit(1.0) };\n    let spc = lit(shares_per_contract as f64);\n\n    let mut select_exprs = vec![\n        col(contract_col).alias(\"contract\"),\n        col(\"underlying\"),\n        col(\"expiration\"),\n        col(\"type\"),\n        col(\"strike\").cast(DataType::Float64),\n        (sign * col(cost_field) * spc).alias(\"cost\"),\n    ];\n    // Include extra columns needed by signal selectors (e.g. delta, openinterest)\n    for extra in extra_columns {\n        select_exprs.push(col(extra));\n    }\n    lazy = lazy.select(select_exprs);\n\n    lazy.collect()\n}\n\n/// Compute entry quantities given total costs and available allocation.\npub fn compute_entry_qty(total_costs: &Series, allocation: f64) -> PolarsResult<Series> {\n    let abs_costs = total_costs.f64()?.apply(|v| v.map(|x| x.abs()));\n    let qty: Float64Chunked = abs_costs\n        .into_iter()\n        .map(|c| c.map(|cost| if cost > 0.0 { (allocation / cost).floor() } else { 0.0 }))\n        .collect();\n    Ok(qty.into_series())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    fn sample_options() -> DataFrame {\n        df!(\n            \"optionroot\" => &[\"A\", \"B\", \"C\", \"D\"],\n            \"underlying\" => &[\"SPX\", \"SPX\", \"SPX\", \"SPX\"],\n            \"type\" => &[\"put\", \"put\", \"call\", \"put\"],\n            \"expiration\" => &[\"2024-06-01\", \"2024-06-01\", \"2024-06-01\", \"2024-06-01\"],\n            \"strike\" => &[4000.0, 4100.0, 4200.0, 4300.0],\n            \"ask\" => &[10.0, 15.0, 20.0, 25.0],\n            \"bid\" => &[9.0, 14.0, 19.0, 24.0],\n            \"dte\" => &[90i64, 90, 90, 90],\n        )\n        .unwrap()\n    }\n\n    #[test]\n    fn compute_entries_excludes_held() {\n        let opts = sample_options();\n        let filter = CompiledFilter::new(\"type == 'put'\").unwrap();\n\n        let result = compute_leg_entries(\n            &opts,\n            &[\"A\".into()], // A is held\n            &filter,\n            \"optionroot\",\n            \"ask\",\n            None,\n            true,\n            100,\n            false,\n            &[],\n        )\n        .unwrap();\n\n        // A is excluded, C is a call (filtered out), so B and D remain\n        assert_eq!(result.height(), 2);\n    }\n\n    #[test]\n    fn compute_qty() {\n        let costs = Series::new(\"cost\".into(), &[100.0, 200.0, 50.0]);\n        let qty = compute_entry_qty(&costs, 1000.0).unwrap();\n        let vals: Vec<f64> = qty.f64().unwrap().into_no_null_iter().collect();\n        assert_eq!(vals, vec![10.0, 5.0, 20.0]);\n    }\n}\n"
  },
  {
    "path": "rust/ob_core/src/exits.rs",
    "content": "//! Exit mask computation in Rust.\n//!\n//! Mirrors Python's _execute_option_exits:\n//! 1. Compute current option quotes for each leg\n//! 2. Apply exit filters to get filter masks\n//! 3. Apply threshold exits (profit/loss targets)\n//! 4. Combine masks with OR\n\nuse polars::prelude::*;\n\n/// Compute exit mask from profit/loss thresholds.\n///\n/// exit if: current_cost <= entry_cost * (1 - loss_pct)  [loss]\n///      or: current_cost >= entry_cost * (1 + profit_pct)  [profit]\npub fn threshold_exit_mask(\n    entry_costs: &Series,\n    current_costs: &Series,\n    profit_pct: Option<f64>,\n    loss_pct: Option<f64>,\n) -> PolarsResult<BooleanChunked> {\n    let entry = entry_costs.f64()?;\n    let current = current_costs.f64()?;\n\n    let mask: BooleanChunked = entry\n        .into_iter()\n        .zip(current.into_iter())\n        .map(|(e, c)| {\n            match (e, c) {\n                (Some(entry_val), Some(curr_val)) => {\n                    let mut should_exit = false;\n                    if let Some(p) = profit_pct {\n                        if entry_val != 0.0 {\n                            let pnl_pct = (curr_val - entry_val) / entry_val.abs();\n                            if pnl_pct >= p {\n                                should_exit = true;\n                            }\n                        }\n                    }\n                    if let Some(l) = loss_pct {\n                        if entry_val != 0.0 {\n                            let pnl_pct = (curr_val - entry_val) / entry_val.abs();\n                            if pnl_pct <= -l {\n                                should_exit = true;\n                            }\n                        }\n                    }\n                    Some(should_exit)\n                }\n                _ => Some(false),\n            }\n        })\n        .collect();\n\n    Ok(mask)\n}\n\n/// Combine multiple boolean masks with OR.\npub fn combine_masks_or(masks: &[BooleanChunked]) -> BooleanChunked {\n    if masks.is_empty() {\n        return BooleanChunked::new(\"mask\".into(), &[] as &[bool]);\n    }\n    let mut result = masks[0].clone();\n    for mask in &masks[1..] {\n        result = result | mask.clone();\n    }\n    result\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn threshold_profit_exit() {\n        let entry = Series::new(\"entry\".into(), &[100.0, 100.0, 100.0]);\n        let current = Series::new(\"current\".into(), &[160.0, 110.0, 80.0]);\n\n        let mask = threshold_exit_mask(&entry, &current, Some(0.50), None).unwrap();\n        let vals: Vec<bool> = mask.into_no_null_iter().collect();\n        assert_eq!(vals, vec![true, false, false]); // 60% profit > 50%\n    }\n\n    #[test]\n    fn threshold_loss_exit() {\n        let entry = Series::new(\"entry\".into(), &[100.0, 100.0, 100.0]);\n        let current = Series::new(\"current\".into(), &[160.0, 110.0, 70.0]);\n\n        let mask = threshold_exit_mask(&entry, &current, None, Some(0.20)).unwrap();\n        let vals: Vec<bool> = mask.into_no_null_iter().collect();\n        assert_eq!(vals, vec![false, false, true]); // 30% loss > 20%\n    }\n\n    #[test]\n    fn combine_masks() {\n        let a = BooleanChunked::new(\"a\".into(), &[true, false, false]);\n        let b = BooleanChunked::new(\"b\".into(), &[false, false, true]);\n\n        let result = combine_masks_or(&[a, b]);\n        let vals: Vec<bool> = result.into_no_null_iter().collect();\n        assert_eq!(vals, vec![true, false, true]);\n    }\n}\n"
  },
  {
    "path": "rust/ob_core/src/fill_model.rs",
    "content": "//! Fill models — determine the execution price for trades.\n//!\n//! Mirrors Python's `options_portfolio_backtester.execution.fill_model`.\n\n#[derive(Debug, Clone, Default)]\npub enum FillModel {\n    /// Fill at bid (sell) or ask (buy) — matches original behavior.\n    #[default]\n    MarketAtBidAsk,\n    /// Fill at the midpoint of bid and ask.\n    MidPrice,\n    /// Fill price adjusts for volume impact. Low volume pushes toward mid.\n    VolumeAware { full_volume_threshold: i64 },\n}\n\nimpl FillModel {\n    /// Compute fill price given bid, ask, volume, and whether this is a buy.\n    ///\n    /// `is_buy`: true for BUY direction (fills at ask), false for SELL (fills at bid).\n    #[inline]\n    pub fn fill_price(&self, bid: f64, ask: f64, volume: Option<f64>, is_buy: bool) -> f64 {\n        match self {\n            FillModel::MarketAtBidAsk => {\n                if is_buy { ask } else { bid }\n            }\n            FillModel::MidPrice => {\n                (bid + ask) / 2.0\n            }\n            FillModel::VolumeAware { full_volume_threshold } => {\n                let mid = (bid + ask) / 2.0;\n                let target = if is_buy { ask } else { bid };\n                let vol = volume.unwrap_or(*full_volume_threshold as f64);\n\n                if vol >= *full_volume_threshold as f64 {\n                    return target;\n                }\n\n                let ratio = vol / *full_volume_threshold as f64;\n                mid + ratio * (target - mid)\n            }\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn market_at_bid_ask_buy() {\n        let m = FillModel::MarketAtBidAsk;\n        assert!((m.fill_price(9.0, 10.0, None, true) - 10.0).abs() < 1e-10);\n    }\n\n    #[test]\n    fn market_at_bid_ask_sell() {\n        let m = FillModel::MarketAtBidAsk;\n        assert!((m.fill_price(9.0, 10.0, None, false) - 9.0).abs() < 1e-10);\n    }\n\n    #[test]\n    fn mid_price() {\n        let m = FillModel::MidPrice;\n        assert!((m.fill_price(9.0, 11.0, None, true) - 10.0).abs() < 1e-10);\n        assert!((m.fill_price(9.0, 11.0, None, false) - 10.0).abs() < 1e-10);\n    }\n\n    #[test]\n    fn volume_aware_full_volume() {\n        let m = FillModel::VolumeAware { full_volume_threshold: 100 };\n        // At or above threshold, same as market\n        assert!((m.fill_price(9.0, 10.0, Some(100.0), true) - 10.0).abs() < 1e-10);\n        assert!((m.fill_price(9.0, 10.0, Some(200.0), false) - 9.0).abs() < 1e-10);\n    }\n\n    #[test]\n    fn volume_aware_zero_volume() {\n        let m = FillModel::VolumeAware { full_volume_threshold: 100 };\n        // At volume=0, fill at mid\n        let mid = (9.0 + 10.0) / 2.0;\n        assert!((m.fill_price(9.0, 10.0, Some(0.0), true) - mid).abs() < 1e-10);\n        assert!((m.fill_price(9.0, 10.0, Some(0.0), false) - mid).abs() < 1e-10);\n    }\n\n    #[test]\n    fn volume_aware_half_volume() {\n        let m = FillModel::VolumeAware { full_volume_threshold: 100 };\n        let mid = (9.0 + 10.0) / 2.0;\n        // At 50% volume: mid + 0.5 * (ask - mid) = 9.5 + 0.25 = 9.75\n        let expected_buy = mid + 0.5 * (10.0 - mid);\n        assert!((m.fill_price(9.0, 10.0, Some(50.0), true) - expected_buy).abs() < 1e-10);\n        let expected_sell = mid + 0.5 * (9.0 - mid);\n        assert!((m.fill_price(9.0, 10.0, Some(50.0), false) - expected_sell).abs() < 1e-10);\n    }\n\n    #[test]\n    fn volume_aware_no_volume_data() {\n        let m = FillModel::VolumeAware { full_volume_threshold: 100 };\n        // Missing volume defaults to threshold -> market price\n        assert!((m.fill_price(9.0, 10.0, None, true) - 10.0).abs() < 1e-10);\n    }\n}\n"
  },
  {
    "path": "rust/ob_core/src/filter.rs",
    "content": "//! Filter expression parser and evaluator.\n//!\n//! Parses the pandas-eval query strings generated by the Python Filter DSL\n//! into an AST, then evaluates against Polars DataFrames.\n//!\n//! Supported patterns (all generated by schema.py):\n//!   \"(type == 'put') & (ask > 0)\"\n//!   \"(underlying == 'SPX') & (dte >= 60) & (dte <= 120)\"\n//!   \"(strike >= underlying_last * 1.02)\"\n//!   \"dte <= 30\"\n\nuse polars::prelude::*;\nuse thiserror::Error;\n\n#[derive(Error, Debug)]\npub enum FilterError {\n    #[error(\"parse error: {0}\")]\n    Parse(String),\n    #[error(\"polars error: {0}\")]\n    Polars(#[from] PolarsError),\n}\n\n/// A parsed value literal.\n#[derive(Debug, Clone, PartialEq)]\npub enum Value {\n    Int(i64),\n    Float(f64),\n    Str(String),\n    Column(String),\n}\n\n/// Comparison operator.\n#[derive(Debug, Clone, Copy, PartialEq)]\npub enum CmpOp {\n    Eq,\n    Ne,\n    Lt,\n    Le,\n    Gt,\n    Ge,\n}\n\n/// Arithmetic operator for column expressions.\n#[derive(Debug, Clone, Copy, PartialEq)]\npub enum ArithOp {\n    Add,\n    Sub,\n    Mul,\n    Div,\n}\n\n/// Compiled filter expression AST.\n#[derive(Debug, Clone, PartialEq)]\npub enum FilterExpr {\n    Cmp(String, CmpOp, Value),\n    /// column <arith_op> value <cmp_op> value\n    /// e.g. strike >= underlying_last * 1.02\n    ColArith(String, ArithOp, Value, CmpOp, Value),\n    And(Box<FilterExpr>, Box<FilterExpr>),\n    Or(Box<FilterExpr>, Box<FilterExpr>),\n    Not(Box<FilterExpr>),\n}\n\n/// Tokenizer for filter expressions.\n#[derive(Debug, Clone, PartialEq)]\nenum Token {\n    Ident(String),\n    StrLit(String),\n    IntLit(i64),\n    FloatLit(f64),\n    Eq,    // ==\n    Ne,    // !=\n    Lt,    // <\n    Le,    // <=\n    Gt,    // >\n    Ge,    // >=\n    And,   // &\n    Or,    // |\n    Not,   // !\n    LParen,\n    RParen,\n    Plus,\n    Minus,\n    Star,\n    Slash,\n}\n\nfn tokenize(input: &str) -> Result<Vec<Token>, FilterError> {\n    let mut tokens = Vec::new();\n    let chars: Vec<char> = input.chars().collect();\n    let mut i = 0;\n\n    while i < chars.len() {\n        match chars[i] {\n            ' ' | '\\t' | '\\n' => i += 1,\n            '(' => { tokens.push(Token::LParen); i += 1; }\n            ')' => { tokens.push(Token::RParen); i += 1; }\n            '&' => { tokens.push(Token::And); i += 1; }\n            '|' => { tokens.push(Token::Or); i += 1; }\n            '+' => { tokens.push(Token::Plus); i += 1; }\n            '-' => { tokens.push(Token::Minus); i += 1; }\n            '*' => { tokens.push(Token::Star); i += 1; }\n            '/' => { tokens.push(Token::Slash); i += 1; }\n            '!' => {\n                if i + 1 < chars.len() && chars[i + 1] == '=' {\n                    tokens.push(Token::Ne);\n                    i += 2;\n                } else {\n                    tokens.push(Token::Not);\n                    i += 1;\n                }\n            }\n            '=' => {\n                if i + 1 < chars.len() && chars[i + 1] == '=' {\n                    tokens.push(Token::Eq);\n                    i += 2;\n                } else {\n                    return Err(FilterError::Parse(format!(\"unexpected '=' at {i}\")));\n                }\n            }\n            '<' => {\n                if i + 1 < chars.len() && chars[i + 1] == '=' {\n                    tokens.push(Token::Le);\n                    i += 2;\n                } else {\n                    tokens.push(Token::Lt);\n                    i += 1;\n                }\n            }\n            '>' => {\n                if i + 1 < chars.len() && chars[i + 1] == '=' {\n                    tokens.push(Token::Ge);\n                    i += 2;\n                } else {\n                    tokens.push(Token::Gt);\n                    i += 1;\n                }\n            }\n            '\\'' | '\"' => {\n                let quote = chars[i];\n                i += 1;\n                let start = i;\n                while i < chars.len() && chars[i] != quote {\n                    i += 1;\n                }\n                let s: String = chars[start..i].iter().collect();\n                tokens.push(Token::StrLit(s));\n                i += 1; // skip closing quote\n            }\n            c if c.is_ascii_digit() || c == '.' => {\n                let start = i;\n                let mut has_dot = c == '.';\n                let mut has_exp = false;\n                i += 1;\n                while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') {\n                    if chars[i] == '.' { has_dot = true; }\n                    i += 1;\n                }\n                // Scientific notation: e/E followed by optional +/- and digits\n                if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') {\n                    has_exp = true;\n                    i += 1;\n                    if i < chars.len() && (chars[i] == '+' || chars[i] == '-') {\n                        i += 1;\n                    }\n                    while i < chars.len() && chars[i].is_ascii_digit() {\n                        i += 1;\n                    }\n                }\n                let num_str: String = chars[start..i].iter().collect();\n                if has_dot || has_exp {\n                    tokens.push(Token::FloatLit(\n                        num_str.parse().map_err(|e| FilterError::Parse(format!(\"{e}\")))?,\n                    ));\n                } else {\n                    tokens.push(Token::IntLit(\n                        num_str.parse().map_err(|e| FilterError::Parse(format!(\"{e}\")))?,\n                    ));\n                }\n            }\n            c if c.is_ascii_alphabetic() || c == '_' => {\n                let start = i;\n                i += 1;\n                while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') {\n                    i += 1;\n                }\n                let ident: String = chars[start..i].iter().collect();\n                tokens.push(Token::Ident(ident));\n            }\n            c => return Err(FilterError::Parse(format!(\"unexpected char '{c}' at {i}\"))),\n        }\n    }\n\n    Ok(tokens)\n}\n\n/// Recursive descent parser.\nstruct Parser {\n    tokens: Vec<Token>,\n    pos: usize,\n}\n\nimpl Parser {\n    fn new(tokens: Vec<Token>) -> Self {\n        Self { tokens, pos: 0 }\n    }\n\n    fn peek(&self) -> Option<&Token> {\n        self.tokens.get(self.pos)\n    }\n\n    fn advance(&mut self) -> Option<Token> {\n        let tok = self.tokens.get(self.pos)?.clone();\n        self.pos += 1;\n        Some(tok)\n    }\n\n    fn expect(&mut self, expected: &Token) -> Result<(), FilterError> {\n        let tok = self.advance().ok_or_else(|| FilterError::Parse(\"unexpected end\".into()))?;\n        if &tok != expected {\n            return Err(FilterError::Parse(format!(\"expected {expected:?}, got {tok:?}\")));\n        }\n        Ok(())\n    }\n\n    /// expr = or_expr\n    fn parse_expr(&mut self) -> Result<FilterExpr, FilterError> {\n        self.parse_or()\n    }\n\n    /// or_expr = and_expr ( '|' and_expr )*\n    fn parse_or(&mut self) -> Result<FilterExpr, FilterError> {\n        let mut left = self.parse_and()?;\n        while matches!(self.peek(), Some(Token::Or)) {\n            self.advance();\n            let right = self.parse_and()?;\n            left = FilterExpr::Or(Box::new(left), Box::new(right));\n        }\n        Ok(left)\n    }\n\n    /// and_expr = unary ( '&' unary )*\n    fn parse_and(&mut self) -> Result<FilterExpr, FilterError> {\n        let mut left = self.parse_unary()?;\n        while matches!(self.peek(), Some(Token::And)) {\n            self.advance();\n            let right = self.parse_unary()?;\n            left = FilterExpr::And(Box::new(left), Box::new(right));\n        }\n        Ok(left)\n    }\n\n    /// unary = '!' unary | primary\n    fn parse_unary(&mut self) -> Result<FilterExpr, FilterError> {\n        if matches!(self.peek(), Some(Token::Not)) {\n            self.advance();\n            let inner = self.parse_unary()?;\n            return Ok(FilterExpr::Not(Box::new(inner)));\n        }\n        self.parse_primary()\n    }\n\n    /// primary = '(' expr ')' | comparison\n    fn parse_primary(&mut self) -> Result<FilterExpr, FilterError> {\n        if matches!(self.peek(), Some(Token::LParen)) {\n            self.advance();\n            let expr = self.parse_expr()?;\n            self.expect(&Token::RParen)?;\n            return Ok(expr);\n        }\n        self.parse_comparison()\n    }\n\n    /// comparison = value cmp_op value\n    /// value can be: ident, ident arith_op literal, literal\n    fn parse_comparison(&mut self) -> Result<FilterExpr, FilterError> {\n        let left = self.parse_value_expr()?;\n        let cmp = self.parse_cmp_op()?;\n        let right = self.parse_value_expr()?;\n\n        match (left, right) {\n            // column cmp literal/column\n            (ValueExpr::Column(name), ValueExpr::Literal(val)) => {\n                Ok(FilterExpr::Cmp(name, cmp, val))\n            }\n            (ValueExpr::Column(name), ValueExpr::Column(rhs)) => {\n                Ok(FilterExpr::Cmp(name, cmp, Value::Column(rhs)))\n            }\n            // column_arith cmp value\n            (ValueExpr::Arith(name, op, operand), rhs) => {\n                Ok(FilterExpr::ColArith(name, op, operand, cmp, self.value_expr_to_value(rhs)?))\n            }\n            // column cmp column_arith  →  flip to ColArith form\n            // e.g. strike >= underlying_last * 1.02\n            //    → ColArith(\"underlying_last\", Mul, 1.02, Le, Column(\"strike\"))\n            (ValueExpr::Column(name), ValueExpr::Arith(rhs_col, op, operand)) => {\n                Ok(FilterExpr::ColArith(rhs_col, op, operand, flip_cmp(cmp), Value::Column(name)))\n            }\n            // literal cmp column → flip\n            (ValueExpr::Literal(val), ValueExpr::Column(name)) => {\n                Ok(FilterExpr::Cmp(name, flip_cmp(cmp), val))\n            }\n            _ => Err(FilterError::Parse(\"unsupported comparison form\".into())),\n        }\n    }\n\n    fn parse_value_expr(&mut self) -> Result<ValueExpr, FilterError> {\n        let tok = self.advance().ok_or_else(|| FilterError::Parse(\"unexpected end\".into()))?;\n        match tok {\n            Token::Ident(name) => {\n                // Check for arithmetic: ident * 1.02\n                if let Some(arith) = self.try_parse_arith() {\n                    return Ok(ValueExpr::Arith(name, arith.0, arith.1));\n                }\n                Ok(ValueExpr::Column(name))\n            }\n            Token::IntLit(n) => {\n                // Check for arithmetic: 1.02 * ident (reversed)\n                Ok(ValueExpr::Literal(Value::Int(n)))\n            }\n            Token::FloatLit(f) => Ok(ValueExpr::Literal(Value::Float(f))),\n            Token::StrLit(s) => Ok(ValueExpr::Literal(Value::Str(s))),\n            // Unary minus: negate the next numeric literal\n            Token::Minus => {\n                let next = self.advance().ok_or_else(|| FilterError::Parse(\"unexpected end after '-'\".into()))?;\n                match next {\n                    Token::IntLit(n) => Ok(ValueExpr::Literal(Value::Int(-n))),\n                    Token::FloatLit(f) => Ok(ValueExpr::Literal(Value::Float(-f))),\n                    t => Err(FilterError::Parse(format!(\"expected number after '-', got {t:?}\"))),\n                }\n            }\n            t => Err(FilterError::Parse(format!(\"unexpected token in value: {t:?}\"))),\n        }\n    }\n\n    fn try_parse_arith(&mut self) -> Option<(ArithOp, Value)> {\n        let op = match self.peek()? {\n            Token::Plus => ArithOp::Add,\n            Token::Minus => ArithOp::Sub,\n            Token::Star => ArithOp::Mul,\n            Token::Slash => ArithOp::Div,\n            _ => return None,\n        };\n        self.advance();\n        let val = match self.advance()? {\n            Token::IntLit(n) => Value::Int(n),\n            Token::FloatLit(f) => Value::Float(f),\n            Token::Ident(s) => Value::Column(s),\n            _ => return None,\n        };\n        Some((op, val))\n    }\n\n    fn parse_cmp_op(&mut self) -> Result<CmpOp, FilterError> {\n        match self.advance() {\n            Some(Token::Eq) => Ok(CmpOp::Eq),\n            Some(Token::Ne) => Ok(CmpOp::Ne),\n            Some(Token::Lt) => Ok(CmpOp::Lt),\n            Some(Token::Le) => Ok(CmpOp::Le),\n            Some(Token::Gt) => Ok(CmpOp::Gt),\n            Some(Token::Ge) => Ok(CmpOp::Ge),\n            t => Err(FilterError::Parse(format!(\"expected comparison op, got {t:?}\"))),\n        }\n    }\n\n    fn value_expr_to_value(&self, ve: ValueExpr) -> Result<Value, FilterError> {\n        match ve {\n            ValueExpr::Column(name) => Ok(Value::Column(name)),\n            ValueExpr::Literal(val) => Ok(val),\n            ValueExpr::Arith(..) => Err(FilterError::Parse(\n                \"arithmetic expressions only supported on left side\".into(),\n            )),\n        }\n    }\n}\n\n#[derive(Debug)]\nenum ValueExpr {\n    Column(String),\n    Literal(Value),\n    Arith(String, ArithOp, Value),\n}\n\nfn flip_cmp(op: CmpOp) -> CmpOp {\n    match op {\n        CmpOp::Lt => CmpOp::Gt,\n        CmpOp::Le => CmpOp::Ge,\n        CmpOp::Gt => CmpOp::Lt,\n        CmpOp::Ge => CmpOp::Le,\n        other => other,\n    }\n}\n\n/// Parse a query string into a FilterExpr AST.\npub fn parse(query: &str) -> Result<FilterExpr, FilterError> {\n    let tokens = tokenize(query)?;\n    let mut parser = Parser::new(tokens);\n    let expr = parser.parse_expr()?;\n    if parser.pos != parser.tokens.len() {\n        return Err(FilterError::Parse(format!(\n            \"unexpected tokens after position {}\",\n            parser.pos\n        )));\n    }\n    Ok(expr)\n}\n\n/// Convert a FilterExpr to a Polars Expr for lazy evaluation.\npub fn to_polars_expr(filter: &FilterExpr) -> Expr {\n    match filter {\n        FilterExpr::Cmp(column, op, value) => {\n            let c = col(column.as_str());\n            let v = value_to_lit(value);\n            apply_cmp(c, *op, v)\n        }\n        FilterExpr::ColArith(column, arith_op, arith_val, cmp_op, cmp_val) => {\n            let c = col(column.as_str());\n            let av = value_to_lit(arith_val);\n            let arith_expr = match arith_op {\n                ArithOp::Add => c + av,\n                ArithOp::Sub => c - av,\n                ArithOp::Mul => c * av,\n                ArithOp::Div => c / av,\n            };\n            let cv = value_to_lit(cmp_val);\n            apply_cmp(arith_expr, *cmp_op, cv)\n        }\n        FilterExpr::And(left, right) => {\n            to_polars_expr(left).and(to_polars_expr(right))\n        }\n        FilterExpr::Or(left, right) => {\n            to_polars_expr(left).or(to_polars_expr(right))\n        }\n        FilterExpr::Not(inner) => {\n            to_polars_expr(inner).not()\n        }\n    }\n}\n\nfn value_to_lit(value: &Value) -> Expr {\n    match value {\n        // Always use f64 for numeric literals to avoid Int128 issues in polars 0.48\n        Value::Int(n) => lit(*n as f64),\n        Value::Float(f) => lit(*f),\n        Value::Str(s) => lit(s.as_str()),\n        Value::Column(name) => col(name.as_str()),\n    }\n}\n\nfn apply_cmp(left: Expr, op: CmpOp, right: Expr) -> Expr {\n    match op {\n        CmpOp::Eq => left.eq(right),\n        CmpOp::Ne => left.neq(right),\n        CmpOp::Lt => left.lt(right),\n        CmpOp::Le => left.lt_eq(right),\n        CmpOp::Gt => left.gt(right),\n        CmpOp::Ge => left.gt_eq(right),\n    }\n}\n\n/// A compiled filter: parsed once, evaluated many times.\npub struct CompiledFilter {\n    pub expr: FilterExpr,\n    pub polars_expr: Expr,\n}\n\nimpl CompiledFilter {\n    pub fn new(query: &str) -> Result<Self, FilterError> {\n        let expr = parse(query)?;\n        let polars_expr = to_polars_expr(&expr);\n        Ok(Self { expr, polars_expr })\n    }\n\n    pub fn apply(&self, df: &DataFrame) -> PolarsResult<DataFrame> {\n        let mask = df\n            .clone()\n            .lazy()\n            .select([self.polars_expr.clone().alias(\"_mask\")])\n            .collect()?;\n        let bool_mask = mask.column(\"_mask\")?.bool()?.clone();\n        df.filter(&bool_mask)\n    }\n\n    /// Evaluate filter against a single row — O(1) per comparison, no Polars overhead.\n    #[inline]\n    pub fn eval_row(&self, df: &DataFrame, row_idx: usize) -> bool {\n        eval_expr_row(&self.expr, df, row_idx)\n    }\n}\n\n/// Read a numeric value from a column at a given row index.\n#[inline]\nfn read_f64(col: &Column, row: usize) -> Option<f64> {\n    if let Ok(ca) = col.f64() {\n        return ca.get(row);\n    }\n    if let Ok(ca) = col.i64() {\n        return ca.get(row).map(|v| v as f64);\n    }\n    if let Ok(ca) = col.i32() {\n        return ca.get(row).map(|v| v as f64);\n    }\n    None\n}\n\n/// Read a string value from a column at a given row index.\n#[inline]\nfn read_str<'a>(col: &'a Column, row: usize) -> Option<&'a str> {\n    col.str().ok().and_then(|ca| ca.get(row))\n}\n\n/// Compare two f64 values with the given operator.\n#[inline]\nfn cmp_f64(lhs: f64, op: CmpOp, rhs: f64) -> bool {\n    match op {\n        CmpOp::Eq => (lhs - rhs).abs() < f64::EPSILON,\n        CmpOp::Ne => (lhs - rhs).abs() >= f64::EPSILON,\n        CmpOp::Lt => lhs < rhs,\n        CmpOp::Le => lhs <= rhs,\n        CmpOp::Gt => lhs > rhs,\n        CmpOp::Ge => lhs >= rhs,\n    }\n}\n\n/// Resolve a Value to f64 given a DataFrame and row index.\n#[inline]\nfn resolve_f64(val: &Value, df: &DataFrame, row: usize) -> Option<f64> {\n    match val {\n        Value::Int(n) => Some(*n as f64),\n        Value::Float(f) => Some(*f),\n        Value::Column(name) => df.column(name).ok().and_then(|c| read_f64(c, row)),\n        Value::Str(_) => None,\n    }\n}\n\n/// Evaluate a filter expression against a single row directly.\nfn eval_expr_row(expr: &FilterExpr, df: &DataFrame, row: usize) -> bool {\n    match expr {\n        FilterExpr::Cmp(col_name, op, val) => {\n            let col = match df.column(col_name) {\n                Ok(c) => c,\n                Err(_) => return false,\n            };\n            match val {\n                Value::Str(s) => {\n                    match read_str(col, row) {\n                        Some(cell) => match op {\n                            CmpOp::Eq => cell == s.as_str(),\n                            CmpOp::Ne => cell != s.as_str(),\n                            _ => false,\n                        },\n                        None => false,\n                    }\n                }\n                _ => {\n                    match (read_f64(col, row), resolve_f64(val, df, row)) {\n                        (Some(lhs), Some(rhs)) => cmp_f64(lhs, *op, rhs),\n                        _ => false,\n                    }\n                }\n            }\n        }\n        FilterExpr::ColArith(col_name, arith_op, arith_val, cmp_op, cmp_val) => {\n            let col = match df.column(col_name) {\n                Ok(c) => c,\n                Err(_) => return false,\n            };\n            let base = match read_f64(col, row) {\n                Some(v) => v,\n                None => return false,\n            };\n            let operand = match resolve_f64(arith_val, df, row) {\n                Some(v) => v,\n                None => return false,\n            };\n            let arith_result = match arith_op {\n                ArithOp::Add => base + operand,\n                ArithOp::Sub => base - operand,\n                ArithOp::Mul => base * operand,\n                ArithOp::Div => base / operand,\n            };\n            let rhs = match resolve_f64(cmp_val, df, row) {\n                Some(v) => v,\n                None => return false,\n            };\n            cmp_f64(arith_result, *cmp_op, rhs)\n        }\n        FilterExpr::And(l, r) => eval_expr_row(l, df, row) && eval_expr_row(r, df, row),\n        FilterExpr::Or(l, r) => eval_expr_row(l, df, row) || eval_expr_row(r, df, row),\n        FilterExpr::Not(inner) => !eval_expr_row(inner, df, row),\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn parse_simple_eq() {\n        let expr = parse(\"type == 'put'\").unwrap();\n        assert_eq!(\n            expr,\n            FilterExpr::Cmp(\"type\".into(), CmpOp::Eq, Value::Str(\"put\".into()))\n        );\n    }\n\n    #[test]\n    fn parse_simple_gte() {\n        let expr = parse(\"dte >= 60\").unwrap();\n        assert_eq!(\n            expr,\n            FilterExpr::Cmp(\"dte\".into(), CmpOp::Ge, Value::Int(60))\n        );\n    }\n\n    #[test]\n    fn parse_and() {\n        let expr = parse(\"(type == 'put') & (ask > 0)\").unwrap();\n        match expr {\n            FilterExpr::And(left, right) => {\n                assert_eq!(\n                    *left,\n                    FilterExpr::Cmp(\"type\".into(), CmpOp::Eq, Value::Str(\"put\".into()))\n                );\n                assert_eq!(\n                    *right,\n                    FilterExpr::Cmp(\"ask\".into(), CmpOp::Gt, Value::Int(0))\n                );\n            }\n            _ => panic!(\"expected And\"),\n        }\n    }\n\n    #[test]\n    fn parse_col_arith() {\n        // \"strike >= underlying_last * 1.02\" flips to:\n        // ColArith(\"underlying_last\", Mul, 1.02, Le, Column(\"strike\"))\n        let expr = parse(\"strike >= underlying_last * 1.02\").unwrap();\n        match expr {\n            FilterExpr::ColArith(ref col, ArithOp::Mul, Value::Float(f), CmpOp::Le, Value::Column(ref rhs)) => {\n                assert_eq!(col, \"underlying_last\");\n                assert!((f - 1.02).abs() < 1e-10);\n                assert_eq!(rhs, \"strike\");\n            }\n            _ => panic!(\"expected ColArith, got {expr:?}\"),\n        }\n    }\n\n    #[test]\n    fn parse_chained_and() {\n        let expr = parse(\"(underlying == 'SPX') & (dte >= 60) & (dte <= 120)\").unwrap();\n        // Should be And(And(eq, gte), lte)\n        match expr {\n            FilterExpr::And(_, _) => {} // OK\n            _ => panic!(\"expected chained And\"),\n        }\n    }\n\n    #[test]\n    fn compiled_filter_apply() {\n        let df = DataFrame::new(vec![\n            Column::new(\"type\".into(), &[\"call\", \"put\", \"put\"]),\n            Column::new(\"ask\".into(), &[1.0f64, 2.0, 0.0]),\n        ])\n        .unwrap();\n\n        let f = CompiledFilter::new(\"(type == 'put') & (ask > 0)\").unwrap();\n        let result = f.apply(&df).unwrap();\n        assert_eq!(result.height(), 1);\n    }\n\n    #[test]\n    fn compiled_filter_dte_range() {\n        let df = DataFrame::new(vec![\n            Column::new(\"underlying\".into(), &[\"SPX\", \"SPX\", \"AAPL\", \"SPX\"]),\n            Column::new(\"dte\".into(), &[30i32, 90, 90, 150]),\n        ])\n        .unwrap();\n\n        let f = CompiledFilter::new(\"(underlying == 'SPX') & (dte >= 60) & (dte <= 120)\").unwrap();\n        let result = f.apply(&df).unwrap();\n        assert_eq!(result.height(), 1); // Only SPX with dte=90\n    }\n\n    #[test]\n    fn parse_scientific_notation() {\n        let expr = parse(\"ask > 1e-5\").unwrap();\n        match expr {\n            FilterExpr::Cmp(col, CmpOp::Gt, Value::Float(f)) => {\n                assert_eq!(col, \"ask\");\n                assert!((f - 1e-5).abs() < 1e-15);\n            }\n            _ => panic!(\"expected Cmp with float, got {expr:?}\"),\n        }\n    }\n\n    #[test]\n    fn parse_scientific_notation_positive_exp() {\n        let expr = parse(\"strike >= 1.5E3\").unwrap();\n        match expr {\n            FilterExpr::Cmp(col, CmpOp::Ge, Value::Float(f)) => {\n                assert_eq!(col, \"strike\");\n                assert!((f - 1500.0).abs() < 1e-10);\n            }\n            _ => panic!(\"expected Cmp with float, got {expr:?}\"),\n        }\n    }\n\n    #[test]\n    fn parse_scientific_notation_no_sign() {\n        let expr = parse(\"delta >= 1e2\").unwrap();\n        match expr {\n            FilterExpr::Cmp(_, CmpOp::Ge, Value::Float(f)) => {\n                assert!((f - 100.0).abs() < 1e-10);\n            }\n            _ => panic!(\"expected float, got {expr:?}\"),\n        }\n    }\n\n    #[test]\n    fn compiled_filter_scientific_notation() {\n        let df = DataFrame::new(vec![\n            Column::new(\"ask\".into(), &[0.0f64, 0.00001, 0.1, 5.0]),\n        ]).unwrap();\n\n        let f = CompiledFilter::new(\"ask > 1e-3\").unwrap();\n        let result = f.apply(&df).unwrap();\n        assert_eq!(result.height(), 2); // 0.1 and 5.0\n    }\n\n    #[test]\n    fn parse_negative_float_literal() {\n        let expr = parse(\"delta >= -0.25\").unwrap();\n        assert_eq!(\n            expr,\n            FilterExpr::Cmp(\"delta\".into(), CmpOp::Ge, Value::Float(-0.25))\n        );\n    }\n\n    #[test]\n    fn parse_negative_int_literal() {\n        let expr = parse(\"dte >= -30\").unwrap();\n        assert_eq!(\n            expr,\n            FilterExpr::Cmp(\"dte\".into(), CmpOp::Ge, Value::Int(-30))\n        );\n    }\n\n    #[test]\n    fn parse_negative_delta_range() {\n        // The exact pattern that fails for Cash-Secured Put / Short Strangle\n        let expr = parse(\"(delta >= -0.30) & (delta <= -0.15)\").unwrap();\n        match expr {\n            FilterExpr::And(left, right) => {\n                assert_eq!(\n                    *left,\n                    FilterExpr::Cmp(\"delta\".into(), CmpOp::Ge, Value::Float(-0.30))\n                );\n                assert_eq!(\n                    *right,\n                    FilterExpr::Cmp(\"delta\".into(), CmpOp::Le, Value::Float(-0.15))\n                );\n            }\n            _ => panic!(\"expected And, got {expr:?}\"),\n        }\n    }\n\n    #[test]\n    fn compiled_filter_negative_delta() {\n        let df = DataFrame::new(vec![\n            Column::new(\"delta\".into(), &[-0.40f64, -0.25, -0.15, -0.05, 0.10]),\n        ]).unwrap();\n\n        let f = CompiledFilter::new(\"(delta >= -0.30) & (delta <= -0.10)\").unwrap();\n        let result = f.apply(&df).unwrap();\n        assert_eq!(result.height(), 2); // -0.25 and -0.15\n    }\n\n    // --- eval_row tests ---\n\n    #[test]\n    fn eval_row_simple_eq() {\n        let df = DataFrame::new(vec![\n            Column::new(\"type\".into(), &[\"call\", \"put\", \"put\"]),\n            Column::new(\"ask\".into(), &[1.0f64, 2.0, 0.0]),\n        ]).unwrap();\n\n        let f = CompiledFilter::new(\"type == 'put'\").unwrap();\n        assert!(!f.eval_row(&df, 0)); // call\n        assert!(f.eval_row(&df, 1));  // put\n        assert!(f.eval_row(&df, 2));  // put\n    }\n\n    #[test]\n    fn eval_row_and_filter() {\n        let df = DataFrame::new(vec![\n            Column::new(\"type\".into(), &[\"call\", \"put\", \"put\"]),\n            Column::new(\"ask\".into(), &[1.0f64, 2.0, 0.0]),\n        ]).unwrap();\n\n        let f = CompiledFilter::new(\"(type == 'put') & (ask > 0)\").unwrap();\n        assert!(!f.eval_row(&df, 0)); // call\n        assert!(f.eval_row(&df, 1));  // put, ask=2\n        assert!(!f.eval_row(&df, 2)); // put, ask=0\n    }\n\n    #[test]\n    fn eval_row_col_arith() {\n        let df = DataFrame::new(vec![\n            Column::new(\"strike\".into(), &[110.0f64, 95.0, 105.0]),\n            Column::new(\"underlying_last\".into(), &[100.0f64, 100.0, 100.0]),\n        ]).unwrap();\n\n        // strike >= underlying_last * 1.02 → ColArith(\"underlying_last\", Mul, 1.02, Le, Column(\"strike\"))\n        let f = CompiledFilter::new(\"strike >= underlying_last * 1.02\").unwrap();\n        assert!(f.eval_row(&df, 0));  // 110 >= 102\n        assert!(!f.eval_row(&df, 1)); // 95 < 102\n        assert!(f.eval_row(&df, 2));  // 105 >= 102\n    }\n\n    #[test]\n    fn eval_row_negative_delta() {\n        let df = DataFrame::new(vec![\n            Column::new(\"delta\".into(), &[-0.40f64, -0.25, -0.15, -0.05, 0.10]),\n        ]).unwrap();\n\n        let f = CompiledFilter::new(\"(delta >= -0.30) & (delta <= -0.10)\").unwrap();\n        assert!(!f.eval_row(&df, 0)); // -0.40\n        assert!(f.eval_row(&df, 1));  // -0.25\n        assert!(f.eval_row(&df, 2));  // -0.15\n        assert!(!f.eval_row(&df, 3)); // -0.05\n        assert!(!f.eval_row(&df, 4)); // 0.10\n    }\n\n    #[test]\n    fn eval_row_matches_apply() {\n        // Verify eval_row matches apply for all rows\n        let df = DataFrame::new(vec![\n            Column::new(\"underlying\".into(), &[\"SPX\", \"SPX\", \"AAPL\", \"SPX\"]),\n            Column::new(\"dte\".into(), &[30i32, 90, 90, 150]),\n        ]).unwrap();\n\n        let f = CompiledFilter::new(\"(underlying == 'SPX') & (dte >= 60) & (dte <= 120)\").unwrap();\n        let apply_result = f.apply(&df).unwrap();\n        assert_eq!(apply_result.height(), 1);\n\n        // eval_row should match\n        assert!(!f.eval_row(&df, 0)); // SPX, dte=30\n        assert!(f.eval_row(&df, 1));  // SPX, dte=90\n        assert!(!f.eval_row(&df, 2)); // AAPL\n        assert!(!f.eval_row(&df, 3)); // SPX, dte=150\n    }\n}\n"
  },
  {
    "path": "rust/ob_core/src/inventory.rs",
    "content": "//! Inventory join — THE hot path.\n//!\n//! Mirrors the inner loop of Python's `_update_balance`:\n//!   inv_info.merge(options_data, left_on=\"_contract\", right_on=contract_col)\n//!   then compute _value = sign * price * qty * shares_per_contract\n//!   then groupby(date).sum() split by call/put.\n\nuse polars::prelude::*;\n\nuse crate::types::Direction;\n\n/// Join inventory contracts with current market data and compute leg values.\n///\n/// Returns a DataFrame with columns: [date, _value, _type]\n/// where _value = sign * price * qty * shares_per_contract.\npub fn join_inventory_to_market(\n    contracts: &[String],\n    qtys: &[f64],\n    types: &[String],\n    underlyings: &[String],\n    strikes: &[f64],\n    options_data: &DataFrame,\n    stocks_data: Option<&DataFrame>,\n    contract_col: &str,\n    _date_col: &str,\n    cost_field: &str,\n    stocks_sym_col: Option<&str>,\n    stocks_price_col: Option<&str>,\n    direction: Direction,\n    shares_per_contract: i64,\n) -> PolarsResult<DataFrame> {\n    let contract_series = Series::new(\"_contract\".into(), contracts);\n    let qty_series = Series::new(\"_qty\".into(), qtys);\n    let type_series = Series::new(\"_type\".into(), types);\n    let underlying_series = Series::new(\"_underlying\".into(), underlyings);\n    let strike_series = Series::new(\"_strike\".into(), strikes);\n\n    let inv = DataFrame::new(vec![\n        contract_series.into_column(),\n        qty_series.into_column(),\n        type_series.into_column(),\n        underlying_series.into_column(),\n        strike_series.into_column(),\n    ])?;\n\n    let mut joined = inv\n        .lazy()\n        .join(\n            options_data.clone().lazy(),\n            [col(\"_contract\")],\n            [col(contract_col)],\n            JoinArgs::new(JoinType::Left),\n        )\n        .collect()?;\n\n    // Fill null cost fields with intrinsic value from stocks data\n    if let Some(cost_col) = joined.column(cost_field).ok() {\n        let null_mask = cost_col.is_null();\n        if null_mask.sum().unwrap_or(0) > 0 {\n            // Build a price lookup from stocks data (latest price per symbol)\n            let mut price_map: std::collections::HashMap<String, f64> = std::collections::HashMap::new();\n            if let (Some(sdf), Some(sym_c), Some(price_c)) = (stocks_data, stocks_sym_col, stocks_price_col) {\n                if let (Ok(sym_ca), Ok(price_raw)) = (sdf.column(sym_c), sdf.column(price_c)) {\n                    if let Ok(sym_str) = sym_ca.str() {\n                        let price_casted = price_raw.cast(&DataType::Float64).unwrap_or(price_raw.clone());\n                        if let Ok(price_ca) = price_casted.f64() {\n                            for i in 0..sdf.height() {\n                                if let (Some(s), Some(p)) = (sym_str.get(i), price_ca.get(i)) {\n                                    price_map.insert(s.to_string(), p);\n                                }\n                            }\n                        }\n                    }\n                }\n            }\n\n            let types_ca = joined.column(\"_type\")?.str()?;\n            let strikes_ca = joined.column(\"_strike\")?.f64()?;\n            let underlyings_ca = joined.column(\"_underlying\")?.str()?;\n            let cost_ca = joined.column(cost_field)?.f64()?;\n\n            let filled: Vec<Option<f64>> = (0..joined.height())\n                .map(|i| {\n                    if cost_ca.get(i).is_some() {\n                        cost_ca.get(i)\n                    } else {\n                        let opt_type = types_ca.get(i).unwrap_or(\"put\");\n                        let strike = strikes_ca.get(i).unwrap_or(0.0);\n                        let underlying = underlyings_ca.get(i).unwrap_or(\"\");\n                        let spot = price_map.get(underlying).copied().unwrap_or(0.0);\n                        let iv = if opt_type == \"call\" {\n                            (spot - strike).max(0.0)\n                        } else {\n                            (strike - spot).max(0.0)\n                        };\n                        Some(iv)\n                    }\n                })\n                .collect();\n\n            let filled_series = Float64Chunked::from_iter_options(cost_field.into(), filled.into_iter());\n            let _ = joined.replace(cost_field, filled_series.into_series());\n        }\n    }\n\n    // Compute _value after filling nulls\n    let joined = joined\n        .lazy()\n        .with_column(\n            (lit(direction.sign())\n                * col(cost_field)\n                * col(\"_qty\")\n                * lit(shares_per_contract as f64))\n            .alias(\"_value\"),\n        )\n        .collect()?;\n\n    Ok(joined)\n}\n\n/// Aggregate values by date, split into calls and puts capital.\n///\n/// Returns (calls_by_date, puts_by_date) as Series indexed by date.\npub fn aggregate_by_type(\n    joined: &DataFrame,\n    date_col: &str,\n) -> PolarsResult<(DataFrame, DataFrame)> {\n    let calls = joined\n        .clone()\n        .lazy()\n        .filter(col(\"_type\").eq(lit(\"call\")))\n        .group_by([col(date_col)])\n        .agg([col(\"_value\").sum().alias(\"calls_capital\")])\n        .sort([date_col], Default::default())\n        .collect()?;\n\n    let puts = joined\n        .clone()\n        .lazy()\n        .filter(col(\"_type\").neq(lit(\"call\")))\n        .group_by([col(date_col)])\n        .agg([col(\"_value\").sum().alias(\"puts_capital\")])\n        .sort([date_col], Default::default())\n        .collect()?;\n\n    Ok((calls, puts))\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    fn sample_options() -> DataFrame {\n        df!(\n            \"optionroot\" => &[\"SPX_A\", \"SPX_A\", \"SPX_B\", \"SPX_B\"],\n            \"quotedate\" => &[\"2024-01-01\", \"2024-01-02\", \"2024-01-01\", \"2024-01-02\"],\n            \"ask\" => &[2.0, 2.5, 3.0, 3.5],\n            \"bid\" => &[1.8, 2.3, 2.8, 3.3],\n        )\n        .unwrap()\n    }\n\n    #[test]\n    fn join_computes_values() {\n        let opts = sample_options();\n        let result = join_inventory_to_market(\n            &[\"SPX_A\".into(), \"SPX_B\".into()],\n            &[10.0, 5.0],\n            &[\"call\".into(), \"put\".into()],\n            &[\"SPY\".into(), \"SPY\".into()],\n            &[400.0, 400.0],\n            &opts,\n            None,\n            \"optionroot\",\n            \"quotedate\",\n            \"bid\",\n            None,\n            None,\n            Direction::Buy,\n            100,\n        )\n        .unwrap();\n\n        assert!(result.height() > 0);\n        let values = result.column(\"_value\").unwrap();\n        // Direction::Buy sign = -1, so values should be negative\n        let first_val: f64 = values.f64().unwrap().get(0).unwrap();\n        assert!(first_val < 0.0);\n    }\n\n    #[test]\n    fn aggregate_splits_calls_puts() {\n        let opts = sample_options();\n        let joined = join_inventory_to_market(\n            &[\"SPX_A\".into(), \"SPX_B\".into()],\n            &[10.0, 5.0],\n            &[\"call\".into(), \"put\".into()],\n            &[\"SPY\".into(), \"SPY\".into()],\n            &[400.0, 400.0],\n            &opts,\n            None,\n            \"optionroot\",\n            \"quotedate\",\n            \"bid\",\n            None,\n            None,\n            Direction::Buy,\n            100,\n        )\n        .unwrap();\n\n        let (calls, puts) = aggregate_by_type(&joined, \"quotedate\").unwrap();\n        assert!(calls.height() > 0);\n        assert!(puts.height() > 0);\n    }\n}\n"
  },
  {
    "path": "rust/ob_core/src/lib.rs",
    "content": "pub mod types;\npub mod inventory;\npub mod balance;\npub mod filter;\npub mod entries;\npub mod exits;\npub mod stats;\npub mod cost_model;\npub mod fill_model;\npub mod signal_selector;\npub mod risk;\npub mod backtest;\npub mod convexity_scoring;\npub mod convexity_backtest;\n"
  },
  {
    "path": "rust/ob_core/src/risk.rs",
    "content": "//! Risk management — constraints checked before entering positions.\n//!\n//! Mirrors Python's `options_portfolio_backtester.portfolio.risk`.\n\nuse crate::types::Greeks;\n\n#[derive(Debug, Clone)]\npub enum RiskConstraint {\n    /// Reject trades that would push portfolio delta beyond a limit.\n    MaxDelta { limit: f64 },\n    /// Reject trades that would push portfolio vega beyond a limit.\n    MaxVega { limit: f64 },\n    /// Reject new entries if portfolio drawdown exceeds a threshold.\n    MaxDrawdown { max_dd_pct: f64 },\n}\n\nimpl RiskConstraint {\n    /// Check whether a proposed trade is allowed.\n    ///\n    /// Returns true if the trade passes this constraint.\n    pub fn check(\n        &self,\n        current_greeks: &Greeks,\n        proposed_greeks: &Greeks,\n        portfolio_value: f64,\n        peak_value: f64,\n    ) -> bool {\n        match self {\n            RiskConstraint::MaxDelta { limit } => {\n                let new_delta = current_greeks.delta + proposed_greeks.delta;\n                new_delta.abs() <= *limit\n            }\n            RiskConstraint::MaxVega { limit } => {\n                let new_vega = current_greeks.vega + proposed_greeks.vega;\n                new_vega.abs() <= *limit\n            }\n            RiskConstraint::MaxDrawdown { max_dd_pct } => {\n                if peak_value <= 0.0 {\n                    return true;\n                }\n                let dd = (peak_value - portfolio_value) / peak_value;\n                dd < *max_dd_pct\n            }\n        }\n    }\n}\n\n/// Check all constraints. Returns (allowed, failing_constraint_index).\npub fn check_all(\n    constraints: &[RiskConstraint],\n    current_greeks: &Greeks,\n    proposed_greeks: &Greeks,\n    portfolio_value: f64,\n    peak_value: f64,\n) -> bool {\n    constraints.iter().all(|c| c.check(current_greeks, proposed_greeks, portfolio_value, peak_value))\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn max_delta_allows() {\n        let c = RiskConstraint::MaxDelta { limit: 100.0 };\n        let current = Greeks::new(50.0, 0.0, 0.0, 0.0);\n        let proposed = Greeks::new(30.0, 0.0, 0.0, 0.0);\n        assert!(c.check(&current, &proposed, 1_000_000.0, 1_000_000.0));\n    }\n\n    #[test]\n    fn max_delta_rejects() {\n        let c = RiskConstraint::MaxDelta { limit: 100.0 };\n        let current = Greeks::new(80.0, 0.0, 0.0, 0.0);\n        let proposed = Greeks::new(30.0, 0.0, 0.0, 0.0);\n        assert!(!c.check(&current, &proposed, 1_000_000.0, 1_000_000.0));\n    }\n\n    #[test]\n    fn max_delta_negative() {\n        let c = RiskConstraint::MaxDelta { limit: 100.0 };\n        let current = Greeks::new(-80.0, 0.0, 0.0, 0.0);\n        let proposed = Greeks::new(-30.0, 0.0, 0.0, 0.0);\n        assert!(!c.check(&current, &proposed, 1_000_000.0, 1_000_000.0));\n    }\n\n    #[test]\n    fn max_vega_allows() {\n        let c = RiskConstraint::MaxVega { limit: 50.0 };\n        let current = Greeks::new(0.0, 0.0, 0.0, 20.0);\n        let proposed = Greeks::new(0.0, 0.0, 0.0, 10.0);\n        assert!(c.check(&current, &proposed, 1_000_000.0, 1_000_000.0));\n    }\n\n    #[test]\n    fn max_vega_rejects() {\n        let c = RiskConstraint::MaxVega { limit: 50.0 };\n        let current = Greeks::new(0.0, 0.0, 0.0, 40.0);\n        let proposed = Greeks::new(0.0, 0.0, 0.0, 20.0);\n        assert!(!c.check(&current, &proposed, 1_000_000.0, 1_000_000.0));\n    }\n\n    #[test]\n    fn max_drawdown_allows() {\n        let c = RiskConstraint::MaxDrawdown { max_dd_pct: 0.20 };\n        let g = Greeks::default();\n        // 10% drawdown from peak\n        assert!(c.check(&g, &g, 900_000.0, 1_000_000.0));\n    }\n\n    #[test]\n    fn max_drawdown_rejects() {\n        let c = RiskConstraint::MaxDrawdown { max_dd_pct: 0.20 };\n        let g = Greeks::default();\n        // 25% drawdown from peak\n        assert!(!c.check(&g, &g, 750_000.0, 1_000_000.0));\n    }\n\n    #[test]\n    fn max_drawdown_zero_peak() {\n        let c = RiskConstraint::MaxDrawdown { max_dd_pct: 0.20 };\n        let g = Greeks::default();\n        assert!(c.check(&g, &g, 100.0, 0.0));\n    }\n\n    #[test]\n    fn check_all_passes() {\n        let constraints = vec![\n            RiskConstraint::MaxDelta { limit: 100.0 },\n            RiskConstraint::MaxVega { limit: 50.0 },\n        ];\n        let current = Greeks::new(30.0, 0.0, 0.0, 10.0);\n        let proposed = Greeks::new(10.0, 0.0, 0.0, 5.0);\n        assert!(super::check_all(&constraints, &current, &proposed, 1_000_000.0, 1_000_000.0));\n    }\n\n    #[test]\n    fn check_all_fails_one() {\n        let constraints = vec![\n            RiskConstraint::MaxDelta { limit: 100.0 },\n            RiskConstraint::MaxVega { limit: 50.0 },\n        ];\n        let current = Greeks::new(30.0, 0.0, 0.0, 40.0);\n        let proposed = Greeks::new(10.0, 0.0, 0.0, 20.0);\n        // Delta OK (40), but Vega fails (60 > 50)\n        assert!(!super::check_all(&constraints, &current, &proposed, 1_000_000.0, 1_000_000.0));\n    }\n\n    #[test]\n    fn check_all_empty_passes() {\n        let g = Greeks::default();\n        assert!(super::check_all(&[], &g, &g, 1_000_000.0, 1_000_000.0));\n    }\n}\n"
  },
  {
    "path": "rust/ob_core/src/signal_selector.rs",
    "content": "//! Signal selectors — choose which contract to trade from candidates.\n//!\n//! Mirrors Python's `options_portfolio_backtester.execution.signal_selector`.\n\nuse polars::prelude::*;\n\n#[derive(Debug, Clone, Default)]\npub enum SignalSelector {\n    /// Pick the first row (default — matches original iloc[0] behavior).\n    #[default]\n    FirstMatch,\n    /// Pick the contract whose column value is closest to `target`.\n    NearestDelta { target: f64, column: String },\n    /// Pick the contract with the highest value in `column`.\n    MaxOpenInterest { column: String },\n}\n\nimpl SignalSelector {\n    /// Extra columns this selector needs preserved through the entry pipeline.\n    pub fn column_requirements(&self) -> Vec<&str> {\n        match self {\n            SignalSelector::FirstMatch => vec![],\n            SignalSelector::NearestDelta { column, .. } => vec![column.as_str()],\n            SignalSelector::MaxOpenInterest { column } => vec![column.as_str()],\n        }\n    }\n\n    /// Select one row index from a DataFrame of candidates. Returns 0-based row index.\n    #[inline]\n    pub fn select_index(&self, candidates: &DataFrame) -> usize {\n        if candidates.height() == 0 {\n            return 0;\n        }\n        match self {\n            SignalSelector::FirstMatch => 0,\n            SignalSelector::NearestDelta { target, column } => {\n                match candidates.column(column).ok() {\n                    Some(col) => {\n                        match col.f64() {\n                            Ok(ca) => {\n                                let mut best_idx = 0;\n                                let mut best_diff = f64::MAX;\n                                for (i, val) in ca.into_iter().enumerate() {\n                                    if let Some(v) = val {\n                                        let diff = (v - target).abs();\n                                        if diff < best_diff {\n                                            best_diff = diff;\n                                            best_idx = i;\n                                        }\n                                    }\n                                }\n                                best_idx\n                            }\n                            Err(_) => 0,\n                        }\n                    }\n                    None => 0, // column not found, fall back to first\n                }\n            }\n            SignalSelector::MaxOpenInterest { column } => {\n                match candidates.column(column).ok() {\n                    Some(col) => {\n                        match col.f64() {\n                            Ok(ca) => {\n                                let mut best_idx = 0;\n                                let mut best_val = f64::MIN;\n                                for (i, val) in ca.into_iter().enumerate() {\n                                    if let Some(v) = val {\n                                        if v > best_val {\n                                            best_val = v;\n                                            best_idx = i;\n                                        }\n                                    }\n                                }\n                                best_idx\n                            }\n                            Err(_) => {\n                                // Try i64 column\n                                match col.i64() {\n                                    Ok(ca) => {\n                                        let mut best_idx = 0;\n                                        let mut best_val = i64::MIN;\n                                        for (i, val) in ca.into_iter().enumerate() {\n                                            if let Some(v) = val {\n                                                if v > best_val {\n                                                    best_val = v;\n                                                    best_idx = i;\n                                                }\n                                            }\n                                        }\n                                        best_idx\n                                    }\n                                    Err(_) => 0,\n                                }\n                            }\n                        }\n                    }\n                    None => 0,\n                }\n            }\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    fn sample_candidates() -> DataFrame {\n        df!(\n            \"contract\" => &[\"A\", \"B\", \"C\"],\n            \"cost\" => &[100.0, 200.0, 150.0],\n            \"delta\" => &[-0.20, -0.30, -0.45],\n            \"openinterest\" => &[500.0, 1200.0, 800.0],\n        ).unwrap()\n    }\n\n    #[test]\n    fn first_match() {\n        let df = sample_candidates();\n        let sel = SignalSelector::FirstMatch;\n        assert_eq!(sel.select_index(&df), 0);\n    }\n\n    #[test]\n    fn nearest_delta() {\n        let df = sample_candidates();\n        let sel = SignalSelector::NearestDelta {\n            target: -0.30,\n            column: \"delta\".into(),\n        };\n        assert_eq!(sel.select_index(&df), 1); // B has delta=-0.30\n    }\n\n    #[test]\n    fn nearest_delta_between() {\n        let df = sample_candidates();\n        let sel = SignalSelector::NearestDelta {\n            target: -0.35,\n            column: \"delta\".into(),\n        };\n        // -0.30 is 0.05 away, -0.45 is 0.10 away → B wins\n        assert_eq!(sel.select_index(&df), 1);\n    }\n\n    #[test]\n    fn max_open_interest() {\n        let df = sample_candidates();\n        let sel = SignalSelector::MaxOpenInterest {\n            column: \"openinterest\".into(),\n        };\n        assert_eq!(sel.select_index(&df), 1); // B has OI=1200\n    }\n\n    #[test]\n    fn missing_column_falls_back() {\n        let df = sample_candidates();\n        let sel = SignalSelector::NearestDelta {\n            target: -0.30,\n            column: \"nonexistent\".into(),\n        };\n        assert_eq!(sel.select_index(&df), 0);\n    }\n\n    #[test]\n    fn column_requirements_check() {\n        assert!(SignalSelector::FirstMatch.column_requirements().is_empty());\n        let sel = SignalSelector::NearestDelta { target: 0.0, column: \"delta\".into() };\n        assert_eq!(sel.column_requirements(), vec![\"delta\"]);\n        let sel = SignalSelector::MaxOpenInterest { column: \"oi\".into() };\n        assert_eq!(sel.column_requirements(), vec![\"oi\"]);\n    }\n}\n"
  },
  {
    "path": "rust/ob_core/src/stats.rs",
    "content": "//! Performance statistics computation.\n//!\n//! Comprehensive stats matching Python's BacktestStats: return metrics,\n//! drawdown analysis, period stats, lookback returns, trade stats,\n//! portfolio metrics (turnover, Herfindahl).\n\nconst TRADING_DAYS_PER_YEAR: f64 = 252.0;\nconst MONTHS_PER_YEAR: f64 = 12.0;\n\n// ---------------------------------------------------------------------------\n// Public result types\n// ---------------------------------------------------------------------------\n\n/// Stats for a specific return frequency (daily, monthly, yearly).\n#[derive(Debug, Clone, Default)]\npub struct PeriodStats {\n    pub mean: f64,\n    pub vol: f64,\n    pub sharpe: f64,\n    pub sortino: f64,\n    pub skew: f64,\n    pub kurtosis: f64,\n    pub best: f64,\n    pub worst: f64,\n}\n\n/// Trailing-period returns as of the last date.\n#[derive(Debug, Clone, Default)]\npub struct LookbackReturns {\n    pub mtd: Option<f64>,\n    pub three_month: Option<f64>,\n    pub six_month: Option<f64>,\n    pub ytd: Option<f64>,\n    pub one_year: Option<f64>,\n    pub three_year: Option<f64>,\n    pub five_year: Option<f64>,\n    pub ten_year: Option<f64>,\n}\n\n/// Comprehensive backtest statistics.\n#[derive(Debug, Clone, Default)]\npub struct FullStats {\n    // Trade stats\n    pub total_trades: u32,\n    pub wins: u32,\n    pub losses: u32,\n    pub win_pct: f64,\n    pub profit_factor: f64,\n    pub largest_win: f64,\n    pub largest_loss: f64,\n    pub avg_win: f64,\n    pub avg_loss: f64,\n    pub avg_trade: f64,\n\n    // Return stats\n    pub total_return: f64,\n    pub annualized_return: f64,\n    pub sharpe_ratio: f64,\n    pub sortino_ratio: f64,\n    pub calmar_ratio: f64,\n\n    // Risk stats\n    pub max_drawdown: f64,\n    pub max_drawdown_duration: u32,\n    pub avg_drawdown: f64,\n    pub avg_drawdown_duration: u32,\n    pub volatility: f64,\n    pub tail_ratio: f64,\n\n    // Period stats\n    pub daily: PeriodStats,\n    pub monthly: PeriodStats,\n    pub yearly: PeriodStats,\n\n    // Lookback\n    pub lookback: LookbackReturns,\n\n    // Portfolio metrics\n    pub turnover: f64,\n    pub herfindahl: f64,\n}\n\n// ---------------------------------------------------------------------------\n// Legacy Stats (kept for backward compat with existing callers)\n// ---------------------------------------------------------------------------\n\n/// Legacy stats struct used by run_backtest_py / parallel_sweep.\n#[derive(Debug, Clone, Default)]\npub struct Stats {\n    pub total_return: f64,\n    pub annualized_return: f64,\n    pub sharpe_ratio: f64,\n    pub sortino_ratio: f64,\n    pub calmar_ratio: f64,\n    pub max_drawdown: f64,\n    pub max_drawdown_duration: u32,\n    pub profit_factor: f64,\n    pub win_rate: f64,\n    pub total_trades: u32,\n}\n\n// ---------------------------------------------------------------------------\n// Main entry points\n// ---------------------------------------------------------------------------\n\n/// Compute legacy stats (backward compat).\npub fn compute_stats(\n    daily_returns: &[f64],\n    trade_pnls: &[f64],\n    risk_free_rate: f64,\n) -> Stats {\n    let n = daily_returns.len();\n    if n == 0 {\n        return Stats::default();\n    }\n\n    let total_return = cum_return(daily_returns);\n    let years = n as f64 / TRADING_DAYS_PER_YEAR;\n    let annualized_return = annualize(total_return, years);\n\n    let sharpe_ratio = sharpe(daily_returns, risk_free_rate, TRADING_DAYS_PER_YEAR);\n    let sortino_ratio = sortino(daily_returns, risk_free_rate, TRADING_DAYS_PER_YEAR);\n\n    let dd = compute_drawdown_full(daily_returns);\n    let calmar_ratio = if dd.max_drawdown > 0.0 {\n        annualized_return / dd.max_drawdown\n    } else {\n        0.0\n    };\n\n    let ts = compute_trade_stats(trade_pnls);\n\n    Stats {\n        total_return,\n        annualized_return,\n        sharpe_ratio,\n        sortino_ratio,\n        calmar_ratio,\n        max_drawdown: dd.max_drawdown,\n        max_drawdown_duration: dd.max_drawdown_duration,\n        profit_factor: ts.profit_factor,\n        win_rate: ts.win_pct / 100.0, // legacy uses 0-1 scale\n        total_trades: ts.total_trades,\n    }\n}\n\n/// Compute comprehensive stats from total_capital series + optional trade PnLs.\n///\n/// `total_capital`: daily total capital values (one per trading day).\n/// `timestamps_ns`: nanosecond timestamps for each capital value (for monthly/yearly resampling).\n/// `trade_pnls`: per-trade profit/loss values.\n/// `stock_weights`: flattened [n_days × n_stocks] matrix of portfolio weights (row-major).\n/// `n_stocks`: number of stock columns.\n/// `risk_free_rate`: annualized risk-free rate.\npub fn compute_full_stats(\n    total_capital: &[f64],\n    timestamps_ns: &[i64],\n    trade_pnls: &[f64],\n    stock_weights: &[f64],\n    n_stocks: usize,\n    risk_free_rate: f64,\n) -> FullStats {\n    let mut fs = FullStats::default();\n\n    if total_capital.len() < 2 {\n        return fs;\n    }\n\n    // Daily returns from capital series\n    let daily_returns: Vec<f64> = total_capital\n        .windows(2)\n        .map(|w| if w[0] != 0.0 { w[1] / w[0] - 1.0 } else { 0.0 })\n        .collect();\n\n    let n = daily_returns.len();\n    if n == 0 {\n        return fs;\n    }\n\n    // -- Return metrics --\n    fs.total_return = total_capital.last().unwrap() / total_capital[0] - 1.0;\n    let years = n as f64 / TRADING_DAYS_PER_YEAR;\n    fs.annualized_return = annualize(fs.total_return, years);\n    fs.volatility = std_dev(&daily_returns) * TRADING_DAYS_PER_YEAR.sqrt();\n    fs.sharpe_ratio = sharpe(&daily_returns, risk_free_rate, TRADING_DAYS_PER_YEAR);\n    fs.sortino_ratio = sortino(&daily_returns, risk_free_rate, TRADING_DAYS_PER_YEAR);\n\n    // -- Drawdown --\n    let dd = compute_drawdown_full(&daily_returns);\n    fs.max_drawdown = dd.max_drawdown;\n    fs.max_drawdown_duration = dd.max_drawdown_duration;\n    fs.avg_drawdown = dd.avg_drawdown;\n    fs.avg_drawdown_duration = dd.avg_drawdown_duration;\n\n    // Calmar\n    if fs.max_drawdown > 0.0 {\n        fs.calmar_ratio = fs.annualized_return / fs.max_drawdown;\n    }\n\n    // Tail ratio\n    if n > 20 {\n        let p95 = percentile(&daily_returns, 95.0);\n        let p5 = percentile(&daily_returns, 5.0).abs();\n        if p5 > 0.0 {\n            fs.tail_ratio = p95 / p5;\n        }\n    }\n\n    // -- Daily period stats --\n    fs.daily = compute_period_stats(&daily_returns, risk_free_rate, TRADING_DAYS_PER_YEAR);\n\n    // -- Monthly period stats --\n    let monthly_returns = resample_returns(total_capital, timestamps_ns, ResampleFreq::Monthly);\n    if !monthly_returns.is_empty() {\n        fs.monthly = compute_period_stats(&monthly_returns, risk_free_rate, MONTHS_PER_YEAR);\n    }\n\n    // -- Yearly period stats --\n    let yearly_returns = resample_returns(total_capital, timestamps_ns, ResampleFreq::Yearly);\n    if !yearly_returns.is_empty() {\n        fs.yearly = compute_period_stats(&yearly_returns, risk_free_rate, 1.0);\n    }\n\n    // -- Lookback returns --\n    fs.lookback = compute_lookback(total_capital, timestamps_ns);\n\n    // -- Turnover --\n    fs.turnover = compute_turnover(stock_weights, n_stocks);\n\n    // -- Herfindahl --\n    fs.herfindahl = compute_herfindahl(stock_weights, n_stocks);\n\n    // -- Trade stats --\n    let ts = compute_trade_stats(trade_pnls);\n    fs.total_trades = ts.total_trades;\n    fs.wins = ts.wins;\n    fs.losses = ts.losses;\n    fs.win_pct = ts.win_pct;\n    fs.profit_factor = ts.profit_factor;\n    fs.largest_win = ts.largest_win;\n    fs.largest_loss = ts.largest_loss;\n    fs.avg_win = ts.avg_win;\n    fs.avg_loss = ts.avg_loss;\n    fs.avg_trade = ts.avg_trade;\n\n    fs\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\nfn cum_return(returns: &[f64]) -> f64 {\n    returns.iter().fold(1.0, |acc, &r| acc * (1.0 + r)) - 1.0\n}\n\nfn annualize(total_return: f64, years: f64) -> f64 {\n    if years > 0.0 {\n        (1.0 + total_return).powf(1.0 / years) - 1.0\n    } else {\n        0.0\n    }\n}\n\nfn mean(values: &[f64]) -> f64 {\n    if values.is_empty() {\n        return 0.0;\n    }\n    values.iter().sum::<f64>() / values.len() as f64\n}\n\nfn std_dev(values: &[f64]) -> f64 {\n    if values.len() < 2 {\n        return 0.0;\n    }\n    let m = mean(values);\n    let variance =\n        values.iter().map(|&x| (x - m).powi(2)).sum::<f64>() / (values.len() - 1) as f64;\n    variance.sqrt()\n}\n\nfn skewness(values: &[f64]) -> f64 {\n    let n = values.len();\n    if n < 8 {\n        return 0.0;\n    }\n    let m = mean(values);\n    let s = std_dev(values);\n    if s == 0.0 {\n        return 0.0;\n    }\n    let nf = n as f64;\n    let m3: f64 = values.iter().map(|&x| ((x - m) / s).powi(3)).sum::<f64>() / nf;\n    // Adjusted Fisher-Pearson (matches pandas default)\n    let adj = (nf * (nf - 1.0)).sqrt() / (nf - 2.0);\n    adj * m3\n}\n\nfn kurtosis_excess(values: &[f64]) -> f64 {\n    let n = values.len();\n    if n < 8 {\n        return 0.0;\n    }\n    let m = mean(values);\n    let s = std_dev(values);\n    if s == 0.0 {\n        return 0.0;\n    }\n    let nf = n as f64;\n    let m4: f64 = values.iter().map(|&x| ((x - m) / s).powi(4)).sum::<f64>() / nf;\n    // Excess kurtosis with bias correction (matches pandas default)\n    let raw = m4 - 3.0;\n    let adj = (nf - 1.0) / ((nf - 2.0) * (nf - 3.0)) * ((nf + 1.0) * raw + 6.0);\n    adj\n}\n\nfn sharpe(returns: &[f64], risk_free_rate: f64, periods_per_year: f64) -> f64 {\n    if returns.len() < 2 {\n        return 0.0;\n    }\n    let rf_per_period = (1.0 + risk_free_rate).powf(1.0 / periods_per_year) - 1.0;\n    let excess: Vec<f64> = returns.iter().map(|&r| r - rf_per_period).collect();\n    let s = std_dev(&excess);\n    if s == 0.0 {\n        return 0.0;\n    }\n    mean(&excess) / s * periods_per_year.sqrt()\n}\n\nfn sortino(returns: &[f64], risk_free_rate: f64, periods_per_year: f64) -> f64 {\n    if returns.len() < 2 {\n        return 0.0;\n    }\n    let rf_per_period = (1.0 + risk_free_rate).powf(1.0 / periods_per_year) - 1.0;\n    let excess: Vec<f64> = returns.iter().map(|&r| r - rf_per_period).collect();\n    let downside: Vec<f64> = excess.iter().filter(|&&r| r < 0.0).copied().collect();\n    if downside.is_empty() {\n        return 0.0;\n    }\n    let s = std_dev(&downside);\n    if s == 0.0 {\n        return 0.0;\n    }\n    mean(&excess) / s * periods_per_year.sqrt()\n}\n\nfn percentile(values: &[f64], pct: f64) -> f64 {\n    if values.is_empty() {\n        return 0.0;\n    }\n    let mut sorted: Vec<f64> = values.to_vec();\n    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));\n    let idx = (pct / 100.0) * (sorted.len() - 1) as f64;\n    let lo = idx.floor() as usize;\n    let hi = idx.ceil() as usize;\n    if lo == hi || hi >= sorted.len() {\n        sorted[lo.min(sorted.len() - 1)]\n    } else {\n        let frac = idx - lo as f64;\n        sorted[lo] * (1.0 - frac) + sorted[hi] * frac\n    }\n}\n\n// -- Drawdown --\n\nstruct DrawdownResult {\n    max_drawdown: f64,\n    max_drawdown_duration: u32,\n    avg_drawdown: f64,\n    avg_drawdown_duration: u32,\n}\n\nfn compute_drawdown_full(daily_returns: &[f64]) -> DrawdownResult {\n    let mut peak = 1.0_f64;\n    let mut equity = 1.0_f64;\n    let mut max_dd = 0.0_f64;\n    let mut max_dd_dur: u32 = 0;\n    let mut current_dur: u32 = 0;\n\n    // Track drawdown episodes for avg computation\n    let mut episode_depths: Vec<f64> = Vec::new();\n    let mut episode_durations: Vec<u32> = Vec::new();\n    let mut current_min_dd = 0.0_f64; // deepest dd in current episode\n\n    for &r in daily_returns {\n        equity *= 1.0 + r;\n        if equity > peak {\n            // End of drawdown episode (if we were in one)\n            if current_dur > 0 {\n                episode_depths.push(current_min_dd);\n                episode_durations.push(current_dur);\n            }\n            peak = equity;\n            current_dur = 0;\n            current_min_dd = 0.0;\n        } else {\n            current_dur += 1;\n            max_dd_dur = max_dd_dur.max(current_dur);\n        }\n        let dd = (peak - equity) / peak;\n        if dd > max_dd {\n            max_dd = dd;\n        }\n        if dd > current_min_dd {\n            current_min_dd = dd;\n        }\n    }\n    // Close last episode if still in drawdown\n    if current_dur > 0 {\n        episode_depths.push(current_min_dd);\n        episode_durations.push(current_dur);\n    }\n\n    let avg_drawdown = if episode_depths.is_empty() {\n        0.0\n    } else {\n        mean(&episode_depths)\n    };\n\n    let avg_drawdown_duration = if episode_durations.is_empty() {\n        0\n    } else {\n        let dur_f: Vec<f64> = episode_durations.iter().map(|&d| d as f64).collect();\n        mean(&dur_f) as u32\n    };\n\n    DrawdownResult {\n        max_drawdown: max_dd,\n        max_drawdown_duration: max_dd_dur,\n        avg_drawdown,\n        avg_drawdown_duration,\n    }\n}\n\n// -- Period stats --\n\nfn compute_period_stats(returns: &[f64], risk_free_rate: f64, periods_per_year: f64) -> PeriodStats {\n    if returns.is_empty() {\n        return PeriodStats::default();\n    }\n    PeriodStats {\n        mean: mean(returns),\n        vol: std_dev(returns),\n        sharpe: sharpe(returns, risk_free_rate, periods_per_year),\n        sortino: sortino(returns, risk_free_rate, periods_per_year),\n        skew: skewness(returns),\n        kurtosis: kurtosis_excess(returns),\n        best: returns.iter().cloned().fold(f64::NEG_INFINITY, f64::max),\n        worst: returns.iter().cloned().fold(f64::INFINITY, f64::min),\n    }\n}\n\n// -- Resampling --\n\n#[derive(Clone, Copy)]\nenum ResampleFreq {\n    Monthly,\n    Yearly,\n}\n\n/// Resample total_capital to end-of-period values, then compute returns.\nfn resample_returns(\n    total_capital: &[f64],\n    timestamps_ns: &[i64],\n    freq: ResampleFreq,\n) -> Vec<f64> {\n    if total_capital.len() < 2 || timestamps_ns.len() != total_capital.len() {\n        return Vec::new();\n    }\n\n    // Group by period key, take last value in each period\n    let mut period_vals: Vec<f64> = Vec::new();\n    let mut last_key: Option<(i32, u32)> = None;\n\n    for (i, &ts_ns) in timestamps_ns.iter().enumerate() {\n        let key = period_key(ts_ns, freq);\n        match last_key {\n            Some(prev) if prev != key => {\n                // Previous period ended at i-1\n                period_vals.push(total_capital[i - 1]);\n                last_key = Some(key);\n            }\n            None => {\n                last_key = Some(key);\n            }\n            _ => {}\n        }\n    }\n    // Push the last period value\n    if let Some(_) = last_key {\n        period_vals.push(*total_capital.last().unwrap());\n    }\n\n    // Compute returns from period values\n    if period_vals.len() < 2 {\n        return Vec::new();\n    }\n    period_vals\n        .windows(2)\n        .map(|w| if w[0] != 0.0 { w[1] / w[0] - 1.0 } else { 0.0 })\n        .collect()\n}\n\n/// Convert nanosecond timestamp to (year, period) key.\nfn period_key(ts_ns: i64, freq: ResampleFreq) -> (i32, u32) {\n    // Convert nanoseconds since epoch to days\n    let days_since_epoch = (ts_ns / 86_400_000_000_000) as i32;\n    // Simple calendar calculation from days since 1970-01-01\n    let (year, month, _day) = days_to_ymd(days_since_epoch);\n    match freq {\n        ResampleFreq::Monthly => (year, month),\n        ResampleFreq::Yearly => (year, 0),\n    }\n}\n\n/// Convert days since epoch (1970-01-01) to (year, month, day).\nfn days_to_ymd(days: i32) -> (i32, u32, u32) {\n    // Algorithm from Howard Hinnant's date library (public domain)\n    let z = days + 719468;\n    let era = if z >= 0 { z } else { z - 146096 } / 146097;\n    let doe = (z - era * 146097) as u32;\n    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;\n    let y = yoe as i32 + era * 400;\n    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);\n    let mp = (5 * doy + 2) / 153;\n    let d = doy - (153 * mp + 2) / 5 + 1;\n    let m = if mp < 10 { mp + 3 } else { mp - 9 };\n    let year = if m <= 2 { y + 1 } else { y };\n    (year, m, d)\n}\n\n// -- Lookback returns --\n\nfn compute_lookback(total_capital: &[f64], timestamps_ns: &[i64]) -> LookbackReturns {\n    let mut lb = LookbackReturns::default();\n    if total_capital.len() < 2 || timestamps_ns.len() != total_capital.len() {\n        return lb;\n    }\n\n    let end_val = *total_capital.last().unwrap();\n    let end_ts = *timestamps_ns.last().unwrap();\n    let (end_year, end_month, _end_day) = days_to_ymd((end_ts / 86_400_000_000_000) as i32);\n\n    // Helper: find return since the first data point on or after target_ns\n    let return_since = |target_ns: i64| -> Option<f64> {\n        match timestamps_ns.iter().position(|&ts| ts >= target_ns) {\n            Some(idx) => {\n                let start_val = total_capital[idx];\n                if start_val == 0.0 {\n                    None\n                } else {\n                    Some(end_val / start_val - 1.0)\n                }\n            }\n            None => None,\n        }\n    };\n\n    // MTD: start of current month\n    lb.mtd = return_since(ymd_to_ns(end_year, end_month, 1));\n\n    // YTD: start of current year\n    lb.ytd = return_since(ymd_to_ns(end_year, 1, 1));\n\n    // Fixed offsets (in months)\n    let offsets: [(fn(&mut LookbackReturns, Option<f64>), u32); 6] = [\n        (|lb, v| lb.three_month = v, 3),\n        (|lb, v| lb.six_month = v, 6),\n        (|lb, v| lb.one_year = v, 12),\n        (|lb, v| lb.three_year = v, 36),\n        (|lb, v| lb.five_year = v, 60),\n        (|lb, v| lb.ten_year = v, 120),\n    ];\n\n    for (setter, months) in offsets {\n        let target_ns = subtract_months_ns(end_ts, months);\n        setter(&mut lb, return_since(target_ns));\n    }\n\n    lb\n}\n\nfn ymd_to_ns(year: i32, month: u32, day: u32) -> i64 {\n    // Inverse of days_to_ymd: compute days since epoch\n    let y = if month <= 2 { year - 1 } else { year } as i64;\n    let m = if month <= 2 { month + 9 } else { month - 3 } as i64;\n    let era = if y >= 0 { y } else { y - 399 } / 400;\n    let yoe = y - era * 400;\n    let doy = (153 * m + 2) / 5 + day as i64 - 1;\n    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;\n    let days = era * 146097 + doe - 719468;\n    days * 86_400_000_000_000\n}\n\nfn subtract_months_ns(ts_ns: i64, months: u32) -> i64 {\n    let (year, month, day) = days_to_ymd((ts_ns / 86_400_000_000_000) as i32);\n    let total_months = year * 12 + month as i32 - months as i32;\n    let new_year = if total_months > 0 {\n        (total_months - 1) / 12\n    } else {\n        (total_months - 12) / 12\n    };\n    let new_month = total_months - new_year * 12;\n    ymd_to_ns(new_year, new_month as u32, day.min(28)) // clamp day to avoid invalid dates\n}\n\n// -- Portfolio metrics --\n\nfn compute_turnover(stock_weights: &[f64], n_stocks: usize) -> f64 {\n    if n_stocks == 0 || stock_weights.is_empty() {\n        return 0.0;\n    }\n    let n_days = stock_weights.len() / n_stocks;\n    if n_days < 2 {\n        return 0.0;\n    }\n\n    let mut total_change = 0.0;\n    for day in 1..n_days {\n        let mut day_change = 0.0;\n        for s in 0..n_stocks {\n            let prev = stock_weights[(day - 1) * n_stocks + s];\n            let curr = stock_weights[day * n_stocks + s];\n            day_change += (curr - prev).abs();\n        }\n        total_change += day_change;\n    }\n\n    total_change / (n_days - 1) as f64 / 2.0\n}\n\nfn compute_herfindahl(stock_weights: &[f64], n_stocks: usize) -> f64 {\n    if n_stocks == 0 || stock_weights.is_empty() {\n        return 0.0;\n    }\n    let n_days = stock_weights.len() / n_stocks;\n    if n_days == 0 {\n        return 0.0;\n    }\n\n    let mut total_hhi = 0.0;\n    for day in 0..n_days {\n        let mut hhi = 0.0;\n        for s in 0..n_stocks {\n            let w = stock_weights[day * n_stocks + s];\n            hhi += w * w;\n        }\n        total_hhi += hhi;\n    }\n\n    total_hhi / n_days as f64\n}\n\n// -- Trade stats --\n\nstruct TradeStatsResult {\n    total_trades: u32,\n    wins: u32,\n    losses: u32,\n    win_pct: f64,\n    profit_factor: f64,\n    largest_win: f64,\n    largest_loss: f64,\n    avg_win: f64,\n    avg_loss: f64,\n    avg_trade: f64,\n}\n\nfn compute_trade_stats(pnls: &[f64]) -> TradeStatsResult {\n    if pnls.is_empty() {\n        return TradeStatsResult {\n            total_trades: 0,\n            wins: 0,\n            losses: 0,\n            win_pct: 0.0,\n            profit_factor: 0.0,\n            largest_win: 0.0,\n            largest_loss: 0.0,\n            avg_win: 0.0,\n            avg_loss: 0.0,\n            avg_trade: 0.0,\n        };\n    }\n\n    let mut gross_profit = 0.0;\n    let mut gross_loss = 0.0;\n    let mut wins: u32 = 0;\n    let mut losses: u32 = 0;\n    let mut largest_win = 0.0_f64;\n    let mut largest_loss = 0.0_f64;\n    let mut sum_wins = 0.0;\n    let mut sum_losses = 0.0;\n\n    for &pnl in pnls {\n        if pnl > 0.0 {\n            gross_profit += pnl;\n            wins += 1;\n            sum_wins += pnl;\n            if pnl > largest_win {\n                largest_win = pnl;\n            }\n        } else {\n            gross_loss += pnl.abs();\n            losses += 1;\n            sum_losses += pnl;\n            if pnl < largest_loss {\n                largest_loss = pnl;\n            }\n        }\n    }\n\n    let total = pnls.len() as u32;\n    let win_pct = if total > 0 {\n        wins as f64 / total as f64 * 100.0\n    } else {\n        0.0\n    };\n\n    let profit_factor = if gross_loss > 0.0 {\n        gross_profit / gross_loss\n    } else if gross_profit > 0.0 {\n        f64::INFINITY\n    } else {\n        0.0\n    };\n\n    TradeStatsResult {\n        total_trades: total,\n        wins,\n        losses,\n        win_pct,\n        profit_factor,\n        largest_win,\n        largest_loss,\n        avg_win: if wins > 0 { sum_wins / wins as f64 } else { 0.0 },\n        avg_loss: if losses > 0 { sum_losses / losses as f64 } else { 0.0 },\n        avg_trade: pnls.iter().sum::<f64>() / total as f64,\n    }\n}\n\n// ---------------------------------------------------------------------------\n// Tests\n// ---------------------------------------------------------------------------\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn stats_empty() {\n        let s = compute_stats(&[], &[], 0.0);\n        assert_eq!(s.total_return, 0.0);\n    }\n\n    #[test]\n    fn stats_simple_returns() {\n        let returns = vec![0.01, -0.005, 0.02, -0.01, 0.015];\n        let s = compute_stats(&returns, &[], 0.0);\n        assert!(s.total_return > 0.0);\n        assert!(s.sharpe_ratio != 0.0);\n    }\n\n    #[test]\n    fn drawdown_calculation() {\n        let returns = vec![0.10, -0.18182]; // 1.0 -> 1.1 -> 0.9\n        let dd = compute_drawdown_full(&returns);\n        assert!((dd.max_drawdown - 0.18182).abs() < 0.01);\n    }\n\n    #[test]\n    fn profit_factor_calculation() {\n        let pnls = vec![100.0, -50.0, 200.0, -30.0];\n        let s = compute_stats(&[0.01; 4], &pnls, 0.0);\n        assert!((s.profit_factor - 300.0 / 80.0).abs() < 0.01);\n        assert_eq!(s.total_trades, 4);\n        assert_eq!(s.win_rate, 0.5);\n    }\n\n    #[test]\n    fn full_stats_empty() {\n        let fs = compute_full_stats(&[], &[], &[], &[], 0, 0.0);\n        assert_eq!(fs.total_return, 0.0);\n        assert_eq!(fs.total_trades, 0);\n    }\n\n    #[test]\n    fn full_stats_basic() {\n        // 10 days of varying positive returns\n        let daily = vec![0.01, 0.005, 0.02, -0.003, 0.015, 0.008, -0.002, 0.012, 0.007, 0.01];\n        let mut capital = vec![100_000.0];\n        for &r in &daily {\n            capital.push(capital.last().unwrap() * (1.0 + r));\n        }\n        // Generate fake timestamps (2020-01-01 + daily)\n        let base_ns: i64 = 1577836800_000_000_000; // 2020-01-01\n        let ts: Vec<i64> = (0..capital.len())\n            .map(|i| base_ns + i as i64 * 86_400_000_000_000)\n            .collect();\n\n        let fs = compute_full_stats(&capital, &ts, &[], &[], 0, 0.0);\n        assert!(fs.total_return > 0.0);\n        assert!(fs.volatility > 0.0);\n        assert!(fs.daily.mean > 0.0);\n    }\n\n    #[test]\n    fn full_stats_drawdown_avg() {\n        // Up, crash, recover, crash again\n        let returns = vec![0.10, -0.15, -0.05, 0.30, 0.05, -0.10, 0.20];\n        let mut capital = vec![100_000.0];\n        for &r in &returns {\n            capital.push(capital.last().unwrap() * (1.0 + r));\n        }\n        let base_ns: i64 = 1577836800_000_000_000;\n        let ts: Vec<i64> = (0..capital.len())\n            .map(|i| base_ns + i as i64 * 86_400_000_000_000)\n            .collect();\n\n        let fs = compute_full_stats(&capital, &ts, &[], &[], 0, 0.0);\n        assert!(fs.max_drawdown > 0.0);\n        assert!(fs.avg_drawdown > 0.0);\n        assert!(fs.avg_drawdown <= fs.max_drawdown);\n    }\n\n    #[test]\n    fn full_stats_trade_pnls() {\n        let capital = vec![100_000.0, 101_000.0, 102_000.0];\n        let base_ns: i64 = 1577836800_000_000_000;\n        let ts: Vec<i64> = (0..3).map(|i| base_ns + i * 86_400_000_000_000).collect();\n        let pnls = vec![100.0, 200.0, -50.0];\n\n        let fs = compute_full_stats(&capital, &ts, &pnls, &[], 0, 0.0);\n        assert_eq!(fs.total_trades, 3);\n        assert_eq!(fs.wins, 2);\n        assert_eq!(fs.losses, 1);\n        assert!((fs.profit_factor - 6.0).abs() < 0.01);\n    }\n\n    #[test]\n    fn full_stats_turnover() {\n        // 3 days, 2 stocks\n        let weights = vec![\n            0.5, 0.5, // day 0\n            0.6, 0.4, // day 1: 0.1 change each\n            0.6, 0.4, // day 2: no change\n        ];\n        let t = compute_turnover(&weights, 2);\n        // day 1: sum(|0.1|+|0.1|)/2 = 0.1, day 2: 0 → avg = 0.05\n        assert!((t - 0.05).abs() < 1e-10);\n    }\n\n    #[test]\n    fn full_stats_herfindahl() {\n        // 2 equal stocks → HHI = 0.5\n        let weights = vec![0.5, 0.5, 0.5, 0.5];\n        let h = compute_herfindahl(&weights, 2);\n        assert!((h - 0.5).abs() < 1e-10);\n    }\n\n    #[test]\n    fn percentile_basic() {\n        let vals = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];\n        let p50 = percentile(&vals, 50.0);\n        assert!((p50 - 5.5).abs() < 0.01);\n        let p0 = percentile(&vals, 0.0);\n        assert!((p0 - 1.0).abs() < 0.01);\n        let p100 = percentile(&vals, 100.0);\n        assert!((p100 - 10.0).abs() < 0.01);\n    }\n\n    #[test]\n    fn days_to_ymd_epoch() {\n        let (y, m, d) = days_to_ymd(0);\n        assert_eq!((y, m, d), (1970, 1, 1));\n    }\n\n    #[test]\n    fn days_to_ymd_known_date() {\n        // 2020-01-01 = 18262 days since epoch\n        let (y, m, d) = days_to_ymd(18262);\n        assert_eq!((y, m, d), (2020, 1, 1));\n    }\n\n    #[test]\n    fn ymd_roundtrip() {\n        let ns = ymd_to_ns(2020, 6, 15);\n        let days = (ns / 86_400_000_000_000) as i32;\n        let (y, m, d) = days_to_ymd(days);\n        assert_eq!((y, m, d), (2020, 6, 15));\n    }\n\n    #[test]\n    fn lookback_basic() {\n        // ~500 trading days from 2020-01-01\n        let n = 500;\n        let mut capital = vec![100_000.0];\n        for i in 0..n {\n            capital.push(capital[i] * 1.001); // small daily growth\n        }\n        let base_ns: i64 = ymd_to_ns(2020, 1, 1);\n        let ts: Vec<i64> = (0..=n)\n            .map(|i| base_ns + i as i64 * 86_400_000_000_000)\n            .collect();\n\n        let lb = compute_lookback(&capital, &ts);\n        assert!(lb.mtd.is_some());\n        assert!(lb.ytd.is_some());\n        assert!(lb.one_year.is_some());\n    }\n\n    #[test]\n    fn monthly_resample() {\n        // Generate 90 days of data spanning ~3 months\n        let n = 90;\n        let mut capital = vec![100_000.0];\n        for i in 0..n {\n            capital.push(capital[i] * 1.001);\n        }\n        let base_ns: i64 = ymd_to_ns(2020, 1, 1);\n        let ts: Vec<i64> = (0..=n)\n            .map(|i| base_ns + i as i64 * 86_400_000_000_000)\n            .collect();\n\n        let monthly = resample_returns(&capital, &ts, ResampleFreq::Monthly);\n        assert!(monthly.len() >= 2); // At least 2 monthly returns from 3 months\n    }\n}\n"
  },
  {
    "path": "rust/ob_core/src/types.rs",
    "content": "/// Core domain types mirroring Python's options_portfolio_backtester.core.types.\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]\npub enum Direction {\n    Buy,\n    Sell,\n}\n\nimpl Direction {\n    #[inline]\n    pub fn sign(self) -> f64 {\n        match self {\n            Direction::Buy => -1.0,\n            Direction::Sell => 1.0,\n        }\n    }\n\n    #[inline]\n    pub fn price_column(self) -> &'static str {\n        match self {\n            Direction::Buy => \"ask\",\n            Direction::Sell => \"bid\",\n        }\n    }\n\n    #[inline]\n    pub fn invert(self) -> Direction {\n        match self {\n            Direction::Buy => Direction::Sell,\n            Direction::Sell => Direction::Buy,\n        }\n    }\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]\npub enum OptionType {\n    Call,\n    Put,\n}\n\nimpl OptionType {\n    pub fn as_str(self) -> &'static str {\n        match self {\n            OptionType::Call => \"call\",\n            OptionType::Put => \"put\",\n        }\n    }\n}\n\n/// Configuration for a single strategy leg.\n#[derive(Debug, Clone)]\npub struct LegConfig {\n    pub name: String,\n    pub option_type: OptionType,\n    pub direction: Direction,\n    pub entry_filter_query: Option<String>,\n    pub exit_filter_query: Option<String>,\n    pub entry_sort_col: Option<String>,\n    pub entry_sort_asc: bool,\n    /// Per-leg signal selector override (None = use engine-level selector).\n    pub signal_selector: Option<crate::signal_selector::SignalSelector>,\n    /// Per-leg fill model override (None = use engine-level fill model).\n    pub fill_model: Option<crate::fill_model::FillModel>,\n}\n\n/// Aggregated Greeks for a position or portfolio.\n#[derive(Debug, Clone, Copy, Default)]\npub struct Greeks {\n    pub delta: f64,\n    pub gamma: f64,\n    pub theta: f64,\n    pub vega: f64,\n}\n\nimpl Greeks {\n    pub fn new(delta: f64, gamma: f64, theta: f64, vega: f64) -> Self {\n        Self { delta, gamma, theta, vega }\n    }\n\n    pub fn scale(self, s: f64) -> Self {\n        Self {\n            delta: self.delta * s,\n            gamma: self.gamma * s,\n            theta: self.theta * s,\n            vega: self.vega * s,\n        }\n    }\n}\n\nimpl std::ops::Add for Greeks {\n    type Output = Self;\n    fn add(self, rhs: Self) -> Self {\n        Self {\n            delta: self.delta + rhs.delta,\n            gamma: self.gamma + rhs.gamma,\n            theta: self.theta + rhs.theta,\n            vega: self.vega + rhs.vega,\n        }\n    }\n}\n\nimpl std::ops::AddAssign for Greeks {\n    fn add_assign(&mut self, rhs: Self) {\n        self.delta += rhs.delta;\n        self.gamma += rhs.gamma;\n        self.theta += rhs.theta;\n        self.vega += rhs.vega;\n    }\n}\n\n/// Balance row for a single date.\n#[derive(Debug, Clone, Default)]\npub struct BalanceRow {\n    pub cash: f64,\n    pub options_qty: f64,\n    pub calls_capital: f64,\n    pub puts_capital: f64,\n    pub stocks_qty: f64,\n    pub stock_holdings: Vec<(String, f64)>,\n    pub stock_qtys: Vec<(String, f64)>,\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn direction_sign() {\n        assert_eq!(Direction::Buy.sign(), -1.0);\n        assert_eq!(Direction::Sell.sign(), 1.0);\n    }\n\n    #[test]\n    fn direction_invert() {\n        assert_eq!(Direction::Buy.invert(), Direction::Sell);\n        assert_eq!(Direction::Sell.invert(), Direction::Buy);\n    }\n\n    #[test]\n    fn greeks_add() {\n        let a = Greeks::new(1.0, 2.0, 3.0, 4.0);\n        let b = Greeks::new(0.5, 0.5, 0.5, 0.5);\n        let c = a + b;\n        assert!((c.delta - 1.5).abs() < 1e-10);\n        assert!((c.gamma - 2.5).abs() < 1e-10);\n    }\n\n    #[test]\n    fn greeks_scale() {\n        let g = Greeks::new(1.0, 2.0, 3.0, 4.0).scale(2.0);\n        assert!((g.delta - 2.0).abs() < 1e-10);\n        assert!((g.vega - 8.0).abs() < 1e-10);\n    }\n}\n"
  },
  {
    "path": "rust/ob_python/Cargo.toml",
    "content": "[package]\nname = \"ob_python\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[lib]\nname = \"_ob_rust\"\ncrate-type = [\"cdylib\"]\n\n[dependencies]\nob_core = { path = \"../ob_core\" }\npyo3 = { version = \"0.24\", features = [\"extension-module\"] }\npyo3-polars = \"0.21\"\npolars = { version = \"0.48\", features = [\"lazy\"] }\nnumpy = \"0.24\"\nrayon = \"1.10\"\n"
  },
  {
    "path": "rust/ob_python/src/arrow_bridge.rs",
    "content": "//! Arrow C Data Interface bridge: pyarrow <-> Polars zero-copy.\n//!\n//! Uses pyo3-polars for direct DataFrame conversions between\n//! Python (pandas/pyarrow) and Rust (Polars).\n\nuse pyo3_polars::PyDataFrame;\nuse polars::prelude::DataFrame;\n\n/// Convert a PyDataFrame (from Python) to a Polars DataFrame.\npub fn py_to_polars(py_df: PyDataFrame) -> DataFrame {\n    py_df.0\n}\n\n/// Convert a Polars DataFrame to a PyDataFrame (for Python).\npub fn polars_to_py(df: DataFrame) -> PyDataFrame {\n    PyDataFrame(df)\n}\n"
  },
  {
    "path": "rust/ob_python/src/lib.rs",
    "content": "use pyo3::prelude::*;\n\nmod arrow_bridge;\nmod py_balance;\nmod py_backtest;\nmod py_convexity;\nmod py_filter;\nmod py_entries;\nmod py_exits;\nmod py_stats;\nmod py_execution;\nmod py_sweep;\n\n#[pymodule]\nfn _ob_rust(m: &Bound<'_, PyModule>) -> PyResult<()> {\n    m.add_function(wrap_pyfunction!(py_balance::update_balance, m)?)?;\n    m.add_function(wrap_pyfunction!(py_backtest::run_backtest_py, m)?)?;\n    m.add_function(wrap_pyfunction!(py_backtest::run_multi_strategy_py, m)?)?;\n    m.add_function(wrap_pyfunction!(py_filter::compile_filter, m)?)?;\n    m.add_function(wrap_pyfunction!(py_filter::apply_filter, m)?)?;\n    m.add_function(wrap_pyfunction!(py_entries::compute_entries, m)?)?;\n    m.add_function(wrap_pyfunction!(py_exits::compute_exit_mask, m)?)?;\n    m.add_function(wrap_pyfunction!(py_stats::compute_stats, m)?)?;\n    m.add_function(wrap_pyfunction!(py_stats::compute_full_stats, m)?)?;\n    m.add_function(wrap_pyfunction!(py_sweep::parallel_sweep, m)?)?;\n    m.add_function(wrap_pyfunction!(py_convexity::compute_daily_scores, m)?)?;\n    m.add_function(wrap_pyfunction!(py_convexity::run_convexity_backtest, m)?)?;\n    m.add_function(wrap_pyfunction!(py_execution::rust_option_cost, m)?)?;\n    m.add_function(wrap_pyfunction!(py_execution::rust_stock_cost, m)?)?;\n    m.add_function(wrap_pyfunction!(py_execution::rust_fill_price, m)?)?;\n    m.add_function(wrap_pyfunction!(py_execution::rust_nearest_delta_index, m)?)?;\n    m.add_function(wrap_pyfunction!(py_execution::rust_max_value_index, m)?)?;\n    m.add_function(wrap_pyfunction!(py_execution::rust_risk_check, m)?)?;\n    m.add_class::<py_filter::CompiledFilter>()?;\n    Ok(())\n}\n"
  },
  {
    "path": "rust/ob_python/src/py_backtest.rs",
    "content": "//! PyO3 bindings for full backtest loop.\n\nuse pyo3::prelude::*;\nuse pyo3::types::{PyDict, PyList};\nuse pyo3_polars::PyDataFrame;\n\nuse ob_core::backtest::{run_backtest, run_multi_strategy, prepartition_data, BacktestConfig, StrategySlotConfig, SchemaMapping};\nuse ob_core::cost_model::CostModel;\nuse ob_core::fill_model::FillModel;\nuse ob_core::risk::RiskConstraint;\nuse ob_core::signal_selector::SignalSelector;\nuse ob_core::types::{Direction, LegConfig, OptionType};\n\nuse crate::arrow_bridge::{polars_to_py, py_to_polars};\n\n/// Parse schema dict -> SchemaMapping.\npub fn parse_schema(schema: &Bound<'_, PyDict>) -> PyResult<SchemaMapping> {\n    Ok(SchemaMapping {\n        contract: get_str(schema, \"contract\", \"optionroot\")?,\n        date: get_str(schema, \"date\", \"quotedate\")?,\n        stocks_date: get_str(schema, \"stocks_date\", \"date\")?,\n        stocks_sym: get_str(schema, \"stocks_symbol\", \"symbol\")?,\n        stocks_price: get_str(schema, \"stocks_price\", \"adjClose\")?,\n        underlying: get_str(schema, \"underlying\", \"underlying\")?,\n        expiration: get_str(schema, \"expiration\", \"expiration\")?,\n        option_type: get_str(schema, \"type\", \"type\")?,\n        strike: get_str(schema, \"strike\", \"strike\")?,\n    })\n}\n\n/// Parse a CostModel from a Python dict.\n///\n/// Expected formats:\n///   {\"type\": \"NoCosts\"}\n///   {\"type\": \"PerContract\", \"rate\": 0.65, \"stock_rate\": 0.005}\n///   {\"type\": \"Tiered\", \"tiers\": [[10000, 0.65], [50000, 0.50]], \"stock_rate\": 0.005}\npub fn parse_cost_model(d: &Bound<'_, PyDict>) -> PyResult<CostModel> {\n    let model_type = get_str(d, \"type\", \"NoCosts\")?;\n    match model_type.as_str() {\n        \"NoCosts\" => Ok(CostModel::NoCosts),\n        \"PerContract\" => {\n            let rate = get_f64(d, \"rate\", 0.65)?;\n            let stock_rate = get_f64(d, \"stock_rate\", 0.005)?;\n            Ok(CostModel::PerContract { rate, stock_rate })\n        }\n        \"Tiered\" => {\n            let tiers_raw: Vec<(i64, f64)> = d\n                .get_item(\"tiers\")?\n                .map(|v| v.extract::<Vec<(i64, f64)>>())\n                .transpose()?\n                .unwrap_or_default();\n            let stock_rate = get_f64(d, \"stock_rate\", 0.005)?;\n            Ok(CostModel::Tiered { tiers: tiers_raw, stock_rate })\n        }\n        other => Err(pyo3::exceptions::PyValueError::new_err(\n            format!(\"unknown cost model type: {other}\"),\n        )),\n    }\n}\n\n/// Parse a FillModel from a Python dict.\n///\n/// Expected formats:\n///   {\"type\": \"MarketAtBidAsk\"}\n///   {\"type\": \"MidPrice\"}\n///   {\"type\": \"VolumeAware\", \"full_volume_threshold\": 100}\npub fn parse_fill_model(d: &Bound<'_, PyDict>) -> PyResult<FillModel> {\n    let model_type = get_str(d, \"type\", \"MarketAtBidAsk\")?;\n    match model_type.as_str() {\n        \"MarketAtBidAsk\" => Ok(FillModel::MarketAtBidAsk),\n        \"MidPrice\" => Ok(FillModel::MidPrice),\n        \"VolumeAware\" => {\n            let threshold = get_i64(d, \"full_volume_threshold\", 100)?;\n            Ok(FillModel::VolumeAware { full_volume_threshold: threshold })\n        }\n        other => Err(pyo3::exceptions::PyValueError::new_err(\n            format!(\"unknown fill model type: {other}\"),\n        )),\n    }\n}\n\n/// Parse a SignalSelector from a Python dict.\n///\n/// Expected formats:\n///   {\"type\": \"FirstMatch\"}\n///   {\"type\": \"NearestDelta\", \"target\": -0.30, \"column\": \"delta\"}\n///   {\"type\": \"MaxOpenInterest\", \"column\": \"openinterest\"}\npub fn parse_signal_selector(d: &Bound<'_, PyDict>) -> PyResult<SignalSelector> {\n    let sel_type = get_str(d, \"type\", \"FirstMatch\")?;\n    match sel_type.as_str() {\n        \"FirstMatch\" => Ok(SignalSelector::FirstMatch),\n        \"NearestDelta\" => {\n            let target = get_f64(d, \"target\", -0.30)?;\n            let column = get_str(d, \"column\", \"delta\")?;\n            Ok(SignalSelector::NearestDelta { target, column })\n        }\n        \"MaxOpenInterest\" => {\n            let column = get_str(d, \"column\", \"openinterest\")?;\n            Ok(SignalSelector::MaxOpenInterest { column })\n        }\n        other => Err(pyo3::exceptions::PyValueError::new_err(\n            format!(\"unknown signal selector type: {other}\"),\n        )),\n    }\n}\n\n/// Parse a single RiskConstraint from a Python dict.\n///\n/// Expected formats:\n///   {\"type\": \"MaxDelta\", \"limit\": 100.0}\n///   {\"type\": \"MaxVega\", \"limit\": 50.0}\n///   {\"type\": \"MaxDrawdown\", \"max_dd_pct\": 0.20}\npub fn parse_risk_constraint(d: &Bound<'_, PyDict>) -> PyResult<RiskConstraint> {\n    let c_type = get_str(d, \"type\", \"\")?;\n    match c_type.as_str() {\n        \"MaxDelta\" => {\n            let limit = get_f64(d, \"limit\", 100.0)?;\n            Ok(RiskConstraint::MaxDelta { limit })\n        }\n        \"MaxVega\" => {\n            let limit = get_f64(d, \"limit\", 50.0)?;\n            Ok(RiskConstraint::MaxVega { limit })\n        }\n        \"MaxDrawdown\" => {\n            let max_dd_pct = get_f64(d, \"max_dd_pct\", 0.20)?;\n            Ok(RiskConstraint::MaxDrawdown { max_dd_pct })\n        }\n        other => Err(pyo3::exceptions::PyValueError::new_err(\n            format!(\"unknown risk constraint type: {other}\"),\n        )),\n    }\n}\n\n/// Parse config dict -> BacktestConfig.\npub fn parse_config_from_dict(config: &Bound<'_, PyDict>) -> PyResult<BacktestConfig> {\n    let alloc_obj = config\n        .get_item(\"allocation\")?\n        .ok_or_else(|| pyo3::exceptions::PyKeyError::new_err(\"allocation\"))?;\n    let alloc: &Bound<'_, PyDict> = alloc_obj\n        .downcast::<PyDict>()\n        .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?;\n\n    let alloc_stocks = get_f64(alloc, \"stocks\", 0.0)?;\n    let alloc_options = get_f64(alloc, \"options\", 0.0)?;\n    let alloc_cash = get_f64(alloc, \"cash\", 0.0)?;\n\n    let initial_capital = get_f64(config, \"initial_capital\", 1_000_000.0)?;\n    let spc = get_i64(config, \"shares_per_contract\", 100)?;\n\n    let profit_pct: Option<f64> = config\n        .get_item(\"profit_pct\")?\n        .and_then(|v| v.extract::<f64>().ok());\n    let loss_pct: Option<f64> = config\n        .get_item(\"loss_pct\")?\n        .and_then(|v| v.extract::<f64>().ok());\n\n    let rebalance_dates: Vec<i64> = config\n        .get_item(\"rebalance_dates\")?\n        .map(|v| v.extract::<Vec<i64>>())\n        .transpose()?\n        .unwrap_or_default();\n\n    let legs_list: Vec<Bound<'_, PyDict>> = config\n        .get_item(\"legs\")?\n        .ok_or_else(|| pyo3::exceptions::PyKeyError::new_err(\"legs\"))?\n        .extract::<Vec<Bound<'_, PyDict>>>()?;\n\n    let legs: Vec<LegConfig> = legs_list\n        .iter()\n        .map(|d| parse_leg_config(d))\n        .collect::<PyResult<Vec<_>>>()?;\n\n    let stocks_list: Vec<(String, f64)> = config\n        .get_item(\"stocks\")?\n        .map(|v| v.extract::<Vec<(String, f64)>>())\n        .transpose()?\n        .unwrap_or_default();\n\n    let stock_symbols: Vec<String> = stocks_list.iter().map(|(s, _)| s.clone()).collect();\n    let stock_percentages: Vec<f64> = stocks_list.iter().map(|(_, p)| *p).collect();\n\n    // Parse new execution model configs (optional — defaults to NoCosts/MarketAtBidAsk/FirstMatch/empty)\n    let cost_model = match config.get_item(\"cost_model\")? {\n        Some(v) if !v.is_none() => {\n            let d = v.downcast::<PyDict>()\n                .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?;\n            parse_cost_model(d)?\n        }\n        _ => CostModel::NoCosts,\n    };\n\n    let fill_model = match config.get_item(\"fill_model\")? {\n        Some(v) if !v.is_none() => {\n            let d = v.downcast::<PyDict>()\n                .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?;\n            parse_fill_model(d)?\n        }\n        _ => FillModel::MarketAtBidAsk,\n    };\n\n    let signal_selector = match config.get_item(\"signal_selector\")? {\n        Some(v) if !v.is_none() => {\n            let d = v.downcast::<PyDict>()\n                .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?;\n            parse_signal_selector(d)?\n        }\n        _ => SignalSelector::FirstMatch,\n    };\n\n    let risk_constraints: Vec<RiskConstraint> = match config.get_item(\"risk_constraints\")? {\n        Some(v) if !v.is_none() => {\n            let list = v.downcast::<PyList>()\n                .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?;\n            list.iter()\n                .map(|item| {\n                    let d = item.downcast::<PyDict>()\n                        .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?;\n                    parse_risk_constraint(d)\n                })\n                .collect::<PyResult<Vec<_>>>()?\n        }\n        _ => Vec::new(),\n    };\n\n    let sma_days: Option<usize> = config\n        .get_item(\"sma_days\")?\n        .and_then(|v| v.extract::<usize>().ok());\n\n    let options_budget_pct: Option<f64> = config\n        .get_item(\"options_budget_pct\")?\n        .and_then(|v| v.extract::<f64>().ok());\n\n    let options_budget_annual_pct: Option<f64> = config\n        .get_item(\"options_budget_annual_pct\")?\n        .and_then(|v| v.extract::<f64>().ok());\n\n    let stop_if_broke: bool = config\n        .get_item(\"stop_if_broke\")?\n        .map(|v| v.extract::<bool>())\n        .transpose()?\n        .unwrap_or(false);\n\n    let max_notional_pct: Option<f64> = config\n        .get_item(\"max_notional_pct\")?\n        .and_then(|v| v.extract::<f64>().ok());\n\n    let check_exits_daily: bool = config\n        .get_item(\"check_exits_daily\")?\n        .map(|v| v.extract::<bool>())\n        .transpose()?\n        .unwrap_or(false);\n\n    let options_budget_fresh_spend: bool = config\n        .get_item(\"options_budget_fresh_spend\")?\n        .map(|v| v.extract::<bool>())\n        .transpose()?\n        .unwrap_or(false);\n\n    let rebalance_stocks_on_exit: bool = config\n        .get_item(\"rebalance_stocks_on_exit\")?\n        .map(|v| v.extract::<bool>())\n        .transpose()?\n        .unwrap_or(false);\n\n    Ok(BacktestConfig {\n        allocation_stocks: alloc_stocks,\n        allocation_options: alloc_options,\n        allocation_cash: alloc_cash,\n        initial_capital,\n        shares_per_contract: spc,\n        legs,\n        profit_pct,\n        loss_pct,\n        stock_symbols,\n        stock_percentages,\n        rebalance_dates,\n        cost_model,\n        fill_model,\n        signal_selector,\n        risk_constraints,\n        sma_days,\n        options_budget_pct,\n        options_budget_annual_pct,\n        stop_if_broke,\n        max_notional_pct,\n        check_exits_daily,\n        options_budget_fresh_spend,\n        rebalance_stocks_on_exit,\n    })\n}\n\n/// Run a full backtest and return (balance_df, trade_log_df, stats_dict).\n#[pyfunction]\n#[pyo3(signature = (options_data, stocks_data, config, schema_mapping))]\npub fn run_backtest_py(\n    py: Python<'_>,\n    options_data: PyDataFrame,\n    stocks_data: PyDataFrame,\n    config: &Bound<'_, PyDict>,\n    schema_mapping: &Bound<'_, PyDict>,\n) -> PyResult<PyObject> {\n    let opts = py_to_polars(options_data);\n    let stocks = py_to_polars(stocks_data);\n\n    let schema = parse_schema(schema_mapping)?;\n    let bt_config = parse_config_from_dict(config)?;\n\n    let result = run_backtest(&bt_config, &opts, &stocks, &schema)\n        .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;\n\n    // Build result tuple\n    let balance_py = polars_to_py(result.balance);\n    let trade_log_py = polars_to_py(result.trade_log);\n\n    let stats_dict = PyDict::new(py);\n    stats_dict.set_item(\"total_return\", result.stats.total_return)?;\n    stats_dict.set_item(\"annualized_return\", result.stats.annualized_return)?;\n    stats_dict.set_item(\"sharpe_ratio\", result.stats.sharpe_ratio)?;\n    stats_dict.set_item(\"sortino_ratio\", result.stats.sortino_ratio)?;\n    stats_dict.set_item(\"calmar_ratio\", result.stats.calmar_ratio)?;\n    stats_dict.set_item(\"max_drawdown\", result.stats.max_drawdown)?;\n    stats_dict.set_item(\"max_drawdown_duration\", result.stats.max_drawdown_duration)?;\n    stats_dict.set_item(\"profit_factor\", result.stats.profit_factor)?;\n    stats_dict.set_item(\"win_rate\", result.stats.win_rate)?;\n    stats_dict.set_item(\"total_trades\", result.stats.total_trades)?;\n    stats_dict.set_item(\"final_cash\", result.final_cash)?;\n\n    let result_tuple = pyo3::types::PyTuple::new(py, [\n        balance_py.into_pyobject(py)?.into_any(),\n        trade_log_py.into_pyobject(py)?.into_any(),\n        stats_dict.into_any(),\n    ])?;\n\n    Ok(result_tuple.into())\n}\n\npub fn parse_leg_config(d: &Bound<'_, PyDict>) -> PyResult<LegConfig> {\n    let name = get_str(d, \"name\", \"\")?;\n    let direction_str = get_str(d, \"direction\", \"ask\")?;\n    let type_str = get_str(d, \"type\", \"call\")?;\n    let entry_filter: Option<String> = d.get_item(\"entry_filter\")?.and_then(|v| v.extract().ok());\n    let exit_filter: Option<String> = d.get_item(\"exit_filter\")?.and_then(|v| v.extract().ok());\n    let entry_sort_col: Option<String> = d.get_item(\"entry_sort_col\")?.and_then(|v| v.extract().ok());\n    let entry_sort_asc: bool = d.get_item(\"entry_sort_asc\")?\n        .map(|v| v.extract::<bool>()).transpose()?.unwrap_or(true);\n\n    // Per-leg overrides (optional)\n    let signal_selector = match d.get_item(\"signal_selector\")? {\n        Some(v) if !v.is_none() => {\n            let sd = v.downcast::<PyDict>()\n                .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?;\n            Some(parse_signal_selector(sd)?)\n        }\n        _ => None,\n    };\n    let fill_model = match d.get_item(\"fill_model\")? {\n        Some(v) if !v.is_none() => {\n            let fd = v.downcast::<PyDict>()\n                .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?;\n            Some(parse_fill_model(fd)?)\n        }\n        _ => None,\n    };\n\n    Ok(LegConfig {\n        name,\n        option_type: if type_str == \"put\" { OptionType::Put } else { OptionType::Call },\n        direction: if direction_str == \"bid\" { Direction::Sell } else { Direction::Buy },\n        entry_filter_query: entry_filter,\n        exit_filter_query: exit_filter,\n        entry_sort_col,\n        entry_sort_asc,\n        signal_selector,\n        fill_model,\n    })\n}\n\n/// Parse a StrategySlotConfig from a Python dict.\nfn parse_slot_config(d: &Bound<'_, PyDict>) -> PyResult<StrategySlotConfig> {\n    let name = get_str(d, \"name\", \"\")?;\n    let weight = get_f64(d, \"weight\", 1.0)?;\n\n    let legs_list: Vec<Bound<'_, PyDict>> = d\n        .get_item(\"legs\")?\n        .ok_or_else(|| pyo3::exceptions::PyKeyError::new_err(\"legs\"))?\n        .extract::<Vec<Bound<'_, PyDict>>>()?;\n    let legs: Vec<ob_core::types::LegConfig> = legs_list\n        .iter()\n        .map(|ld| parse_leg_config(ld))\n        .collect::<PyResult<Vec<_>>>()?;\n\n    let rebalance_dates: Vec<i64> = d\n        .get_item(\"rebalance_dates\")?\n        .map(|v| v.extract::<Vec<i64>>())\n        .transpose()?\n        .unwrap_or_default();\n\n    let profit_pct: Option<f64> = d\n        .get_item(\"profit_pct\")?\n        .and_then(|v| v.extract::<f64>().ok());\n    let loss_pct: Option<f64> = d\n        .get_item(\"loss_pct\")?\n        .and_then(|v| v.extract::<f64>().ok());\n\n    let check_exits_daily: bool = d\n        .get_item(\"check_exits_daily\")?\n        .map(|v| v.extract::<bool>())\n        .transpose()?\n        .unwrap_or(false);\n\n    Ok(StrategySlotConfig {\n        name,\n        legs,\n        weight,\n        rebalance_dates,\n        profit_pct,\n        loss_pct,\n        check_exits_daily,\n    })\n}\n\n/// Run a multi-strategy backtest and return (balance_df, trade_log_df, stats_dict).\n#[pyfunction]\n#[pyo3(signature = (options_data, stocks_data, config, schema_mapping, slots))]\npub fn run_multi_strategy_py(\n    py: Python<'_>,\n    options_data: PyDataFrame,\n    stocks_data: PyDataFrame,\n    config: &Bound<'_, PyDict>,\n    schema_mapping: &Bound<'_, PyDict>,\n    slots: &Bound<'_, PyList>,\n) -> PyResult<PyObject> {\n    let opts = py_to_polars(options_data);\n    let stocks = py_to_polars(stocks_data);\n\n    let schema = parse_schema(schema_mapping)?;\n    let bt_config = parse_config_from_dict(config)?;\n\n    let slot_configs: Vec<StrategySlotConfig> = slots\n        .iter()\n        .map(|item| {\n            let d = item.downcast::<PyDict>()\n                .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?;\n            parse_slot_config(d)\n        })\n        .collect::<PyResult<Vec<_>>>()?;\n\n    let partitioned = prepartition_data(&opts, &stocks, &schema)\n        .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;\n\n    let result = run_multi_strategy(&bt_config, &slot_configs, &partitioned, &schema)\n        .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;\n\n    // Build result tuple (same format as run_backtest_py)\n    let balance_py = polars_to_py(result.balance);\n    let trade_log_py = polars_to_py(result.trade_log);\n\n    let stats_dict = PyDict::new(py);\n    stats_dict.set_item(\"total_return\", result.stats.total_return)?;\n    stats_dict.set_item(\"annualized_return\", result.stats.annualized_return)?;\n    stats_dict.set_item(\"sharpe_ratio\", result.stats.sharpe_ratio)?;\n    stats_dict.set_item(\"sortino_ratio\", result.stats.sortino_ratio)?;\n    stats_dict.set_item(\"calmar_ratio\", result.stats.calmar_ratio)?;\n    stats_dict.set_item(\"max_drawdown\", result.stats.max_drawdown)?;\n    stats_dict.set_item(\"max_drawdown_duration\", result.stats.max_drawdown_duration)?;\n    stats_dict.set_item(\"profit_factor\", result.stats.profit_factor)?;\n    stats_dict.set_item(\"win_rate\", result.stats.win_rate)?;\n    stats_dict.set_item(\"total_trades\", result.stats.total_trades)?;\n    stats_dict.set_item(\"final_cash\", result.final_cash)?;\n\n    let result_tuple = pyo3::types::PyTuple::new(py, [\n        balance_py.into_pyobject(py)?.into_any(),\n        trade_log_py.into_pyobject(py)?.into_any(),\n        stats_dict.into_any(),\n    ])?;\n\n    Ok(result_tuple.into())\n}\n\n// Helper extractors\npub fn get_str(d: &Bound<'_, PyDict>, key: &str, default: &str) -> PyResult<String> {\n    Ok(d.get_item(key)?.map(|v| v.extract::<String>()).transpose()?.unwrap_or_else(|| default.into()))\n}\npub fn get_f64(d: &Bound<'_, PyDict>, key: &str, default: f64) -> PyResult<f64> {\n    Ok(d.get_item(key)?.map(|v| v.extract::<f64>()).transpose()?.unwrap_or(default))\n}\npub fn get_i64(d: &Bound<'_, PyDict>, key: &str, default: i64) -> PyResult<i64> {\n    Ok(d.get_item(key)?.map(|v| v.extract::<i64>()).transpose()?.unwrap_or(default))\n}\n"
  },
  {
    "path": "rust/ob_python/src/py_balance.rs",
    "content": "//! PyO3 bindings for balance update.\n\nuse pyo3::prelude::*;\nuse pyo3_polars::PyDataFrame;\n\nuse ob_core::balance::{compute_balance, LegInventory, StockInventory};\nuse ob_core::types::Direction;\n\nuse crate::arrow_bridge::{polars_to_py, py_to_polars};\n\n#[pyfunction]\n#[pyo3(signature = (\n    leg_contracts, leg_qtys, leg_types, leg_directions,\n    leg_underlyings, leg_strikes,\n    stock_symbols, stock_qtys,\n    options_data, stocks_data,\n    contract_col, date_col,\n    stocks_date_col, stocks_sym_col, stocks_price_col,\n    shares_per_contract, cash,\n))]\npub fn update_balance(\n    leg_contracts: Vec<Vec<String>>,\n    leg_qtys: Vec<Vec<f64>>,\n    leg_types: Vec<Vec<String>>,\n    leg_directions: Vec<String>,\n    leg_underlyings: Vec<Vec<String>>,\n    leg_strikes: Vec<Vec<f64>>,\n    stock_symbols: Vec<String>,\n    stock_qtys: Vec<f64>,\n    options_data: PyDataFrame,\n    stocks_data: PyDataFrame,\n    contract_col: &str,\n    date_col: &str,\n    stocks_date_col: &str,\n    stocks_sym_col: &str,\n    stocks_price_col: &str,\n    shares_per_contract: i64,\n    cash: f64,\n) -> PyResult<PyDataFrame> {\n    let opts_df = py_to_polars(options_data);\n    let stocks_df = py_to_polars(stocks_data);\n\n    let legs: Vec<LegInventory> = leg_contracts\n        .into_iter()\n        .zip(leg_qtys)\n        .zip(leg_types)\n        .zip(leg_directions)\n        .zip(leg_underlyings)\n        .zip(leg_strikes)\n        .map(|(((((contracts, qtys), types), dir), underlyings), strikes)| LegInventory {\n            contracts,\n            qtys,\n            types,\n            direction: if dir == \"buy\" { Direction::Buy } else { Direction::Sell },\n            underlyings,\n            strikes,\n        })\n        .collect();\n\n    let stocks = StockInventory {\n        symbols: stock_symbols,\n        qtys: stock_qtys,\n    };\n\n    let result = compute_balance(\n        &legs,\n        &stocks,\n        &opts_df,\n        &stocks_df,\n        contract_col,\n        date_col,\n        stocks_date_col,\n        stocks_sym_col,\n        stocks_price_col,\n        shares_per_contract,\n        cash,\n    )\n    .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;\n\n    Ok(polars_to_py(result))\n}\n"
  },
  {
    "path": "rust/ob_python/src/py_convexity.rs",
    "content": "use numpy::PyReadonlyArray1;\nuse pyo3::prelude::*;\nuse pyo3::types::PyDict;\n\nuse ob_core::convexity_scoring;\nuse ob_core::convexity_backtest;\n\n#[pyfunction]\n#[pyo3(signature = (\n    dates_ns, strikes, bids, asks, deltas, underlying_prices, dtes, implied_vols,\n    target_delta, dte_min, dte_max, tail_drop\n))]\npub fn compute_daily_scores<'py>(\n    py: Python<'py>,\n    dates_ns: PyReadonlyArray1<'py, i64>,\n    strikes: PyReadonlyArray1<'py, f64>,\n    bids: PyReadonlyArray1<'py, f64>,\n    asks: PyReadonlyArray1<'py, f64>,\n    deltas: PyReadonlyArray1<'py, f64>,\n    underlying_prices: PyReadonlyArray1<'py, f64>,\n    dtes: PyReadonlyArray1<'py, i32>,\n    implied_vols: PyReadonlyArray1<'py, f64>,\n    target_delta: f64,\n    dte_min: i32,\n    dte_max: i32,\n    tail_drop: f64,\n) -> PyResult<Bound<'py, PyDict>> {\n    let scores = convexity_scoring::compute_daily_scores(\n        dates_ns.as_slice()?,\n        strikes.as_slice()?,\n        bids.as_slice()?,\n        asks.as_slice()?,\n        deltas.as_slice()?,\n        underlying_prices.as_slice()?,\n        dtes.as_slice()?,\n        implied_vols.as_slice()?,\n        target_delta,\n        dte_min,\n        dte_max,\n        tail_drop,\n    );\n\n    let dict = PyDict::new(py);\n    dict.set_item(\"dates_ns\", scores.iter().map(|s| s.date_ns).collect::<Vec<_>>())?;\n    dict.set_item(\"convexity_ratios\", scores.iter().map(|s| s.convexity_ratio).collect::<Vec<_>>())?;\n    dict.set_item(\"strikes\", scores.iter().map(|s| s.strike).collect::<Vec<_>>())?;\n    dict.set_item(\"asks\", scores.iter().map(|s| s.ask).collect::<Vec<_>>())?;\n    dict.set_item(\"bids\", scores.iter().map(|s| s.bid).collect::<Vec<_>>())?;\n    dict.set_item(\"deltas\", scores.iter().map(|s| s.delta).collect::<Vec<_>>())?;\n    dict.set_item(\"underlying_prices\", scores.iter().map(|s| s.underlying_price).collect::<Vec<_>>())?;\n    dict.set_item(\"implied_vols\", scores.iter().map(|s| s.implied_vol).collect::<Vec<_>>())?;\n    dict.set_item(\"dtes\", scores.iter().map(|s| s.dte).collect::<Vec<_>>())?;\n    dict.set_item(\"annual_costs\", scores.iter().map(|s| s.annual_cost).collect::<Vec<_>>())?;\n    dict.set_item(\"tail_payoffs\", scores.iter().map(|s| s.tail_payoff).collect::<Vec<_>>())?;\n\n    Ok(dict)\n}\n\n#[pyfunction]\n#[pyo3(signature = (\n    put_dates_ns, put_expirations_ns, put_strikes, put_bids, put_asks,\n    put_deltas, put_underlying, put_dtes, put_ivs,\n    stock_dates_ns, stock_prices,\n    initial_capital, budget_pct, target_delta, dte_min, dte_max, tail_drop\n))]\n#[allow(clippy::too_many_arguments)]\npub fn run_convexity_backtest<'py>(\n    py: Python<'py>,\n    put_dates_ns: PyReadonlyArray1<'py, i64>,\n    put_expirations_ns: PyReadonlyArray1<'py, i64>,\n    put_strikes: PyReadonlyArray1<'py, f64>,\n    put_bids: PyReadonlyArray1<'py, f64>,\n    put_asks: PyReadonlyArray1<'py, f64>,\n    put_deltas: PyReadonlyArray1<'py, f64>,\n    put_underlying: PyReadonlyArray1<'py, f64>,\n    put_dtes: PyReadonlyArray1<'py, i32>,\n    put_ivs: PyReadonlyArray1<'py, f64>,\n    stock_dates_ns: PyReadonlyArray1<'py, i64>,\n    stock_prices: PyReadonlyArray1<'py, f64>,\n    initial_capital: f64,\n    budget_pct: f64,\n    target_delta: f64,\n    dte_min: i32,\n    dte_max: i32,\n    tail_drop: f64,\n) -> PyResult<Bound<'py, PyDict>> {\n    let result = convexity_backtest::run_backtest(\n        put_dates_ns.as_slice()?,\n        put_expirations_ns.as_slice()?,\n        put_strikes.as_slice()?,\n        put_bids.as_slice()?,\n        put_asks.as_slice()?,\n        put_deltas.as_slice()?,\n        put_underlying.as_slice()?,\n        put_dtes.as_slice()?,\n        put_ivs.as_slice()?,\n        stock_dates_ns.as_slice()?,\n        stock_prices.as_slice()?,\n        initial_capital,\n        budget_pct,\n        target_delta,\n        dte_min,\n        dte_max,\n        tail_drop,\n    );\n\n    let dict = PyDict::new(py);\n\n    // Monthly records\n    let records = PyDict::new(py);\n    records.set_item(\"dates_ns\", result.records.iter().map(|r| r.date_ns).collect::<Vec<_>>())?;\n    records.set_item(\"shares\", result.records.iter().map(|r| r.shares).collect::<Vec<_>>())?;\n    records.set_item(\"stock_prices\", result.records.iter().map(|r| r.stock_price).collect::<Vec<_>>())?;\n    records.set_item(\"equity_values\", result.records.iter().map(|r| r.equity_value).collect::<Vec<_>>())?;\n    records.set_item(\"put_costs\", result.records.iter().map(|r| r.put_cost).collect::<Vec<_>>())?;\n    records.set_item(\"put_exit_values\", result.records.iter().map(|r| r.put_exit_value).collect::<Vec<_>>())?;\n    records.set_item(\"put_pnls\", result.records.iter().map(|r| r.put_pnl).collect::<Vec<_>>())?;\n    records.set_item(\"portfolio_values\", result.records.iter().map(|r| r.portfolio_value).collect::<Vec<_>>())?;\n    records.set_item(\"convexity_ratios\", result.records.iter().map(|r| r.convexity_ratio).collect::<Vec<_>>())?;\n    records.set_item(\"strikes\", result.records.iter().map(|r| r.strike).collect::<Vec<_>>())?;\n    records.set_item(\"contracts\", result.records.iter().map(|r| r.contracts).collect::<Vec<_>>())?;\n    dict.set_item(\"records\", records)?;\n\n    // Daily balance series\n    dict.set_item(\"daily_dates_ns\", result.daily_dates_ns)?;\n    dict.set_item(\"daily_balances\", result.daily_balances)?;\n\n    Ok(dict)\n}\n"
  },
  {
    "path": "rust/ob_python/src/py_entries.rs",
    "content": "//! PyO3 bindings for entry signal computation.\n\nuse pyo3::prelude::*;\nuse pyo3_polars::PyDataFrame;\n\nuse ob_core::entries;\nuse ob_core::filter;\n\nuse crate::arrow_bridge::{polars_to_py, py_to_polars};\n\n#[pyfunction]\n#[pyo3(signature = (\n    options_data,\n    inventory_contracts,\n    entry_filter_query,\n    contract_col,\n    cost_field,\n    entry_sort_col,\n    entry_sort_asc,\n    shares_per_contract,\n    is_sell,\n))]\npub fn compute_entries(\n    options_data: PyDataFrame,\n    inventory_contracts: Vec<String>,\n    entry_filter_query: &str,\n    contract_col: &str,\n    cost_field: &str,\n    entry_sort_col: Option<&str>,\n    entry_sort_asc: bool,\n    shares_per_contract: i64,\n    is_sell: bool,\n) -> PyResult<PyDataFrame> {\n    let opts = py_to_polars(options_data);\n    let compiled = filter::CompiledFilter::new(entry_filter_query)\n        .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;\n\n    let result = entries::compute_leg_entries(\n        &opts,\n        &inventory_contracts,\n        &compiled,\n        contract_col,\n        cost_field,\n        entry_sort_col,\n        entry_sort_asc,\n        shares_per_contract,\n        is_sell,\n        &[],\n    )\n    .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;\n\n    Ok(polars_to_py(result))\n}\n"
  },
  {
    "path": "rust/ob_python/src/py_execution.rs",
    "content": "//! PyO3 bindings for execution models: cost, fill, signal selection, risk.\n//!\n//! Exposes flat functions that call into `ob_core` implementations,\n//! allowing Python classes to delegate computation to Rust.\n\nuse pyo3::prelude::*;\n\nuse ob_core::cost_model::CostModel;\nuse ob_core::fill_model::FillModel;\nuse ob_core::risk::RiskConstraint;\nuse ob_core::types::Greeks;\n\n// ---------------------------------------------------------------------------\n// Cost models\n// ---------------------------------------------------------------------------\n\n/// Compute option trade commission via Rust cost model.\n///\n/// `model_type`: \"PerContract\" or \"Tiered\"\n/// `tiers`: list of (max_contracts, rate) pairs (only used for Tiered)\n#[pyfunction]\n#[pyo3(signature = (model_type, rate, stock_rate, tiers, price, quantity, spc))]\npub fn rust_option_cost(\n    model_type: &str,\n    rate: f64,\n    stock_rate: f64,\n    tiers: Vec<(i64, f64)>,\n    price: f64,\n    quantity: f64,\n    spc: i64,\n) -> PyResult<f64> {\n    let model = match model_type {\n        \"PerContract\" => CostModel::PerContract { rate, stock_rate },\n        \"Tiered\" => CostModel::Tiered { tiers, stock_rate },\n        other => {\n            return Err(pyo3::exceptions::PyValueError::new_err(\n                format!(\"Unknown cost model type: {other}\"),\n            ))\n        }\n    };\n    Ok(model.option_cost(price, quantity, spc))\n}\n\n/// Compute stock trade commission via Rust cost model.\n#[pyfunction]\n#[pyo3(signature = (model_type, rate, stock_rate, tiers, price, quantity))]\npub fn rust_stock_cost(\n    model_type: &str,\n    rate: f64,\n    stock_rate: f64,\n    tiers: Vec<(i64, f64)>,\n    price: f64,\n    quantity: f64,\n) -> PyResult<f64> {\n    let model = match model_type {\n        \"PerContract\" => CostModel::PerContract { rate, stock_rate },\n        \"Tiered\" => CostModel::Tiered { tiers, stock_rate },\n        other => {\n            return Err(pyo3::exceptions::PyValueError::new_err(\n                format!(\"Unknown cost model type: {other}\"),\n            ))\n        }\n    };\n    Ok(model.stock_cost(price, quantity))\n}\n\n// ---------------------------------------------------------------------------\n// Fill models\n// ---------------------------------------------------------------------------\n\n/// Compute fill price via Rust fill model.\n///\n/// `model_type`: \"VolumeAware\"\n/// `threshold`: full_volume_threshold (only used for VolumeAware)\n/// `volume`: None means missing volume data\n#[pyfunction]\n#[pyo3(signature = (model_type, threshold, bid, ask, volume, is_buy))]\npub fn rust_fill_price(\n    model_type: &str,\n    threshold: i64,\n    bid: f64,\n    ask: f64,\n    volume: Option<f64>,\n    is_buy: bool,\n) -> PyResult<f64> {\n    let model = match model_type {\n        \"VolumeAware\" => FillModel::VolumeAware {\n            full_volume_threshold: threshold,\n        },\n        other => {\n            return Err(pyo3::exceptions::PyValueError::new_err(\n                format!(\"Unknown fill model type: {other}\"),\n            ))\n        }\n    };\n    Ok(model.fill_price(bid, ask, volume, is_buy))\n}\n\n// ---------------------------------------------------------------------------\n// Signal selectors\n// ---------------------------------------------------------------------------\n\n/// Find the index of the value nearest to `target` in a list of f64.\n/// NaN values are skipped. Returns 0 for empty input.\n#[pyfunction]\n#[pyo3(signature = (values, target))]\npub fn rust_nearest_delta_index(values: Vec<f64>, target: f64) -> usize {\n    if values.is_empty() {\n        return 0;\n    }\n    let mut best_idx = 0;\n    let mut best_diff = f64::MAX;\n    for (i, &v) in values.iter().enumerate() {\n        if v.is_nan() {\n            continue;\n        }\n        let diff = (v - target).abs();\n        if diff < best_diff {\n            best_diff = diff;\n            best_idx = i;\n        }\n    }\n    best_idx\n}\n\n/// Find the index of the maximum value in a list of f64.\n/// NaN values are skipped. Returns 0 for empty input.\n#[pyfunction]\n#[pyo3(signature = (values,))]\npub fn rust_max_value_index(values: Vec<f64>) -> usize {\n    if values.is_empty() {\n        return 0;\n    }\n    let mut best_idx = 0;\n    let mut best_val = f64::MIN;\n    for (i, &v) in values.iter().enumerate() {\n        if v.is_nan() {\n            continue;\n        }\n        if v > best_val {\n            best_val = v;\n            best_idx = i;\n        }\n    }\n    best_idx\n}\n\n// ---------------------------------------------------------------------------\n// Risk constraints\n// ---------------------------------------------------------------------------\n\n/// Check a single risk constraint via Rust.\n///\n/// `constraint_type`: \"MaxDelta\", \"MaxVega\", or \"MaxDrawdown\"\n/// `limit`: the constraint limit (delta/vega limit, or max_dd_pct)\n/// `current_greeks`: [delta, gamma, theta, vega]\n/// `proposed_greeks`: [delta, gamma, theta, vega]\n#[pyfunction]\n#[pyo3(signature = (constraint_type, limit, current_greeks, proposed_greeks, portfolio_value, peak_value))]\npub fn rust_risk_check(\n    constraint_type: &str,\n    limit: f64,\n    current_greeks: [f64; 4],\n    proposed_greeks: [f64; 4],\n    portfolio_value: f64,\n    peak_value: f64,\n) -> PyResult<bool> {\n    let constraint = match constraint_type {\n        \"MaxDelta\" => RiskConstraint::MaxDelta { limit },\n        \"MaxVega\" => RiskConstraint::MaxVega { limit },\n        \"MaxDrawdown\" => RiskConstraint::MaxDrawdown { max_dd_pct: limit },\n        other => {\n            return Err(pyo3::exceptions::PyValueError::new_err(\n                format!(\"Unknown risk constraint type: {other}\"),\n            ))\n        }\n    };\n    let current = Greeks::new(\n        current_greeks[0],\n        current_greeks[1],\n        current_greeks[2],\n        current_greeks[3],\n    );\n    let proposed = Greeks::new(\n        proposed_greeks[0],\n        proposed_greeks[1],\n        proposed_greeks[2],\n        proposed_greeks[3],\n    );\n    Ok(constraint.check(&current, &proposed, portfolio_value, peak_value))\n}\n"
  },
  {
    "path": "rust/ob_python/src/py_exits.rs",
    "content": "//! PyO3 bindings for exit mask computation.\n\nuse pyo3::prelude::*;\nuse polars::prelude::{NamedFrom, Series};\n\nuse ob_core::exits;\n\n/// Compute threshold exit mask from entry and current costs.\n#[pyfunction]\n#[pyo3(signature = (entry_costs, current_costs, profit_pct = None, loss_pct = None))]\npub fn compute_exit_mask(\n    entry_costs: Vec<f64>,\n    current_costs: Vec<f64>,\n    profit_pct: Option<f64>,\n    loss_pct: Option<f64>,\n) -> PyResult<Vec<bool>> {\n    let entry_series = Series::new(\"entry\".into(), &entry_costs);\n    let current_series = Series::new(\"current\".into(), &current_costs);\n\n    let mask = exits::threshold_exit_mask(&entry_series, &current_series, profit_pct, loss_pct)\n        .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;\n\n    Ok(mask.into_no_null_iter().collect())\n}\n"
  },
  {
    "path": "rust/ob_python/src/py_filter.rs",
    "content": "//! PyO3 bindings for filter compilation and evaluation.\n\nuse pyo3::prelude::*;\nuse pyo3_polars::PyDataFrame;\n\nuse ob_core::filter;\n\nuse crate::arrow_bridge::{polars_to_py, py_to_polars};\n\n/// Compiled filter that can be reused across multiple evaluations.\n#[pyclass]\npub struct CompiledFilter {\n    inner: filter::CompiledFilter,\n}\n\n#[pymethods]\nimpl CompiledFilter {\n    #[new]\n    fn new(query: &str) -> PyResult<Self> {\n        let inner = filter::CompiledFilter::new(query)\n            .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;\n        Ok(Self { inner })\n    }\n\n    fn apply(&self, data: PyDataFrame) -> PyResult<PyDataFrame> {\n        let df = py_to_polars(data);\n        let result = self\n            .inner\n            .apply(&df)\n            .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;\n        Ok(polars_to_py(result))\n    }\n\n    fn __repr__(&self) -> String {\n        format!(\"CompiledFilter({:?})\", self.inner.expr)\n    }\n}\n\n/// Compile a filter query string and return a CompiledFilter.\n#[pyfunction]\npub fn compile_filter(query: &str) -> PyResult<CompiledFilter> {\n    CompiledFilter::new(query)\n}\n\n/// One-shot: compile and apply a filter in one call.\n#[pyfunction]\npub fn apply_filter(query: &str, data: PyDataFrame) -> PyResult<PyDataFrame> {\n    let f = filter::CompiledFilter::new(query)\n        .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;\n    let df = py_to_polars(data);\n    let result = f\n        .apply(&df)\n        .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;\n    Ok(polars_to_py(result))\n}\n"
  },
  {
    "path": "rust/ob_python/src/py_stats.rs",
    "content": "//! PyO3 bindings for stats computation.\n\nuse pyo3::prelude::*;\n\nuse ob_core::stats;\n\n/// Compute backtest statistics from daily returns and trade PnLs (legacy).\n#[pyfunction]\n#[pyo3(signature = (daily_returns, trade_pnls, risk_free_rate = 0.0))]\npub fn compute_stats(\n    daily_returns: Vec<f64>,\n    trade_pnls: Vec<f64>,\n    risk_free_rate: f64,\n) -> PyResult<PyObject> {\n    let s = stats::compute_stats(&daily_returns, &trade_pnls, risk_free_rate);\n\n    Python::with_gil(|py| {\n        let dict = pyo3::types::PyDict::new(py);\n        dict.set_item(\"total_return\", s.total_return)?;\n        dict.set_item(\"annualized_return\", s.annualized_return)?;\n        dict.set_item(\"sharpe_ratio\", s.sharpe_ratio)?;\n        dict.set_item(\"sortino_ratio\", s.sortino_ratio)?;\n        dict.set_item(\"calmar_ratio\", s.calmar_ratio)?;\n        dict.set_item(\"max_drawdown\", s.max_drawdown)?;\n        dict.set_item(\"max_drawdown_duration\", s.max_drawdown_duration)?;\n        dict.set_item(\"profit_factor\", s.profit_factor)?;\n        dict.set_item(\"win_rate\", s.win_rate)?;\n        dict.set_item(\"total_trades\", s.total_trades)?;\n        Ok(dict.into())\n    })\n}\n\n/// Compute comprehensive backtest statistics from total capital series.\n///\n/// Args:\n///     total_capital: list of daily total capital values\n///     timestamps_ns: list of nanosecond timestamps (one per capital value)\n///     trade_pnls: list of per-trade P&L values\n///     stock_weights: flattened [n_days × n_stocks] weight matrix (row-major)\n///     n_stocks: number of stock columns\n///     risk_free_rate: annualized risk-free rate (default 0.0)\n#[pyfunction]\n#[pyo3(signature = (total_capital, timestamps_ns, trade_pnls, stock_weights, n_stocks, risk_free_rate = 0.0))]\npub fn compute_full_stats(\n    total_capital: Vec<f64>,\n    timestamps_ns: Vec<i64>,\n    trade_pnls: Vec<f64>,\n    stock_weights: Vec<f64>,\n    n_stocks: usize,\n    risk_free_rate: f64,\n) -> PyResult<PyObject> {\n    let fs = stats::compute_full_stats(\n        &total_capital,\n        &timestamps_ns,\n        &trade_pnls,\n        &stock_weights,\n        n_stocks,\n        risk_free_rate,\n    );\n\n    Python::with_gil(|py| {\n        let dict = pyo3::types::PyDict::new(py);\n\n        // Trade stats\n        dict.set_item(\"total_trades\", fs.total_trades)?;\n        dict.set_item(\"wins\", fs.wins)?;\n        dict.set_item(\"losses\", fs.losses)?;\n        dict.set_item(\"win_pct\", fs.win_pct)?;\n        dict.set_item(\"profit_factor\", fs.profit_factor)?;\n        dict.set_item(\"largest_win\", fs.largest_win)?;\n        dict.set_item(\"largest_loss\", fs.largest_loss)?;\n        dict.set_item(\"avg_win\", fs.avg_win)?;\n        dict.set_item(\"avg_loss\", fs.avg_loss)?;\n        dict.set_item(\"avg_trade\", fs.avg_trade)?;\n\n        // Return stats\n        dict.set_item(\"total_return\", fs.total_return)?;\n        dict.set_item(\"annualized_return\", fs.annualized_return)?;\n        dict.set_item(\"sharpe_ratio\", fs.sharpe_ratio)?;\n        dict.set_item(\"sortino_ratio\", fs.sortino_ratio)?;\n        dict.set_item(\"calmar_ratio\", fs.calmar_ratio)?;\n\n        // Risk stats\n        dict.set_item(\"max_drawdown\", fs.max_drawdown)?;\n        dict.set_item(\"max_drawdown_duration\", fs.max_drawdown_duration)?;\n        dict.set_item(\"avg_drawdown\", fs.avg_drawdown)?;\n        dict.set_item(\"avg_drawdown_duration\", fs.avg_drawdown_duration)?;\n        dict.set_item(\"volatility\", fs.volatility)?;\n        dict.set_item(\"tail_ratio\", fs.tail_ratio)?;\n\n        // Daily period stats\n        let daily = pyo3::types::PyDict::new(py);\n        daily.set_item(\"mean\", fs.daily.mean)?;\n        daily.set_item(\"vol\", fs.daily.vol)?;\n        daily.set_item(\"sharpe\", fs.daily.sharpe)?;\n        daily.set_item(\"sortino\", fs.daily.sortino)?;\n        daily.set_item(\"skew\", fs.daily.skew)?;\n        daily.set_item(\"kurtosis\", fs.daily.kurtosis)?;\n        daily.set_item(\"best\", fs.daily.best)?;\n        daily.set_item(\"worst\", fs.daily.worst)?;\n        dict.set_item(\"daily\", daily)?;\n\n        // Monthly period stats\n        let monthly = pyo3::types::PyDict::new(py);\n        monthly.set_item(\"mean\", fs.monthly.mean)?;\n        monthly.set_item(\"vol\", fs.monthly.vol)?;\n        monthly.set_item(\"sharpe\", fs.monthly.sharpe)?;\n        monthly.set_item(\"sortino\", fs.monthly.sortino)?;\n        monthly.set_item(\"skew\", fs.monthly.skew)?;\n        monthly.set_item(\"kurtosis\", fs.monthly.kurtosis)?;\n        monthly.set_item(\"best\", fs.monthly.best)?;\n        monthly.set_item(\"worst\", fs.monthly.worst)?;\n        dict.set_item(\"monthly\", monthly)?;\n\n        // Yearly period stats\n        let yearly = pyo3::types::PyDict::new(py);\n        yearly.set_item(\"mean\", fs.yearly.mean)?;\n        yearly.set_item(\"vol\", fs.yearly.vol)?;\n        yearly.set_item(\"sharpe\", fs.yearly.sharpe)?;\n        yearly.set_item(\"sortino\", fs.yearly.sortino)?;\n        yearly.set_item(\"skew\", fs.yearly.skew)?;\n        yearly.set_item(\"kurtosis\", fs.yearly.kurtosis)?;\n        yearly.set_item(\"best\", fs.yearly.best)?;\n        yearly.set_item(\"worst\", fs.yearly.worst)?;\n        dict.set_item(\"yearly\", yearly)?;\n\n        // Lookback returns\n        let lookback = pyo3::types::PyDict::new(py);\n        set_opt(&lookback, \"mtd\", fs.lookback.mtd)?;\n        set_opt(&lookback, \"three_month\", fs.lookback.three_month)?;\n        set_opt(&lookback, \"six_month\", fs.lookback.six_month)?;\n        set_opt(&lookback, \"ytd\", fs.lookback.ytd)?;\n        set_opt(&lookback, \"one_year\", fs.lookback.one_year)?;\n        set_opt(&lookback, \"three_year\", fs.lookback.three_year)?;\n        set_opt(&lookback, \"five_year\", fs.lookback.five_year)?;\n        set_opt(&lookback, \"ten_year\", fs.lookback.ten_year)?;\n        dict.set_item(\"lookback\", lookback)?;\n\n        // Portfolio metrics\n        dict.set_item(\"turnover\", fs.turnover)?;\n        dict.set_item(\"herfindahl\", fs.herfindahl)?;\n\n        Ok(dict.into())\n    })\n}\n\nfn set_opt(dict: &Bound<'_, pyo3::types::PyDict>, key: &str, val: Option<f64>) -> PyResult<()> {\n    match val {\n        Some(v) => dict.set_item(key, v)?,\n        None => dict.set_item(key, pyo3::types::PyNone::get(dict.py()))?,\n    }\n    Ok(())\n}\n"
  },
  {
    "path": "rust/ob_python/src/py_sweep.rs",
    "content": "//! Parallel grid sweep using Rayon with real run_backtest() per config.\n//!\n//! Receives options+stocks data as DataFrames once, shares via Arc,\n//! runs a full backtest per param override set in parallel.\n//! No pickle overhead — data stays in shared memory.\n\nuse pyo3::prelude::*;\nuse pyo3::types::{PyDict, PyList};\nuse pyo3_polars::PyDataFrame;\nuse rayon::prelude::*;\n\nuse ob_core::backtest::{\n    prepartition_data, run_backtest_with_filters, BacktestConfig, PartitionedData,\n    PrecompiledFilters, SchemaMapping,\n};\nuse ob_core::cost_model::CostModel;\nuse ob_core::fill_model::FillModel;\nuse ob_core::risk::RiskConstraint;\nuse ob_core::signal_selector::SignalSelector;\n\nuse crate::arrow_bridge::py_to_polars;\nuse crate::py_backtest::{\n    parse_config_from_dict, parse_cost_model, parse_fill_model,\n    parse_risk_constraint, parse_schema, parse_signal_selector,\n};\n\n/// Overrides parsed from each param dict (on GIL thread).\nstruct SweepOverrides {\n    label: String,\n    profit_pct: Option<Option<f64>>,   // None=use base, Some(None)=clear, Some(Some(v))=override\n    loss_pct: Option<Option<f64>>,\n    rebalance_dates: Option<Vec<i64>>,\n    leg_entry_filters: Option<Vec<Option<String>>>,\n    leg_exit_filters: Option<Vec<Option<String>>>,\n    cost_model: Option<CostModel>,\n    fill_model: Option<FillModel>,\n    signal_selector: Option<SignalSelector>,\n    risk_constraints: Option<Vec<RiskConstraint>>,\n    sma_days: Option<Option<usize>>,\n    options_budget_pct: Option<Option<f64>>,\n    options_budget_annual_pct: Option<Option<f64>>,\n    options_budget_fresh_spend: Option<bool>,\n    rebalance_stocks_on_exit: Option<bool>,\n}\n\nstruct SweepResult {\n    label: String,\n    stats: ob_core::stats::Stats,\n    final_cash: f64,\n    elapsed_ms: u128,\n    error: Option<String>,\n}\n\n/// Merge base config with overrides, returning a new BacktestConfig.\nfn merge_config(base: &BacktestConfig, overrides: &SweepOverrides) -> BacktestConfig {\n    let mut cfg = base.clone();\n\n    if let Some(ref pp) = overrides.profit_pct {\n        cfg.profit_pct = *pp;\n    }\n    if let Some(ref lp) = overrides.loss_pct {\n        cfg.loss_pct = *lp;\n    }\n    if let Some(ref dates) = overrides.rebalance_dates {\n        cfg.rebalance_dates = dates.clone();\n    }\n    if let Some(ref filters) = overrides.leg_entry_filters {\n        for (i, f) in filters.iter().enumerate() {\n            if i < cfg.legs.len() {\n                cfg.legs[i].entry_filter_query = f.clone();\n            }\n        }\n    }\n    if let Some(ref filters) = overrides.leg_exit_filters {\n        for (i, f) in filters.iter().enumerate() {\n            if i < cfg.legs.len() {\n                cfg.legs[i].exit_filter_query = f.clone();\n            }\n        }\n    }\n    if let Some(ref cm) = overrides.cost_model {\n        cfg.cost_model = cm.clone();\n    }\n    if let Some(ref fm) = overrides.fill_model {\n        cfg.fill_model = fm.clone();\n    }\n    if let Some(ref ss) = overrides.signal_selector {\n        cfg.signal_selector = ss.clone();\n    }\n    if let Some(ref rc) = overrides.risk_constraints {\n        cfg.risk_constraints = rc.clone();\n    }\n    if let Some(ref sma) = overrides.sma_days {\n        cfg.sma_days = *sma;\n    }\n    if let Some(ref bp) = overrides.options_budget_pct {\n        cfg.options_budget_pct = *bp;\n    }\n    if let Some(ref ba) = overrides.options_budget_annual_pct {\n        cfg.options_budget_annual_pct = *ba;\n    }\n    if let Some(fs) = overrides.options_budget_fresh_spend {\n        cfg.options_budget_fresh_spend = fs;\n    }\n    if let Some(rs) = overrides.rebalance_stocks_on_exit {\n        cfg.rebalance_stocks_on_exit = rs;\n    }\n\n    cfg\n}\n\nfn run_single_sweep(\n    partitioned: &PartitionedData,\n    base: &BacktestConfig,\n    schema: &SchemaMapping,\n    overrides: &SweepOverrides,\n    base_filters: &PrecompiledFilters,\n) -> SweepResult {\n    let label = overrides.label.clone();\n    let cfg = merge_config(base, overrides);\n    let start = std::time::Instant::now();\n\n    // Reuse base filters if this override didn't change any filter strings.\n    let needs_recompile = overrides.leg_entry_filters.is_some()\n        || overrides.leg_exit_filters.is_some();\n    let local_filters;\n    let filters = if needs_recompile {\n        local_filters = PrecompiledFilters::from_config(&cfg);\n        &local_filters\n    } else {\n        base_filters\n    };\n\n    match run_backtest_with_filters(&cfg, partitioned, schema, filters) {\n        Ok(result) => SweepResult {\n            label,\n            final_cash: result.final_cash,\n            stats: result.stats,\n            elapsed_ms: start.elapsed().as_millis(),\n            error: None,\n        },\n        Err(e) => SweepResult {\n            label,\n            stats: Default::default(),\n            final_cash: 0.0,\n            elapsed_ms: start.elapsed().as_millis(),\n            error: Some(format!(\"backtest error: {e}\")),\n        },\n    }\n}\n\n/// Parse an optional f64 that may be absent, null, or a float.\n/// None  -> key missing (use base), Some(None) -> explicit null (clear), Some(Some(v)) -> override.\nfn parse_opt_f64(dict: &Bound<'_, PyDict>, key: &str) -> PyResult<Option<Option<f64>>> {\n    match dict.get_item(key)? {\n        None => Ok(None),\n        Some(v) if v.is_none() => Ok(Some(None)),\n        Some(v) => Ok(Some(Some(v.extract::<f64>()?))),\n    }\n}\n\n/// Parse a single param override dict from Python.\nfn parse_overrides(dict: &Bound<'_, PyDict>) -> PyResult<SweepOverrides> {\n    let label = dict\n        .get_item(\"label\")?\n        .map(|v| v.extract::<String>())\n        .transpose()?\n        .unwrap_or_default();\n\n    let profit_pct = parse_opt_f64(dict, \"profit_pct\")?;\n    let loss_pct = parse_opt_f64(dict, \"loss_pct\")?;\n\n    let rebalance_dates: Option<Vec<i64>> = dict\n        .get_item(\"rebalance_dates\")?\n        .map(|v| v.extract::<Vec<i64>>())\n        .transpose()?;\n\n    let leg_entry_filters: Option<Vec<Option<String>>> = dict\n        .get_item(\"leg_entry_filters\")?\n        .map(|v| v.extract::<Vec<Option<String>>>())\n        .transpose()?;\n\n    let leg_exit_filters: Option<Vec<Option<String>>> = dict\n        .get_item(\"leg_exit_filters\")?\n        .map(|v| v.extract::<Vec<Option<String>>>())\n        .transpose()?;\n\n    let cost_model = match dict.get_item(\"cost_model\")? {\n        Some(v) if !v.is_none() => {\n            let d = v.downcast::<PyDict>()\n                .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?;\n            Some(parse_cost_model(d)?)\n        }\n        _ => None,\n    };\n\n    let fill_model = match dict.get_item(\"fill_model\")? {\n        Some(v) if !v.is_none() => {\n            let d = v.downcast::<PyDict>()\n                .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?;\n            Some(parse_fill_model(d)?)\n        }\n        _ => None,\n    };\n\n    let signal_selector = match dict.get_item(\"signal_selector\")? {\n        Some(v) if !v.is_none() => {\n            let d = v.downcast::<PyDict>()\n                .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?;\n            Some(parse_signal_selector(d)?)\n        }\n        _ => None,\n    };\n\n    let risk_constraints: Option<Vec<RiskConstraint>> = match dict.get_item(\"risk_constraints\")? {\n        Some(v) if !v.is_none() => {\n            let list = v.downcast::<PyList>()\n                .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?;\n            Some(list.iter()\n                .map(|item| {\n                    let d = item.downcast::<PyDict>()\n                        .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?;\n                    parse_risk_constraint(d)\n                })\n                .collect::<PyResult<Vec<_>>>()?)\n        }\n        _ => None,\n    };\n\n    let sma_days: Option<Option<usize>> = match dict.get_item(\"sma_days\")? {\n        None => None,\n        Some(v) if v.is_none() => Some(None),\n        Some(v) => Some(Some(v.extract::<usize>()?)),\n    };\n\n    let options_budget_pct = parse_opt_f64(dict, \"options_budget_pct\")?;\n    let options_budget_annual_pct = parse_opt_f64(dict, \"options_budget_annual_pct\")?;\n\n    let options_budget_fresh_spend: Option<bool> = dict\n        .get_item(\"options_budget_fresh_spend\")?\n        .map(|v| v.extract::<bool>())\n        .transpose()?;\n\n    let rebalance_stocks_on_exit: Option<bool> = dict\n        .get_item(\"rebalance_stocks_on_exit\")?\n        .map(|v| v.extract::<bool>())\n        .transpose()?;\n\n    Ok(SweepOverrides {\n        label,\n        profit_pct,\n        loss_pct,\n        rebalance_dates,\n        leg_entry_filters,\n        leg_exit_filters,\n        cost_model,\n        fill_model,\n        signal_selector,\n        risk_constraints,\n        sma_days,\n        options_budget_pct,\n        options_budget_annual_pct,\n        options_budget_fresh_spend,\n        rebalance_stocks_on_exit,\n    })\n}\n\n/// Run a parallel grid sweep over parameter combinations.\n///\n/// For each param dict, merges overrides into the base config and runs\n/// a full backtest. All CPU-bound work runs on Rayon threads; only the\n/// result collection touches the GIL.\n#[pyfunction]\n#[pyo3(signature = (options_data, stocks_data, base_config, schema_mapping, param_grid, n_workers = None))]\npub fn parallel_sweep(\n    py: Python<'_>,\n    options_data: PyDataFrame,\n    stocks_data: PyDataFrame,\n    base_config: &Bound<'_, PyDict>,\n    schema_mapping: &Bound<'_, PyDict>,\n    param_grid: &Bound<'_, PyList>,\n    n_workers: Option<usize>,\n) -> PyResult<PyObject> {\n    let opts = py_to_polars(options_data);\n    let stocks = py_to_polars(stocks_data);\n\n    // Parse base config and schema on GIL thread\n    let base = parse_config_from_dict(base_config)?;\n    let schema = parse_schema(schema_mapping)?;\n\n    // Parse all override dicts on main thread (needs GIL)\n    let overrides: Vec<SweepOverrides> = param_grid\n        .iter()\n        .map(|item| {\n            let dict = item.downcast::<PyDict>().map_err(|e| {\n                pyo3::exceptions::PyTypeError::new_err(format!(\"expected dict: {e}\"))\n            })?;\n            parse_overrides(dict)\n        })\n        .collect::<PyResult<Vec<_>>>()?;\n\n    // Pre-partition data once — shared across all parallel configs via &ref.\n    let partitioned = prepartition_data(&opts, &stocks, &schema)\n        .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!(\"prepartition error: {e}\")))?;\n\n    // Pre-compile base filters once — reused by configs that don't override filters.\n    let base_filters = PrecompiledFilters::from_config(&base);\n\n    // Release GIL and run parallel computation with scoped Rayon pool\n    let results: Vec<SweepResult> = py.allow_threads(|| {\n        let pool = rayon::ThreadPoolBuilder::new()\n            .num_threads(n_workers.unwrap_or(0))\n            .build()\n            .expect(\"failed to build rayon thread pool\");\n        pool.install(|| {\n            overrides\n                .par_iter()\n                .map(|ov| run_single_sweep(&partitioned, &base, &schema, ov, &base_filters))\n                .collect()\n        })\n    });\n\n    // Convert results back to Python (needs GIL)\n    let py_results = PyList::empty(py);\n    for r in &results {\n        let dict = PyDict::new(py);\n        dict.set_item(\"label\", &r.label)?;\n        dict.set_item(\"total_return\", r.stats.total_return)?;\n        dict.set_item(\"annualized_return\", r.stats.annualized_return)?;\n        dict.set_item(\"sharpe_ratio\", r.stats.sharpe_ratio)?;\n        dict.set_item(\"sortino_ratio\", r.stats.sortino_ratio)?;\n        dict.set_item(\"calmar_ratio\", r.stats.calmar_ratio)?;\n        dict.set_item(\"max_drawdown\", r.stats.max_drawdown)?;\n        dict.set_item(\"max_drawdown_duration\", r.stats.max_drawdown_duration)?;\n        dict.set_item(\"profit_factor\", r.stats.profit_factor)?;\n        dict.set_item(\"win_rate\", r.stats.win_rate)?;\n        dict.set_item(\"total_trades\", r.stats.total_trades)?;\n        dict.set_item(\"final_cash\", r.final_cash)?;\n        dict.set_item(\"elapsed_ms\", r.elapsed_ms)?;\n        dict.set_item(\"error\", &r.error)?;\n        py_results.append(dict)?;\n    }\n    Ok(py_results.into())\n}\n"
  },
  {
    "path": "setup.cfg",
    "content": "[mypy]\npython_version = 3.12\nwarn_unused_configs = True\ndisallow_untyped_defs = False\nignore_missing_imports = True\n# Existing codebase uses Optional types checked by asserts at runtime\ncheck_untyped_defs = False\n"
  },
  {
    "path": "tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/analytics/__init__.py",
    "content": ""
  },
  {
    "path": "tests/analytics/test_analytics_pbt.py",
    "content": "\"\"\"Property-based tests for BacktestStats via Rust compute_full_stats.\n\nFuzzes the analytics pipeline with random balance series and trade P&Ls to\nverify statistical invariants hold across all inputs.\n\"\"\"\n\nimport numpy as np\nimport pandas as pd\nimport pytest\nfrom hypothesis import given, settings, assume, HealthCheck\nfrom hypothesis import strategies as st\n\nfrom options_portfolio_backtester.analytics.stats import BacktestStats, PeriodStats\n\n# ---------------------------------------------------------------------------\n# Hypothesis strategies\n# ---------------------------------------------------------------------------\n\ndaily_return = st.floats(min_value=-0.15, max_value=0.15, allow_nan=False, allow_infinity=False)\npositive_return = st.floats(min_value=0.0001, max_value=0.05, allow_nan=False, allow_infinity=False)\nnegative_return = st.floats(min_value=-0.05, max_value=-0.0001, allow_nan=False, allow_infinity=False)\ninitial_capital = st.floats(min_value=1000, max_value=1e7, allow_nan=False, allow_infinity=False)\nrisk_free = st.floats(min_value=0.0, max_value=0.10, allow_nan=False, allow_infinity=False)\ntrade_pnl = st.floats(min_value=-10_000, max_value=10_000, allow_nan=False, allow_infinity=False)\n\n\ndef _make_balance(returns, initial=100_000.0):\n    \"\"\"Build a balance DataFrame from daily returns.\"\"\"\n    dates = pd.date_range(\"2020-01-01\", periods=len(returns) + 1, freq=\"B\")\n    capital = [initial]\n    for r in returns:\n        capital.append(capital[-1] * (1 + r))\n    df = pd.DataFrame({\"total capital\": capital}, index=dates)\n    df[\"% change\"] = df[\"total capital\"].pct_change()\n    return df\n\n\n# ---------------------------------------------------------------------------\n# BacktestStats invariants\n# ---------------------------------------------------------------------------\n\n\nclass TestStatsInvariantsPBT:\n    @given(st.lists(daily_return, min_size=20, max_size=500), initial_capital)\n    @settings(max_examples=80, suppress_health_check=[HealthCheck.too_slow])\n    def test_max_drawdown_non_negative(self, returns, cap):\n        balance = _make_balance(returns, cap)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.max_drawdown >= -1e-10\n\n    @given(st.lists(daily_return, min_size=20, max_size=500), initial_capital)\n    @settings(max_examples=80, suppress_health_check=[HealthCheck.too_slow])\n    def test_max_drawdown_at_most_one(self, returns, cap):\n        balance = _make_balance(returns, cap)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.max_drawdown <= 1.0 + 1e-10\n\n    @given(st.lists(daily_return, min_size=20, max_size=500), initial_capital)\n    @settings(max_examples=80, suppress_health_check=[HealthCheck.too_slow])\n    def test_volatility_non_negative(self, returns, cap):\n        balance = _make_balance(returns, cap)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.volatility >= -1e-10\n\n    @given(st.lists(daily_return, min_size=20, max_size=500), initial_capital)\n    @settings(max_examples=80, suppress_health_check=[HealthCheck.too_slow])\n    def test_total_return_matches_endpoints(self, returns, cap):\n        \"\"\"Total return = final_capital / initial_capital - 1.\"\"\"\n        balance = _make_balance(returns, cap)\n        stats = BacktestStats.from_balance(balance)\n        expected = balance[\"total capital\"].iloc[-1] / balance[\"total capital\"].iloc[0] - 1\n        assert abs(stats.total_return - expected) < 1e-6\n\n    @given(st.lists(positive_return, min_size=20, max_size=200), initial_capital)\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_all_positive_returns_positive_total(self, returns, cap):\n        balance = _make_balance(returns, cap)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.total_return > 0\n\n    @given(st.lists(negative_return, min_size=20, max_size=200), initial_capital)\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_all_negative_returns_negative_total(self, returns, cap):\n        balance = _make_balance(returns, cap)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.total_return < 0\n\n    @given(st.lists(positive_return, min_size=20, max_size=200), initial_capital)\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_all_positive_zero_drawdown(self, returns, cap):\n        \"\"\"Strictly increasing capital -> zero drawdown.\"\"\"\n        balance = _make_balance(returns, cap)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.max_drawdown < 1e-10\n\n    @given(st.lists(daily_return, min_size=20, max_size=500), initial_capital)\n    @settings(max_examples=80, suppress_health_check=[HealthCheck.too_slow])\n    def test_max_drawdown_duration_non_negative(self, returns, cap):\n        balance = _make_balance(returns, cap)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.max_drawdown_duration >= 0\n\n    @given(st.lists(daily_return, min_size=20, max_size=300), initial_capital)\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_calmar_sign_matches_return(self, returns, cap):\n        \"\"\"Calmar ratio has same sign as annualized return (when dd > 0).\"\"\"\n        balance = _make_balance(returns, cap)\n        stats = BacktestStats.from_balance(balance)\n        if stats.max_drawdown > 1e-10:\n            if stats.annualized_return > 0:\n                assert stats.calmar_ratio > -1e-10\n            elif stats.annualized_return < 0:\n                assert stats.calmar_ratio < 1e-10\n\n\nclass TestStatsEmptyEdgePBT:\n    def test_empty_balance(self):\n        stats = BacktestStats.from_balance(pd.DataFrame())\n        assert stats.total_return == 0.0\n        assert stats.max_drawdown == 0.0\n\n    @given(initial_capital)\n    @settings(max_examples=20)\n    def test_single_row(self, cap):\n        balance = _make_balance([], cap)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.total_return == 0.0\n\n\n# ---------------------------------------------------------------------------\n# Trade stats invariants\n# ---------------------------------------------------------------------------\n\n\nclass TestTradeStatsPBT:\n    @given(st.lists(trade_pnl, min_size=5, max_size=200))\n    @settings(max_examples=100, suppress_health_check=[HealthCheck.too_slow])\n    def test_wins_plus_losses_equals_total(self, pnls):\n        balance = _make_balance([0.001] * 50)\n        pnl_arr = np.array(pnls)\n        stats = BacktestStats.from_balance(balance, trade_pnls=pnl_arr)\n        assert stats.wins + stats.losses == stats.total_trades\n\n    @given(st.lists(trade_pnl, min_size=5, max_size=200))\n    @settings(max_examples=100, suppress_health_check=[HealthCheck.too_slow])\n    def test_total_trades_matches_input(self, pnls):\n        balance = _make_balance([0.001] * 50)\n        pnl_arr = np.array(pnls)\n        stats = BacktestStats.from_balance(balance, trade_pnls=pnl_arr)\n        assert stats.total_trades == len(pnls)\n\n    @given(st.lists(st.floats(min_value=1.0, max_value=10_000, allow_nan=False, allow_infinity=False),\n                    min_size=5, max_size=50))\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_all_winners(self, pnls):\n        balance = _make_balance([0.001] * 50)\n        pnl_arr = np.array(pnls)\n        stats = BacktestStats.from_balance(balance, trade_pnls=pnl_arr)\n        assert stats.wins == len(pnls)\n        assert stats.losses == 0\n        assert stats.win_pct == 100.0\n\n    @given(st.lists(st.floats(min_value=-10_000, max_value=-0.01, allow_nan=False, allow_infinity=False),\n                    min_size=5, max_size=50))\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_all_losers(self, pnls):\n        balance = _make_balance([0.001] * 50)\n        pnl_arr = np.array(pnls)\n        stats = BacktestStats.from_balance(balance, trade_pnls=pnl_arr)\n        assert stats.wins == 0\n        assert stats.losses == len(pnls)\n        assert stats.win_pct == 0.0\n\n    @given(st.lists(trade_pnl, min_size=5, max_size=200))\n    @settings(max_examples=100, suppress_health_check=[HealthCheck.too_slow])\n    def test_largest_win_gte_avg_win(self, pnls):\n        balance = _make_balance([0.001] * 50)\n        pnl_arr = np.array(pnls)\n        stats = BacktestStats.from_balance(balance, trade_pnls=pnl_arr)\n        if stats.wins > 0:\n            assert stats.largest_win >= stats.avg_win - 1e-10\n\n    @given(st.lists(trade_pnl, min_size=5, max_size=200))\n    @settings(max_examples=100, suppress_health_check=[HealthCheck.too_slow])\n    def test_largest_loss_lte_avg_loss(self, pnls):\n        balance = _make_balance([0.001] * 50)\n        pnl_arr = np.array(pnls)\n        stats = BacktestStats.from_balance(balance, trade_pnls=pnl_arr)\n        if stats.losses > 0:\n            assert stats.largest_loss <= stats.avg_loss + 1e-10\n\n    @given(st.lists(trade_pnl, min_size=5, max_size=200))\n    @settings(max_examples=100, suppress_health_check=[HealthCheck.too_slow])\n    def test_profit_factor_non_negative(self, pnls):\n        balance = _make_balance([0.001] * 50)\n        pnl_arr = np.array(pnls)\n        stats = BacktestStats.from_balance(balance, trade_pnls=pnl_arr)\n        assert stats.profit_factor >= 0\n\n\n# ---------------------------------------------------------------------------\n# Sharpe / Sortino via BacktestStats\n# ---------------------------------------------------------------------------\n\n\nclass TestSharpePBT:\n    @given(st.lists(daily_return, min_size=10, max_size=300), risk_free)\n    @settings(max_examples=100)\n    def test_finite(self, returns, rf):\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance, risk_free_rate=rf)\n        assert np.isfinite(stats.sharpe_ratio)\n\n    @given(st.lists(daily_return, min_size=10, max_size=300))\n    @settings(max_examples=50)\n    def test_higher_rf_lower_sharpe(self, returns):\n        \"\"\"Higher risk-free rate reduces Sharpe (excess returns shrink).\"\"\"\n        balance = _make_balance(returns)\n        s_low = BacktestStats.from_balance(balance, risk_free_rate=0.0)\n        s_high = BacktestStats.from_balance(balance, risk_free_rate=0.05)\n        if s_low.daily.vol > 1e-8:\n            assert s_high.sharpe_ratio <= s_low.sharpe_ratio + 1e-6\n\n    def test_fewer_than_two_returns_zero(self):\n        balance = _make_balance([0.01])\n        stats = BacktestStats.from_balance(balance)\n        # With 1-2 data points, Sharpe should be 0 or very close\n        assert np.isfinite(stats.sharpe_ratio)\n\n\nclass TestSortinoPBT:\n    @given(st.lists(daily_return, min_size=10, max_size=300), risk_free)\n    @settings(max_examples=100)\n    def test_finite(self, returns, rf):\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance, risk_free_rate=rf)\n        assert np.isfinite(stats.sortino_ratio)\n\n    @given(st.lists(positive_return, min_size=10, max_size=100))\n    @settings(max_examples=50)\n    def test_all_positive_returns_zero_sortino(self, returns):\n        \"\"\"No downside returns -> Sortino = 0 (downside std = 0).\"\"\"\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.sortino_ratio == 0.0\n\n\n# ---------------------------------------------------------------------------\n# Daily period stats invariants\n# ---------------------------------------------------------------------------\n\n\nclass TestPeriodStatsPBT:\n    @given(st.lists(daily_return, min_size=10, max_size=300), risk_free)\n    @settings(max_examples=100)\n    def test_best_gte_worst(self, returns, rf):\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance, risk_free_rate=rf)\n        assert stats.daily.best >= stats.daily.worst - 1e-10\n\n    @given(st.lists(daily_return, min_size=10, max_size=300), risk_free)\n    @settings(max_examples=100)\n    def test_vol_non_negative(self, returns, rf):\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance, risk_free_rate=rf)\n        assert stats.daily.vol >= -1e-10\n\n    @given(st.lists(daily_return, min_size=10, max_size=300), risk_free)\n    @settings(max_examples=100)\n    def test_mean_between_best_and_worst(self, returns, rf):\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance, risk_free_rate=rf)\n        assert stats.daily.worst - 1e-10 <= stats.daily.mean <= stats.daily.best + 1e-10\n\n\n# ---------------------------------------------------------------------------\n# Lookback returns\n# ---------------------------------------------------------------------------\n\n\nclass TestLookbackPBT:\n    @given(st.lists(daily_return, min_size=30, max_size=500), initial_capital)\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_mtd_always_computed(self, returns, cap):\n        balance = _make_balance(returns, cap)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.lookback.mtd is not None\n\n    @given(st.lists(daily_return, min_size=30, max_size=500), initial_capital)\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_ytd_always_computed(self, returns, cap):\n        balance = _make_balance(returns, cap)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.lookback.ytd is not None\n\n    @given(st.lists(positive_return, min_size=30, max_size=200), initial_capital)\n    @settings(max_examples=30, suppress_health_check=[HealthCheck.too_slow])\n    def test_all_positive_lookbacks_positive(self, returns, cap):\n        \"\"\"Strictly increasing capital -> all lookback returns are positive.\"\"\"\n        balance = _make_balance(returns, cap)\n        stats = BacktestStats.from_balance(balance)\n        lb = stats.lookback\n        if lb.mtd is not None:\n            assert lb.mtd >= -1e-10\n        if lb.ytd is not None:\n            assert lb.ytd >= -1e-10\n        if lb.three_month is not None:\n            assert lb.three_month >= -1e-10\n\n\n# ---------------------------------------------------------------------------\n# Turnover / Herfindahl\n# ---------------------------------------------------------------------------\n\n\nclass TestTurnoverHerfindahlPBT:\n    @given(st.lists(daily_return, min_size=20, max_size=100), initial_capital)\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_turnover_non_negative(self, returns, cap):\n        balance = _make_balance(returns, cap)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.turnover >= -1e-10\n\n    @given(st.lists(daily_return, min_size=20, max_size=100), initial_capital)\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_herfindahl_non_negative(self, returns, cap):\n        balance = _make_balance(returns, cap)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.herfindahl >= -1e-10\n\n    def test_no_stock_cols_zero_turnover(self):\n        balance = _make_balance([0.01] * 20)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.turnover == 0.0\n        assert stats.herfindahl == 0.0\n\n\n# ---------------------------------------------------------------------------\n# from_balance_range\n# ---------------------------------------------------------------------------\n\n\nclass TestBalanceRangePBT:\n    @given(st.lists(daily_return, min_size=60, max_size=300), initial_capital)\n    @settings(max_examples=30, suppress_health_check=[HealthCheck.too_slow])\n    def test_range_subset_shorter(self, returns, cap):\n        \"\"\"Slicing to a sub-range gives different (not necessarily smaller) return.\"\"\"\n        balance = _make_balance(returns, cap)\n        full = BacktestStats.from_balance(balance)\n        # Take the middle 50% of dates\n        mid_start = balance.index[len(balance) // 4]\n        mid_end = balance.index[3 * len(balance) // 4]\n        sliced = BacktestStats.from_balance_range(balance, start=mid_start, end=mid_end)\n        # Just verify it computed something -- the stats themselves may differ\n        assert isinstance(sliced, BacktestStats)\n        assert sliced.max_drawdown >= -1e-10\n\n\n# ---------------------------------------------------------------------------\n# Output formatting\n# ---------------------------------------------------------------------------\n\n\nclass TestOutputFormattingPBT:\n    @given(st.lists(daily_return, min_size=20, max_size=200), initial_capital)\n    @settings(max_examples=30, suppress_health_check=[HealthCheck.too_slow])\n    def test_to_dataframe_not_empty(self, returns, cap):\n        balance = _make_balance(returns, cap)\n        stats = BacktestStats.from_balance(balance)\n        df = stats.to_dataframe()\n        assert len(df) > 0\n        assert \"Value\" in df.columns\n\n    @given(st.lists(daily_return, min_size=20, max_size=200), initial_capital)\n    @settings(max_examples=30, suppress_health_check=[HealthCheck.too_slow])\n    def test_summary_not_empty(self, returns, cap):\n        balance = _make_balance(returns, cap)\n        stats = BacktestStats.from_balance(balance)\n        s = stats.summary()\n        assert len(s) > 0\n        assert \"Total Return\" in s\n"
  },
  {
    "path": "tests/analytics/test_charts.py",
    "content": "\"\"\"Tests for chart functions (weights_chart + Altair charts).\"\"\"\n\nfrom __future__ import annotations\n\nimport pandas as pd\nimport numpy as np\nimport altair as alt\nimport matplotlib\n\nmatplotlib.use(\"Agg\")  # non-interactive backend for testing\n\nfrom options_portfolio_backtester.analytics.charts import (\n    weights_chart, returns_chart, returns_histogram, monthly_returns_heatmap,\n)\n\n\ndef _make_balance() -> pd.DataFrame:\n    dates = pd.bdate_range(\"2024-01-02\", periods=10)\n    return pd.DataFrame({\n        \"total capital\": np.linspace(10000, 11000, 10),\n        \"cash\": np.linspace(2000, 1000, 10),\n        \"stocks capital\": np.linspace(8000, 10000, 10),\n        \"SPY qty\": [50.0] * 10,\n        \"TLT qty\": [100.0] * 10,\n    }, index=dates)\n\n\ndef test_weights_chart_returns_fig_ax():\n    balance = _make_balance()\n    fig, ax = weights_chart(balance)\n    assert fig is not None\n    assert ax is not None\n    assert ax.get_ylabel() == \"Weight\"\n\n\ndef test_weights_chart_no_positions():\n    dates = pd.bdate_range(\"2024-01-02\", periods=5)\n    balance = pd.DataFrame({\n        \"total capital\": [10000.0] * 5,\n        \"cash\": [10000.0] * 5,\n    }, index=dates)\n    fig, ax = weights_chart(balance)\n    assert fig is not None\n    assert \"no positions\" in ax.get_title().lower()\n\n\ndef test_weights_chart_single_symbol():\n    dates = pd.bdate_range(\"2024-01-02\", periods=5)\n    balance = pd.DataFrame({\n        \"total capital\": [10000.0] * 5,\n        \"cash\": [2000.0] * 5,\n        \"SPY qty\": [80.0] * 5,\n    }, index=dates)\n    fig, ax = weights_chart(balance)\n    assert fig is not None\n\n\n# ── Altair chart tests (moved from backtester/test/statistics/test_charts.py) ──\n\n\ndef _make_balance_report(days=90):\n    \"\"\"Create a minimal balance-like DataFrame for Altair chart tests.\"\"\"\n    dates = pd.bdate_range('2020-01-01', periods=days, freq='B')\n    rng = np.random.default_rng(42)\n    returns = rng.normal(0.0005, 0.01, size=days)\n    capital = 1_000_000 * np.cumprod(1 + returns)\n\n    report = pd.DataFrame({\n        'total capital': capital,\n        '% change': returns,\n        'accumulated return': np.cumprod(1 + returns),\n    }, index=dates)\n    return report\n\n\ndef test_returns_chart_returns_vconcat():\n    \"\"\"returns_chart should return a VConcatChart (layered + brush).\"\"\"\n    report = _make_balance_report()\n    chart = returns_chart(report)\n    assert isinstance(chart, alt.VConcatChart)\n\n\ndef test_returns_chart_has_two_panels():\n    \"\"\"VConcatChart should have exactly 2 panels (main + brush).\"\"\"\n    report = _make_balance_report()\n    chart = returns_chart(report)\n    assert len(chart.vconcat) == 2\n\n\ndef test_returns_chart_serializes_to_dict():\n    \"\"\"Chart should serialize to a valid Vega-Lite spec dict.\"\"\"\n    report = _make_balance_report()\n    chart = returns_chart(report)\n    spec = chart.to_dict()\n    assert 'vconcat' in spec\n    assert isinstance(spec['vconcat'], list)\n\n\ndef test_returns_histogram_returns_chart():\n    \"\"\"returns_histogram should return a Chart with bar mark.\"\"\"\n    report = _make_balance_report()\n    chart = returns_histogram(report)\n    assert isinstance(chart, alt.Chart)\n\n\ndef test_returns_histogram_serializes():\n    \"\"\"Histogram should serialize without errors.\"\"\"\n    report = _make_balance_report()\n    chart = returns_histogram(report)\n    spec = chart.to_dict()\n    assert spec['mark']['type'] == 'bar'\n\n\ndef test_monthly_returns_heatmap_returns_chart():\n    \"\"\"monthly_returns_heatmap should return a Chart with rect mark.\"\"\"\n    report = _make_balance_report(days=250)\n    chart = monthly_returns_heatmap(report)\n    assert isinstance(chart, alt.Chart)\n\n\ndef test_monthly_returns_heatmap_serializes():\n    \"\"\"Heatmap should serialize without errors.\"\"\"\n    report = _make_balance_report(days=250)\n    chart = monthly_returns_heatmap(report)\n    spec = chart.to_dict()\n    assert spec['mark'] == 'rect' or spec['mark']['type'] == 'rect'\n\n\ndef test_returns_chart_has_interval_and_point_params():\n    \"\"\"Verify the spec has both interval and point selection params (Altair 5 API).\"\"\"\n    report = _make_balance_report()\n    chart = returns_chart(report)\n    spec = chart.to_dict()\n\n    # In Altair 5, params are hoisted to the top-level spec\n    params = spec.get('params', [])\n    param_types = {p.get('select', {}).get('type') for p in params}\n    assert 'interval' in param_types, \"Expected an 'interval' selection param\"\n    assert 'point' in param_types, \"Expected a 'point' selection param\"\n\n\ndef test_returns_chart_data_included():\n    \"\"\"Verify chart spec includes data.\"\"\"\n    report = _make_balance_report()\n    chart = returns_chart(report)\n    spec = chart.to_dict()\n    assert 'data' in spec or 'datasets' in spec\n"
  },
  {
    "path": "tests/analytics/test_optimization.py",
    "content": "\"\"\"Tests for analytics/optimization.py — grid_sweep and walk_forward.\"\"\"\n\nimport pandas as pd\nimport numpy as np\n\nfrom options_portfolio_backtester.analytics.optimization import (\n    OptimizationResult, grid_sweep, walk_forward,\n)\nfrom options_portfolio_backtester.analytics.stats import BacktestStats\n\n\ndef _dummy_run_fn(param_a=1, param_b=2):\n    \"\"\"Dummy backtest function for grid sweep tests.\"\"\"\n    dates = pd.date_range(\"2020-01-01\", periods=50, freq=\"B\")\n    capital = [100_000.0]\n    for _ in range(49):\n        capital.append(capital[-1] * (1 + 0.001 * param_a))\n    bal = pd.DataFrame({\"total capital\": capital}, index=dates)\n    bal[\"% change\"] = bal[\"total capital\"].pct_change()\n    stats = BacktestStats.from_balance(bal)\n    return stats, bal\n\n\ndef _failing_run_fn(param_a=1):\n    \"\"\"Fails when param_a == 2; picklable because module-level.\"\"\"\n    if param_a == 2:\n        raise ValueError(\"boom\")\n    return _dummy_run_fn(param_a=param_a)\n\n\ndef _dummy_wf_fn(start_date, end_date):\n    \"\"\"Dummy walk-forward function.\"\"\"\n    dates = pd.bdate_range(start_date, end_date)\n    if len(dates) < 2:\n        dates = pd.bdate_range(start_date, periods=5)\n    capital = np.linspace(100000, 105000, len(dates))\n    bal = pd.DataFrame({\"total capital\": capital}, index=dates)\n    bal[\"% change\"] = bal[\"total capital\"].pct_change()\n    stats = BacktestStats.from_balance(bal)\n    return stats, bal\n\n\nclass TestOptimizationResult:\n    def test_fields(self):\n        stats = BacktestStats()\n        bal = pd.DataFrame()\n        r = OptimizationResult(params={\"x\": 1}, stats=stats, balance=bal)\n        assert r.params == {\"x\": 1}\n        assert r.stats is stats\n\n\nclass TestGridSweep:\n    def test_returns_results_for_all_combos(self):\n        results = grid_sweep(\n            _dummy_run_fn,\n            param_grid={\"param_a\": [1, 2], \"param_b\": [10, 20]},\n            max_workers=1,\n        )\n        assert len(results) == 4\n\n    def test_sorted_by_sharpe_descending(self):\n        results = grid_sweep(\n            _dummy_run_fn,\n            param_grid={\"param_a\": [1, 2, 3]},\n            max_workers=1,\n        )\n        sharpes = [r.stats.sharpe_ratio for r in results]\n        assert sharpes == sorted(sharpes, reverse=True)\n\n    def test_single_combo(self):\n        results = grid_sweep(\n            _dummy_run_fn,\n            param_grid={\"param_a\": [1]},\n            max_workers=1,\n        )\n        assert len(results) == 1\n\n    def test_failing_fn_skipped(self):\n        results = grid_sweep(\n            _failing_run_fn,\n            param_grid={\"param_a\": [1, 2, 3]},\n            max_workers=1,\n        )\n        # param_a=2 should be skipped\n        assert len(results) == 2\n\n\nclass TestWalkForward:\n    def test_returns_splits(self):\n        dates = pd.bdate_range(\"2020-01-01\", periods=250)\n        results = walk_forward(_dummy_wf_fn, dates, n_splits=3)\n        assert len(results) == 3\n        for is_result, oos_result in results:\n            assert is_result.params[\"type\"] == \"in_sample\"\n            assert oos_result.params[\"type\"] == \"out_of_sample\"\n\n    def test_single_split(self):\n        dates = pd.bdate_range(\"2020-01-01\", periods=100)\n        results = walk_forward(_dummy_wf_fn, dates, n_splits=1)\n        assert len(results) == 1\n\n    def test_failing_wf_fn_skipped(self):\n        \"\"\"Walk-forward skips splits where run_fn raises.\"\"\"\n        call_count = [0]\n\n        def _failing_wf(start_date, end_date):\n            call_count[0] += 1\n            # Fail on call 1 (in-sample for split 0) → entire split 0 skipped\n            if call_count[0] == 1:\n                raise ValueError(\"boom\")\n            return _dummy_wf_fn(start_date, end_date)\n\n        dates = pd.bdate_range(\"2020-01-01\", periods=200)\n        results = walk_forward(_failing_wf, dates, n_splits=2)\n        # Split 0 fails (in-sample raises), split 1 succeeds\n        assert len(results) == 1\n\n    def test_custom_in_sample_pct(self):\n        dates = pd.bdate_range(\"2020-01-01\", periods=200)\n        results = walk_forward(_dummy_wf_fn, dates, n_splits=2, in_sample_pct=0.8)\n        assert len(results) == 2\n"
  },
  {
    "path": "tests/analytics/test_stats.py",
    "content": "\"\"\"Tests for BacktestStats — including the fixed profit_factor.\"\"\"\n\nimport numpy as np\nimport pandas as pd\nimport pytest\n\nfrom options_portfolio_backtester.analytics.stats import (\n    BacktestStats, PeriodStats, LookbackReturns,\n)\n\n\ndef _make_balance(returns: list[float], initial: float = 100_000.0) -> pd.DataFrame:\n    \"\"\"Build a balance DataFrame from a list of daily returns.\"\"\"\n    dates = pd.date_range(\"2020-01-01\", periods=len(returns) + 1, freq=\"B\")\n    capital = [initial]\n    for r in returns:\n        capital.append(capital[-1] * (1 + r))\n    df = pd.DataFrame({\"total capital\": capital}, index=dates)\n    df[\"% change\"] = df[\"total capital\"].pct_change()\n    return df\n\n\nclass TestProfitFactor:\n    \"\"\"Critical test: profit_factor must be dollar-based, not count-based.\"\"\"\n\n    def test_profit_factor_dollar_ratio(self):\n        \"\"\"profit_factor = gross_profit / gross_loss in dollars.\"\"\"\n        # 2 wins ($100, $200) and 1 loss (-$50)\n        trade_pnls = np.array([100.0, 200.0, -50.0])\n        balance = _make_balance([0.01] * 10)\n        stats = BacktestStats.from_balance(balance, trade_pnls)\n        # gross_profit = 300, gross_loss = 50, factor = 6.0\n        assert stats.profit_factor == 6.0\n\n    def test_profit_factor_not_count_ratio(self):\n        \"\"\"The old bug used win_count/loss_count. Verify it's NOT that.\"\"\"\n        # 1 big win ($1000) and 3 small losses (-$10 each)\n        trade_pnls = np.array([1000.0, -10.0, -10.0, -10.0])\n        balance = _make_balance([0.01] * 10)\n        stats = BacktestStats.from_balance(balance, trade_pnls)\n        # Dollar: 1000/30 = 33.33, Count would be: 1/3 = 0.33\n        assert abs(stats.profit_factor - 33.333333) < 0.01\n\n    def test_profit_factor_no_losses(self):\n        trade_pnls = np.array([100.0, 200.0])\n        balance = _make_balance([0.01] * 10)\n        stats = BacktestStats.from_balance(balance, trade_pnls)\n        assert stats.profit_factor == float(\"inf\")\n\n    def test_profit_factor_no_wins(self):\n        trade_pnls = np.array([-100.0, -200.0])\n        balance = _make_balance([0.01] * 10)\n        stats = BacktestStats.from_balance(balance, trade_pnls)\n        assert stats.profit_factor == 0.0\n\n\nclass TestReturnMetrics:\n    def test_total_return(self):\n        balance = _make_balance([0.01] * 252)  # 1% daily for a year\n        stats = BacktestStats.from_balance(balance)\n        # (1.01)^252 - 1 ~ 11.28\n        assert stats.total_return > 10.0\n\n    def test_zero_return(self):\n        balance = _make_balance([0.0] * 10)\n        stats = BacktestStats.from_balance(balance)\n        assert abs(stats.total_return) < 1e-10\n\n    def test_sharpe_positive(self):\n        # Use varying positive returns so std > 0\n        rng = np.random.RandomState(42)\n        returns = list(rng.normal(0.001, 0.01, 252))\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.sharpe_ratio > 0\n\n\nclass TestDrawdown:\n    def test_max_drawdown(self):\n        # Go up, then crash, then recover\n        returns = [0.10, 0.10, -0.30, -0.20, 0.10, 0.10]\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.max_drawdown > 0\n\n    def test_no_drawdown(self):\n        returns = [0.01] * 10\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.max_drawdown == 0.0\n\n    def test_drawdown_duration(self):\n        # Drop then flat then recover\n        returns = [0.10, -0.20, -0.01, -0.01, 0.30]\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.max_drawdown_duration >= 2\n\n\nclass TestTradeStats:\n    def test_wins_losses_count(self):\n        trade_pnls = np.array([100, -50, 200, -30, 150])\n        balance = _make_balance([0.01] * 10)\n        stats = BacktestStats.from_balance(balance, trade_pnls)\n        assert stats.wins == 3\n        assert stats.losses == 2\n        assert stats.total_trades == 5\n\n    def test_win_pct(self):\n        trade_pnls = np.array([100, -50, 200, -30, 150])\n        balance = _make_balance([0.01] * 10)\n        stats = BacktestStats.from_balance(balance, trade_pnls)\n        assert abs(stats.win_pct - 60.0) < 1e-10\n\n    def test_empty_balance(self):\n        balance = pd.DataFrame()\n        stats = BacktestStats.from_balance(balance)\n        assert stats.total_trades == 0\n        assert stats.total_return == 0.0\n\n    def test_no_trade_pnls(self):\n        balance = _make_balance([0.01] * 10)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.total_trades == 0\n        assert stats.total_return > 0\n\n\nclass TestToDataframe:\n    def test_shape(self):\n        trade_pnls = np.array([100, -50])\n        balance = _make_balance([0.01] * 10)\n        stats = BacktestStats.from_balance(balance, trade_pnls)\n        df = stats.to_dataframe()\n        assert df.shape[0] >= 30  # expanded stats (period, lookback, portfolio)\n        assert df.shape[1] == 1\n\n    def test_summary_string(self):\n        balance = _make_balance([0.01] * 10)\n        stats = BacktestStats.from_balance(balance)\n        s = stats.summary()\n        assert \"Sharpe\" in s\n        assert \"Max Drawdown\" in s\n\n\nclass TestPeriodStats:\n    def test_daily_stats_computed(self):\n        rng = np.random.RandomState(42)\n        returns = list(rng.normal(0.001, 0.01, 252))\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.daily.mean != 0\n        assert stats.daily.vol != 0\n        assert stats.daily.sharpe != 0\n        assert stats.daily.best > 0\n        assert stats.daily.worst < 0\n\n    def test_monthly_stats_computed(self):\n        rng = np.random.RandomState(42)\n        returns = list(rng.normal(0.001, 0.01, 504))  # 2 years\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.monthly.mean != 0\n        assert stats.monthly.vol != 0\n        assert stats.monthly.sharpe != 0\n\n    def test_yearly_stats_computed(self):\n        rng = np.random.RandomState(42)\n        returns = list(rng.normal(0.001, 0.01, 756))  # 3 years\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.yearly.mean != 0\n        assert stats.yearly.best > 0\n        assert stats.yearly.worst != 0\n\n    def test_skew_kurtosis_with_enough_data(self):\n        rng = np.random.RandomState(42)\n        returns = list(rng.normal(0.001, 0.01, 252))\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.daily.skew != 0\n        assert stats.daily.kurtosis != 0\n\n    def test_skew_kurtosis_not_computed_with_few_points(self):\n        # With only 3 data points, skew/kurtosis should be 0\n        balance = _make_balance([0.01, 0.02, -0.01])\n        stats = BacktestStats.from_balance(balance)\n        assert stats.daily.skew == 0  # not enough data (< 8)\n\n\nclass TestAvgDrawdown:\n    def test_avg_drawdown_depth(self):\n        returns = [0.10, -0.15, -0.05, 0.30, 0.05, -0.10, 0.20]\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.avg_drawdown > 0\n        assert stats.avg_drawdown <= stats.max_drawdown\n\n    def test_avg_drawdown_duration(self):\n        returns = [0.10, -0.15, -0.05, 0.30, 0.05, -0.10, 0.20]\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.avg_drawdown_duration > 0\n        assert stats.avg_drawdown_duration <= stats.max_drawdown_duration\n\n\nclass TestLookbackReturns:\n    def test_mtd_and_ytd(self):\n        rng = np.random.RandomState(42)\n        returns = list(rng.normal(0.001, 0.01, 504))\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.lookback.mtd is not None\n        assert stats.lookback.ytd is not None\n\n    def test_one_year_return(self):\n        rng = np.random.RandomState(42)\n        returns = list(rng.normal(0.001, 0.01, 504))\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.lookback.one_year is not None\n\n    def test_lookback_table(self):\n        rng = np.random.RandomState(42)\n        returns = list(rng.normal(0.001, 0.01, 504))\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance)\n        table = stats.lookback_table()\n        assert not table.empty\n        assert \"MTD\" in table.columns\n\n    def test_short_series_lookback_equals_total(self):\n        returns = [0.01] * 10\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance)\n        # For periods longer than the data, lookback == total return\n        assert stats.lookback.ten_year is not None\n        assert abs(stats.lookback.ten_year - stats.total_return) < 1e-6\n\n\nclass TestTurnover:\n    def test_turnover_zero_for_no_stocks(self):\n        balance = _make_balance([0.01] * 10)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.turnover == 0.0\n\n    def test_turnover_computed_with_stocks(self):\n        rng = np.random.RandomState(42)\n        dates = pd.date_range(\"2020-01-01\", periods=20, freq=\"B\")\n        total = 100_000 + np.cumsum(rng.normal(100, 500, 20))\n        spy = total * 0.6 + rng.normal(0, 500, 20)\n        balance = pd.DataFrame({\n            \"total capital\": total,\n            \"SPY\": spy,\n            \"SPY qty\": spy / 300,\n        }, index=dates)\n        balance[\"% change\"] = balance[\"total capital\"].pct_change()\n        stats = BacktestStats.from_balance(balance)\n        assert stats.turnover >= 0\n\n\nclass TestHerfindahl:\n    def test_single_stock_hhi_is_one(self):\n        dates = pd.date_range(\"2020-01-01\", periods=10, freq=\"B\")\n        balance = pd.DataFrame({\n            \"total capital\": [100_000] * 10,\n            \"SPY\": [100_000] * 10,\n            \"SPY qty\": [300] * 10,\n        }, index=dates)\n        balance[\"% change\"] = balance[\"total capital\"].pct_change()\n        stats = BacktestStats.from_balance(balance)\n        assert abs(stats.herfindahl - 1.0) < 0.01\n\n    def test_two_equal_stocks_hhi(self):\n        dates = pd.date_range(\"2020-01-01\", periods=10, freq=\"B\")\n        balance = pd.DataFrame({\n            \"total capital\": [100_000] * 10,\n            \"SPY\": [50_000] * 10,\n            \"SPY qty\": [150] * 10,\n            \"QQQ\": [50_000] * 10,\n            \"QQQ qty\": [200] * 10,\n        }, index=dates)\n        balance[\"% change\"] = balance[\"total capital\"].pct_change()\n        stats = BacktestStats.from_balance(balance)\n        # 0.5^2 + 0.5^2 = 0.5\n        assert abs(stats.herfindahl - 0.5) < 0.01\n\n\nclass TestFromBalanceRange:\n    def test_slice_start(self):\n        returns = [0.01] * 20\n        balance = _make_balance(returns)\n        mid_date = balance.index[10]\n        stats = BacktestStats.from_balance_range(balance, start=str(mid_date))\n        # Should compute stats on roughly half the data\n        assert stats.total_return > 0\n\n    def test_slice_end(self):\n        returns = [0.01] * 20\n        balance = _make_balance(returns)\n        mid_date = balance.index[10]\n        stats = BacktestStats.from_balance_range(balance, end=str(mid_date))\n        full_stats = BacktestStats.from_balance(balance)\n        assert stats.total_return < full_stats.total_return\n\n    def test_slice_both(self):\n        returns = [0.01] * 30\n        balance = _make_balance(returns)\n        start = str(balance.index[5])\n        end = str(balance.index[15])\n        stats = BacktestStats.from_balance_range(balance, start=start, end=end)\n        assert stats.total_return > 0\n\n    def test_empty_balance(self):\n        balance = pd.DataFrame()\n        stats = BacktestStats.from_balance_range(balance)\n        assert stats.total_return == 0.0\n\n    def test_no_slice(self):\n        returns = [0.01] * 10\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance_range(balance)\n        full_stats = BacktestStats.from_balance(balance)\n        assert abs(stats.total_return - full_stats.total_return) < 1e-6\n"
  },
  {
    "path": "tests/analytics/test_stats_python_path.py",
    "content": "\"\"\"Tests for BacktestStats.from_balance — covers the Rust compute_full_stats\npath including period stats, lookback, turnover, herfindahl, trade stats,\nsummary text with monthly/turnover branches, and lookback_table.\"\"\"\n\nimport numpy as np\nimport pandas as pd\nimport pytest\n\nfrom options_portfolio_backtester.analytics.stats import (\n    BacktestStats, PeriodStats, LookbackReturns,\n)\n\n\ndef _make_balance(returns, initial=100_000.0, start=\"2020-01-01\"):\n    dates = pd.date_range(start, periods=len(returns) + 1, freq=\"B\")\n    capital = [initial]\n    for r in returns:\n        capital.append(capital[-1] * (1 + r))\n    df = pd.DataFrame({\"total capital\": capital}, index=dates)\n    df[\"% change\"] = df[\"total capital\"].pct_change()\n    return df\n\n\ndef _make_balance_with_stocks(returns, initial=100_000.0, start=\"2020-01-01\"):\n    \"\"\"Balance with stock columns for turnover/herfindahl tests.\"\"\"\n    df = _make_balance(returns, initial, start)\n    n = len(df)\n    df[\"SPY\"] = np.linspace(60000, 70000, n)\n    df[\"SPY qty\"] = 200\n    df[\"IWM\"] = np.linspace(30000, 25000, n)\n    df[\"IWM qty\"] = 150\n    return df\n\n\nclass TestFromBalanceReturnMetrics:\n    def test_total_return(self):\n        rets = [0.01] * 50\n        bal = _make_balance(rets)\n        s = BacktestStats.from_balance(bal)\n        expected = (1.01 ** 50) - 1\n        assert abs(s.total_return - expected) < 1e-6\n\n    def test_annualized_return(self):\n        rets = [0.001] * 252\n        bal = _make_balance(rets)\n        s = BacktestStats.from_balance(bal)\n        assert s.annualized_return > 0\n\n    def test_volatility(self):\n        rets = [0.01, -0.01] * 50\n        bal = _make_balance(rets)\n        s = BacktestStats.from_balance(bal)\n        assert s.volatility > 0\n\n    def test_sharpe_and_sortino(self):\n        rng = np.random.default_rng(42)\n        rets = (rng.normal(0.005, 0.01, 100)).tolist()\n        bal = _make_balance(rets)\n        s = BacktestStats.from_balance(bal)\n        assert s.sharpe_ratio != 0\n        assert s.sortino_ratio != 0\n\n\nclass TestFromBalanceDrawdown:\n    def test_drawdown_with_losses(self):\n        rets = [0.01] * 10 + [-0.05] * 5 + [0.01] * 10\n        bal = _make_balance(rets)\n        s = BacktestStats.from_balance(bal)\n        assert s.max_drawdown > 0\n        assert s.max_drawdown_duration > 0\n\n    def test_avg_drawdown(self):\n        rets = [0.02] * 5 + [-0.03] * 3 + [0.02] * 5 + [-0.02] * 2 + [0.02] * 5\n        bal = _make_balance(rets)\n        s = BacktestStats.from_balance(bal)\n        assert s.avg_drawdown > 0\n        assert s.avg_drawdown_duration > 0\n\n    def test_calmar_ratio(self):\n        rets = [0.01] * 10 + [-0.03] + [0.01] * 10\n        bal = _make_balance(rets)\n        s = BacktestStats.from_balance(bal)\n        assert s.calmar_ratio != 0\n\n    def test_no_drawdown(self):\n        rets = [0.01] * 20\n        bal = _make_balance(rets)\n        s = BacktestStats.from_balance(bal)\n        assert s.max_drawdown == 0.0\n        assert s.calmar_ratio == 0.0\n\n\nclass TestFromBalanceTailRatio:\n    def test_tail_ratio_enough_data(self):\n        rng = np.random.default_rng(42)\n        rets = rng.normal(0.001, 0.02, 100).tolist()\n        bal = _make_balance(rets)\n        s = BacktestStats.from_balance(bal)\n        assert s.tail_ratio > 0\n\n    def test_tail_ratio_insufficient_data(self):\n        rets = [0.01] * 10\n        bal = _make_balance(rets)\n        s = BacktestStats.from_balance(bal)\n        assert s.tail_ratio == 0.0\n\n\nclass TestFromBalancePeriodStats:\n    def test_daily_stats(self):\n        rng = np.random.default_rng(99)\n        rets = rng.normal(0.005, 0.01, 50).tolist()\n        bal = _make_balance(rets)\n        s = BacktestStats.from_balance(bal)\n        assert s.daily.mean != 0\n        assert s.daily.vol > 0\n        assert s.daily.best > 0\n        assert s.daily.worst < s.daily.best\n\n    def test_skew_kurtosis_need_8_returns(self):\n        rets = [0.01] * 3\n        bal = _make_balance(rets)\n        s = BacktestStats.from_balance(bal)\n        assert s.daily.skew == 0.0\n        assert s.daily.kurtosis == 0.0\n\n    def test_skew_kurtosis_with_enough_data(self):\n        rng = np.random.default_rng(42)\n        rets = rng.normal(0.001, 0.02, 30).tolist()\n        bal = _make_balance(rets)\n        s = BacktestStats.from_balance(bal)\n        # skew and kurtosis are non-zero with random data\n        assert s.daily.skew != 0.0 or s.daily.kurtosis != 0.0\n\n\nclass TestFromBalanceLookback:\n    def test_lookback_mtd_ytd(self):\n        rets = [0.002] * 100\n        bal = _make_balance(rets)\n        s = BacktestStats.from_balance(bal)\n        assert s.lookback.mtd is not None\n        assert s.lookback.ytd is not None\n\n    def test_lookback_trailing_periods(self):\n        # 2 years of data\n        rets = [0.001] * 504\n        bal = _make_balance(rets, start=\"2018-06-01\")\n        s = BacktestStats.from_balance(bal)\n        assert s.lookback.three_month is not None\n        assert s.lookback.six_month is not None\n        assert s.lookback.one_year is not None\n\n\nclass TestFromBalanceTurnoverHerfindahl:\n    def test_turnover_with_stocks(self):\n        rets = [0.001] * 50\n        bal = _make_balance_with_stocks(rets)\n        s = BacktestStats.from_balance(bal)\n        assert s.turnover >= 0.0\n\n    def test_herfindahl_with_stocks(self):\n        rets = [0.001] * 50\n        bal = _make_balance_with_stocks(rets)\n        s = BacktestStats.from_balance(bal)\n        assert s.herfindahl > 0.0\n\n    def test_turnover_no_stocks(self):\n        rets = [0.001] * 10\n        bal = _make_balance(rets)\n        s = BacktestStats.from_balance(bal)\n        assert s.turnover == 0.0\n\n    def test_herfindahl_no_stocks(self):\n        rets = [0.001] * 10\n        bal = _make_balance(rets)\n        s = BacktestStats.from_balance(bal)\n        assert s.herfindahl == 0.0\n\n\nclass TestFromBalanceTradeStats:\n    def test_trade_stats_full(self):\n        rets = [0.01] * 10\n        bal = _make_balance(rets)\n        pnls = np.array([100.0, 200.0, -50.0, -30.0, 150.0])\n        s = BacktestStats.from_balance(bal, trade_pnls=pnls)\n        assert s.total_trades == 5\n        assert s.wins == 3\n        assert s.losses == 2\n        assert s.win_pct == pytest.approx(60.0)\n        assert s.largest_win == 200.0\n        assert s.largest_loss == -50.0\n        assert s.avg_win > 0\n        assert s.avg_loss < 0\n        assert s.avg_trade > 0\n\n    def test_trade_stats_all_wins(self):\n        rets = [0.01] * 10\n        bal = _make_balance(rets)\n        pnls = np.array([100.0, 200.0])\n        s = BacktestStats.from_balance(bal, trade_pnls=pnls)\n        assert s.profit_factor == float(\"inf\")\n\n    def test_trade_stats_all_losses(self):\n        rets = [0.01] * 10\n        bal = _make_balance(rets)\n        pnls = np.array([-100.0, -200.0])\n        s = BacktestStats.from_balance(bal, trade_pnls=pnls)\n        assert s.profit_factor == 0.0\n        assert s.largest_win == 0\n        assert s.avg_win == 0\n\n    def test_trade_stats_none(self):\n        rets = [0.01] * 10\n        bal = _make_balance(rets)\n        s = BacktestStats.from_balance(bal, trade_pnls=None)\n        assert s.total_trades == 0\n\n\nclass TestSummaryText:\n    def test_summary_minimal(self):\n        rets = [0.01] * 5\n        bal = _make_balance(rets)\n        s = BacktestStats.from_balance(bal)\n        text = s.summary()\n        assert \"Total Return\" in text\n\n    def test_summary_with_turnover(self):\n        rets = [0.001] * 50\n        bal = _make_balance_with_stocks(rets)\n        s = BacktestStats.from_balance(bal)\n        text = s.summary()\n        assert \"Turnover\" in text\n\n\nclass TestLookbackTable:\n    def test_lookback_table_nonempty(self):\n        rets = [0.002] * 100\n        bal = _make_balance(rets)\n        s = BacktestStats.from_balance(bal)\n        tbl = s.lookback_table()\n        assert not tbl.empty\n        assert \"MTD\" in tbl.columns\n\n    def test_lookback_table_empty_when_no_data(self):\n        s = BacktestStats()\n        tbl = s.lookback_table()\n        assert tbl.empty\n\n\nclass TestToDataframe:\n    def test_has_expected_rows(self):\n        rets = [0.002] * 60\n        bal = _make_balance(rets)\n        s = BacktestStats.from_balance(bal)\n        df = s.to_dataframe()\n        assert \"Total return\" in df.index\n        assert \"Sharpe ratio\" in df.index\n        assert \"Herfindahl index\" in df.index\n\n\nclass TestFromBalanceSharpe:\n    def test_positive_returns_with_variance(self):\n        rng = np.random.default_rng(42)\n        rets = rng.normal(0.01, 0.005, 50).tolist()\n        bal = _make_balance(rets)\n        s = BacktestStats.from_balance(bal)\n        assert s.sharpe_ratio > 0\n\n    def test_all_positive_sortino_zero(self):\n        rets = [0.01] * 50\n        bal = _make_balance(rets)\n        s = BacktestStats.from_balance(bal)\n        # No downside returns -> sortino should be 0\n        assert s.sortino_ratio == 0.0\n\n    def test_mixed_returns_sortino_nonzero(self):\n        rets = ([0.02, -0.01, 0.015, -0.005] * 10)\n        bal = _make_balance(rets)\n        s = BacktestStats.from_balance(bal)\n        assert s.sortino_ratio != 0.0\n\n\nclass TestFromBalanceDispatch:\n    \"\"\"Test the from_balance classmethod.\"\"\"\n\n    def test_from_balance_empty(self):\n        bal = pd.DataFrame(columns=[\"total capital\", \"% change\"])\n        s = BacktestStats.from_balance(bal)\n        assert s.total_return == 0.0\n\n    def test_from_balance_basic(self):\n        rets = [0.01] * 20\n        bal = _make_balance(rets)\n        s = BacktestStats.from_balance(bal)\n        assert s.total_return > 0\n        assert s.annualized_return > 0\n\n    def test_from_balance_with_trade_pnls(self):\n        rets = [0.01] * 20\n        bal = _make_balance(rets)\n        pnls = np.array([100.0, -50.0, 200.0])\n        s = BacktestStats.from_balance(bal, trade_pnls=pnls)\n        assert s.total_trades == 3\n\n\nclass TestFromBalanceRange:\n    \"\"\"Test the from_balance_range classmethod.\"\"\"\n\n    def test_empty_balance(self):\n        bal = pd.DataFrame(columns=[\"total capital\"])\n        s = BacktestStats.from_balance_range(bal)\n        assert s.total_return == 0.0\n\n    def test_full_range(self):\n        rets = [0.01] * 30\n        bal = _make_balance(rets)\n        s = BacktestStats.from_balance_range(bal)\n        assert s.total_return > 0\n\n    def test_with_start(self):\n        rets = [0.01] * 30\n        bal = _make_balance(rets, start=\"2020-01-01\")\n        # Slice from 10 business days in\n        s = BacktestStats.from_balance_range(bal, start=\"2020-01-15\")\n        assert s.total_return > 0\n\n    def test_with_end(self):\n        rets = [0.01] * 30\n        bal = _make_balance(rets, start=\"2020-01-01\")\n        s = BacktestStats.from_balance_range(bal, end=\"2020-01-15\")\n        assert s.total_return > 0\n\n    def test_with_start_and_end(self):\n        rets = [0.01] * 30\n        bal = _make_balance(rets, start=\"2020-01-01\")\n        s = BacktestStats.from_balance_range(bal, start=\"2020-01-10\", end=\"2020-01-20\")\n        assert s.total_return > 0\n\n    def test_out_of_range_returns_empty(self):\n        rets = [0.01] * 10\n        bal = _make_balance(rets, start=\"2020-01-01\")\n        s = BacktestStats.from_balance_range(bal, start=\"2025-01-01\")\n        assert s.total_return == 0.0\n"
  },
  {
    "path": "tests/analytics/test_summary.py",
    "content": "\"\"\"Tests for analytics/summary.py — the legacy summary statistics function.\"\"\"\n\nimport numpy as np\nimport pandas as pd\n\nfrom options_portfolio_backtester.core.types import Order\nfrom options_portfolio_backtester.analytics.summary import summary\n\n\ndef _make_trade_log_and_balance():\n    \"\"\"Build a minimal MultiIndex trade log and balance for testing summary().\"\"\"\n    leg = \"leg_1\"\n    entries = pd.DataFrame({\n        (leg, \"contract\"): [\"SPY_C_001\", \"SPY_C_002\"],\n        (leg, \"underlying\"): [\"SPY\", \"SPY\"],\n        (leg, \"expiration\"): pd.to_datetime([\"2020-03-20\", \"2020-03-20\"]),\n        (leg, \"type\"): [\"call\", \"call\"],\n        (leg, \"strike\"): [320.0, 325.0],\n        (leg, \"cost\"): [-500.0, -400.0],\n        (leg, \"order\"): [Order.BTO, Order.BTO],\n        (\"totals\", \"cost\"): [-500.0, -400.0],\n        (\"totals\", \"qty\"): [2, 3],\n        (\"totals\", \"date\"): pd.to_datetime([\"2020-01-15\", \"2020-01-20\"]),\n    })\n    exits = pd.DataFrame({\n        (leg, \"contract\"): [\"SPY_C_001\", \"SPY_C_002\"],\n        (leg, \"underlying\"): [\"SPY\", \"SPY\"],\n        (leg, \"expiration\"): pd.to_datetime([\"2020-03-20\", \"2020-03-20\"]),\n        (leg, \"type\"): [\"call\", \"call\"],\n        (leg, \"strike\"): [320.0, 325.0],\n        (leg, \"cost\"): [600.0, 350.0],\n        (leg, \"order\"): [Order.STC, Order.STC],\n        (\"totals\", \"cost\"): [600.0, 350.0],\n        (\"totals\", \"qty\"): [2, 3],\n        (\"totals\", \"date\"): pd.to_datetime([\"2020-02-15\", \"2020-02-20\"]),\n    })\n    entries.columns = pd.MultiIndex.from_tuples(entries.columns)\n    exits.columns = pd.MultiIndex.from_tuples(exits.columns)\n    trade_log = pd.concat([entries, exits], ignore_index=True)\n\n    dates = pd.date_range(\"2020-01-10\", periods=30, freq=\"B\")\n    capital = np.linspace(1_000_000, 1_050_000, 30)\n    balance = pd.DataFrame({\"total capital\": capital}, index=dates)\n    balance[\"% change\"] = balance[\"total capital\"].pct_change()\n\n    return trade_log, balance\n\n\nclass TestSummary:\n    def test_returns_styler(self):\n        trade_log, balance = _make_trade_log_and_balance()\n        result = summary(trade_log, balance)\n        assert isinstance(result, pd.io.formats.style.Styler)\n\n    def test_summary_has_expected_rows(self):\n        trade_log, balance = _make_trade_log_and_balance()\n        result = summary(trade_log, balance)\n        df = result.data\n        assert \"Total trades\" in df.index\n        assert \"Win %\" in df.index\n        assert \"Average P&L %\" in df.index\n        assert \"Total P&L %\" in df.index\n\n    def test_total_trades_count(self):\n        trade_log, balance = _make_trade_log_and_balance()\n        result = summary(trade_log, balance)\n        df = result.data\n        total_trades = df.loc[\"Total trades\", \"Strategy\"]\n        assert total_trades == 2\n\n    def test_win_metrics(self):\n        trade_log, balance = _make_trade_log_and_balance()\n        result = summary(trade_log, balance)\n        df = result.data\n        wins = df.loc[\"Number of wins\", \"Strategy\"]\n        losses = df.loc[\"Number of losses\", \"Strategy\"]\n        assert wins + losses == df.loc[\"Total trades\", \"Strategy\"]\n\n    def test_summary_with_missing_exit(self):\n        \"\"\"When an exit is missing for a contract, IndexError branch is hit.\"\"\"\n        leg = \"leg_1\"\n        # Entry for contract A, but no matching exit\n        entries = pd.DataFrame({\n            (leg, \"contract\"): [\"SPY_C_ORPHAN\"],\n            (leg, \"underlying\"): [\"SPY\"],\n            (leg, \"expiration\"): pd.to_datetime([\"2020-03-20\"]),\n            (leg, \"type\"): [\"call\"],\n            (leg, \"strike\"): [320.0],\n            (leg, \"cost\"): [-500.0],\n            (leg, \"order\"): [Order.BTO],\n            (\"totals\", \"cost\"): [-500.0],\n            (\"totals\", \"qty\"): [2],\n            (\"totals\", \"date\"): pd.to_datetime([\"2020-01-15\"]),\n        })\n        # Exit for a different contract\n        exits = pd.DataFrame({\n            (leg, \"contract\"): [\"SPY_C_OTHER\"],\n            (leg, \"underlying\"): [\"SPY\"],\n            (leg, \"expiration\"): pd.to_datetime([\"2020-03-20\"]),\n            (leg, \"type\"): [\"call\"],\n            (leg, \"strike\"): [325.0],\n            (leg, \"cost\"): [600.0],\n            (leg, \"order\"): [Order.STC],\n            (\"totals\", \"cost\"): [600.0],\n            (\"totals\", \"qty\"): [2],\n            (\"totals\", \"date\"): pd.to_datetime([\"2020-02-15\"]),\n        })\n        entries.columns = pd.MultiIndex.from_tuples(entries.columns)\n        exits.columns = pd.MultiIndex.from_tuples(exits.columns)\n        trade_log = pd.concat([entries, exits], ignore_index=True)\n\n        dates = pd.date_range(\"2020-01-10\", periods=10, freq=\"B\")\n        balance = pd.DataFrame({\n            \"total capital\": np.linspace(1_000_000, 1_010_000, 10),\n        }, index=dates)\n        balance[\"% change\"] = balance[\"total capital\"].pct_change()\n\n        result = summary(trade_log, balance)\n        df = result.data\n        # Should still produce output — the orphan contract is skipped\n        assert \"Total trades\" in df.index\n"
  },
  {
    "path": "tests/analytics/test_tearsheet.py",
    "content": "from __future__ import annotations\n\nfrom unittest.mock import patch\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\n\nfrom options_portfolio_backtester.analytics.tearsheet import (\n    build_tearsheet,\n    drawdown_series,\n    monthly_return_table,\n)\n\n\ndef _balance(periods: int = 40) -> pd.DataFrame:\n    idx = pd.date_range(\"2024-01-01\", periods=periods, freq=\"B\")\n    total = [100_000.0]\n    for i in range(1, len(idx)):\n        total.append(total[-1] * (1.0 + (0.001 if i % 3 else -0.0005)))\n    bal = pd.DataFrame({\"total capital\": total}, index=idx)\n    bal[\"% change\"] = bal[\"total capital\"].pct_change()\n    return bal\n\n\n# ---------------------------------------------------------------------------\n# build_tearsheet\n# ---------------------------------------------------------------------------\n\ndef test_build_tearsheet_has_expected_artifacts():\n    report = build_tearsheet(_balance(), trade_pnls=[100.0, -50.0, 70.0])\n    assert report.stats.total_trades == 3\n    assert not report.stats_table.empty\n    assert \"Value\" in report.stats_table.columns\n    assert isinstance(report.monthly_returns, pd.DataFrame)\n    assert isinstance(report.drawdown_series, pd.Series)\n\n\ndef test_build_tearsheet_no_trades():\n    report = build_tearsheet(_balance())\n    assert report.stats.total_trades == 0\n\n\ndef test_build_tearsheet_with_risk_free_rate():\n    report = build_tearsheet(_balance(), risk_free_rate=0.04)\n    assert report.stats is not None\n\n\n# ---------------------------------------------------------------------------\n# to_dict\n# ---------------------------------------------------------------------------\n\ndef test_tearsheet_to_dict_shape():\n    report = build_tearsheet(_balance())\n    d = report.to_dict()\n    assert \"stats\" in d\n    assert \"stats_table\" in d\n    assert \"monthly_returns\" in d\n    assert \"drawdown_series\" in d\n\n\n# ---------------------------------------------------------------------------\n# Exports: CSV, HTML, Markdown\n# ---------------------------------------------------------------------------\n\ndef test_tearsheet_exports(tmp_path: Path):\n    report = build_tearsheet(_balance())\n    files = report.to_csv(tmp_path)\n    assert files[\"stats_table\"].exists()\n    assert files[\"monthly_returns\"].exists()\n    assert files[\"drawdown_series\"].exists()\n    assert \"<html>\" in report.to_html()\n    assert \"# Tearsheet\" in report.to_markdown()\n\n\ndef test_csv_creates_directories(tmp_path: Path):\n    nested = tmp_path / \"a\" / \"b\" / \"c\"\n    report = build_tearsheet(_balance())\n    files = report.to_csv(nested)\n    assert nested.exists()\n    assert files[\"stats_table\"].exists()\n\n\ndef test_html_contains_tables():\n    report = build_tearsheet(_balance())\n    html = report.to_html()\n    assert \"stats-table\" in html\n    assert \"monthly-returns\" in html or \"No monthly returns\" in html\n\n\n# ---------------------------------------------------------------------------\n# to_markdown fallback (item 15)\n# ---------------------------------------------------------------------------\n\ndef test_tearsheet_markdown_fallback_without_tabulate():\n    report = build_tearsheet(_balance())\n    # Patch to_markdown to raise so the except fallback fires\n    with patch.object(pd.DataFrame, \"to_markdown\", side_effect=ImportError(\"no tabulate\")):\n        md = report.to_markdown()\n    assert \"# Tearsheet\" in md\n    assert \"Summary\" in md\n\n\n# ---------------------------------------------------------------------------\n# monthly_return_table\n# ---------------------------------------------------------------------------\n\ndef test_monthly_return_table_has_year_month_structure():\n    bal = _balance(periods=120)  # ~6 months of business days\n    tbl = monthly_return_table(bal)\n    if not tbl.empty:\n        assert tbl.index.name == \"year\"\n        assert all(isinstance(c, int) for c in tbl.columns)\n\n\ndef test_monthly_return_table_empty_balance():\n    empty = pd.DataFrame(columns=[\"total capital\", \"% change\"])\n    assert monthly_return_table(empty).empty\n\n\ndef test_monthly_return_table_no_pct_change():\n    bal = pd.DataFrame({\"total capital\": [100, 101]}, index=pd.date_range(\"2024-01-01\", periods=2))\n    assert monthly_return_table(bal).empty\n\n\n# ---------------------------------------------------------------------------\n# drawdown_series\n# ---------------------------------------------------------------------------\n\ndef test_drawdown_series_shape():\n    bal = _balance()\n    dd = drawdown_series(bal)\n    assert len(dd) == len(bal)\n    assert (dd <= 0).all()  # drawdowns are always <= 0\n\n\ndef test_drawdown_series_peak_at_start():\n    idx = pd.date_range(\"2024-01-01\", periods=5, freq=\"B\")\n    bal = pd.DataFrame({\"total capital\": [100, 90, 80, 85, 95]}, index=idx)\n    dd = drawdown_series(bal)\n    assert dd.iloc[0] == 0.0  # first point is always 0\n    assert dd.iloc[2] == -0.2  # 80/100 - 1 = -0.2\n\n\ndef test_drawdown_series_empty():\n    empty = pd.DataFrame(columns=[\"total capital\"])\n    dd = drawdown_series(empty)\n    assert dd.empty\n\n\ndef test_drawdown_series_no_total_capital():\n    bad = pd.DataFrame({\"other\": [1, 2]}, index=pd.date_range(\"2024-01-01\", periods=2))\n    dd = drawdown_series(bad)\n    assert dd.empty\n\n\n# ---------------------------------------------------------------------------\n# Edge cases\n# ---------------------------------------------------------------------------\n\ndef test_build_tearsheet_single_day():\n    bal = pd.DataFrame(\n        {\"total capital\": [100_000.0], \"% change\": [np.nan]},\n        index=pd.date_range(\"2024-01-01\", periods=1),\n    )\n    report = build_tearsheet(bal)\n    assert report.monthly_returns.empty or not report.monthly_returns.empty\n    assert isinstance(report.drawdown_series, pd.Series)\n\n\ndef test_build_tearsheet_flat_returns():\n    idx = pd.date_range(\"2024-01-01\", periods=20, freq=\"B\")\n    bal = pd.DataFrame({\"total capital\": [100_000.0] * 20}, index=idx)\n    bal[\"% change\"] = bal[\"total capital\"].pct_change()\n    report = build_tearsheet(bal)\n    dd = report.drawdown_series\n    # No drawdown for flat returns\n    assert (dd.dropna() == 0).all()\n"
  },
  {
    "path": "tests/analytics/test_trade_log.py",
    "content": "\"\"\"Tests for structured TradeLog.\"\"\"\n\nimport numpy as np\nimport pandas as pd\n\nfrom options_portfolio_backtester.analytics.trade_log import Trade, TradeLog\nfrom options_portfolio_backtester.core.types import Order\n\n\ndef _make_trade(pnl_sign: float = 1.0) -> Trade:\n    return Trade(\n        contract=\"SPY_C_500\",\n        underlying=\"SPY\",\n        option_type=\"call\",\n        strike=500.0,\n        entry_date=pd.Timestamp(\"2024-01-01\"),\n        exit_date=pd.Timestamp(\"2024-02-01\"),\n        entry_price=5.0,\n        exit_price=5.0 + pnl_sign * 2.0,\n        quantity=10,\n        shares_per_contract=100,\n        entry_order=Order.BTO,\n        exit_order=Order.STC,\n    )\n\n\nclass TestTrade:\n    def test_gross_pnl(self):\n        t = _make_trade(pnl_sign=1.0)\n        # (7-5) * 10 * 100 = 2000\n        assert t.gross_pnl == 2000.0\n\n    def test_gross_pnl_loss(self):\n        t = _make_trade(pnl_sign=-1.0)\n        # (3-5) * 10 * 100 = -2000\n        assert t.gross_pnl == -2000.0\n\n    def test_net_pnl_with_commission(self):\n        t = Trade(\n            contract=\"X\", underlying=\"SPY\", option_type=\"call\",\n            strike=500.0, entry_date=\"2024-01-01\", exit_date=\"2024-02-01\",\n            entry_price=5.0, exit_price=7.0,\n            quantity=10, shares_per_contract=100,\n            entry_order=Order.BTO, exit_order=Order.STC,\n            entry_commission=6.50, exit_commission=6.50,\n        )\n        assert t.net_pnl == 2000.0 - 13.0\n\n    def test_return_pct(self):\n        t = _make_trade(pnl_sign=1.0)\n        # gross=2000, entry_cost=5*10*100=5000, return=40%\n        assert abs(t.return_pct - 0.40) < 1e-10\n\n\nclass TestTradeLog:\n    def test_add_and_len(self):\n        tl = TradeLog()\n        tl.add_trade(_make_trade(1.0))\n        tl.add_trade(_make_trade(-1.0))\n        assert len(tl) == 2\n\n    def test_winners_losers(self):\n        tl = TradeLog()\n        tl.add_trade(_make_trade(1.0))\n        tl.add_trade(_make_trade(-1.0))\n        tl.add_trade(_make_trade(1.0))\n        assert len(tl.winners) == 2\n        assert len(tl.losers) == 1\n\n    def test_net_pnls(self):\n        tl = TradeLog()\n        tl.add_trade(_make_trade(1.0))\n        tl.add_trade(_make_trade(-1.0))\n        pnls = tl.net_pnls\n        assert len(pnls) == 2\n        assert pnls[0] == 2000.0\n        assert pnls[1] == -2000.0\n\n    def test_to_dataframe(self):\n        tl = TradeLog()\n        tl.add_trade(_make_trade(1.0))\n        df = tl.to_dataframe()\n        assert \"net_pnl\" in df.columns\n        assert \"return_pct\" in df.columns\n        assert len(df) == 1\n\n    def test_empty_to_dataframe(self):\n        tl = TradeLog()\n        df = tl.to_dataframe()\n        assert len(df) == 0\n\n    def test_from_legacy_empty(self):\n        tl = TradeLog.from_legacy_trade_log(pd.DataFrame())\n        assert len(tl) == 0\n\n    def test_from_legacy_trade_log(self):\n        \"\"\"Build a MultiIndex trade log and verify round-trip parsing.\"\"\"\n        leg = \"leg_1\"\n        entries = pd.DataFrame({\n            (leg, \"contract\"): [\"SPY_C_001\"],\n            (leg, \"underlying\"): [\"SPY\"],\n            (leg, \"expiration\"): pd.to_datetime([\"2024-03-15\"]),\n            (leg, \"type\"): [\"call\"],\n            (leg, \"strike\"): [450.0],\n            (leg, \"cost\"): [-500.0],\n            (leg, \"order\"): [Order.BTO],\n            (\"totals\", \"cost\"): [-500.0],\n            (\"totals\", \"qty\"): [5],\n            (\"totals\", \"date\"): pd.to_datetime([\"2024-01-15\"]),\n        })\n        exits = pd.DataFrame({\n            (leg, \"contract\"): [\"SPY_C_001\"],\n            (leg, \"underlying\"): [\"SPY\"],\n            (leg, \"expiration\"): pd.to_datetime([\"2024-03-15\"]),\n            (leg, \"type\"): [\"call\"],\n            (leg, \"strike\"): [450.0],\n            (leg, \"cost\"): [600.0],\n            (leg, \"order\"): [Order.STC],\n            (\"totals\", \"cost\"): [600.0],\n            (\"totals\", \"qty\"): [5],\n            (\"totals\", \"date\"): pd.to_datetime([\"2024-02-15\"]),\n        })\n        entries.columns = pd.MultiIndex.from_tuples(entries.columns)\n        exits.columns = pd.MultiIndex.from_tuples(exits.columns)\n        trade_log_df = pd.concat([entries, exits], ignore_index=True)\n\n        tl = TradeLog.from_legacy_trade_log(trade_log_df)\n        assert len(tl) == 1\n        assert tl.trades[0].contract == \"SPY_C_001\"\n        assert tl.trades[0].quantity == 5\n\n    def test_return_pct_zero_entry(self):\n        t = Trade(\n            contract=\"X\", underlying=\"SPY\", option_type=\"call\",\n            strike=500.0, entry_date=\"2024-01-01\", exit_date=\"2024-02-01\",\n            entry_price=0.0, exit_price=1.0,\n            quantity=10, shares_per_contract=100,\n            entry_order=Order.BTO, exit_order=Order.STC,\n        )\n        assert t.return_pct == 0.0\n"
  },
  {
    "path": "tests/bench/__init__.py",
    "content": ""
  },
  {
    "path": "tests/bench/_test_helpers.py",
    "content": "\"\"\"Shared helpers for bench regression tests.\"\"\"\n\nfrom __future__ import annotations\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nimport pytest\n\ntry:\n    from options_portfolio_backtester._ob_rust import run_backtest_py  # noqa: F401\n    RUST_AVAILABLE = True\nexcept ImportError:\n    RUST_AVAILABLE = False\n\n_TEST_DIR = os.path.join(os.path.dirname(__file__), \"..\", \"test_data\")\n_DATA_DIR = os.path.join(os.path.dirname(__file__), \"..\", \"data\")\n\n# ── Constants ──────────────────────────────────────────────────────────\n\nDEFAULT_ALLOC = {\"stocks\": 0.6, \"options\": 0.3, \"cash\": 0.1}\nDEFAULT_CAPITAL = 1_000_000\n\nIVY_STOCKS_TUPLES = [\n    (\"VTI\", 0.2), (\"VEU\", 0.2), (\"BND\", 0.2), (\"VNQ\", 0.2), (\"DBC\", 0.2),\n]\n\nGENERATED_STOCKS_TUPLES = [\n    (\"VOO\", 0.20), (\"TLT\", 0.20), (\"EWY\", 0.15),\n    (\"PDBC\", 0.15), (\"IAU\", 0.10), (\"VNQI\", 0.10), (\"VTIP\", 0.10),\n]\n\nPROD_SPY_STOCKS_TUPLES = [(\"SPY\", 1.0)]\n\nPROD_SLICES = {\n    \"spy_crisis\": {\"stocks_tuples\": [(\"SPY\", 1.0)], \"underlying\": \"SPY\"},\n    \"spy_lowvol\": {\"stocks_tuples\": [(\"SPY\", 1.0)], \"underlying\": \"SPY\"},\n    \"spy_covid\":  {\"stocks_tuples\": [(\"SPY\", 1.0)], \"underlying\": \"SPY\"},\n    \"spy_bear\":   {\"stocks_tuples\": [(\"SPY\", 1.0)], \"underlying\": \"SPY\"},\n    \"iwm_2020\":   {\"stocks_tuples\": [(\"IWM\", 1.0)], \"underlying\": \"IWM\"},\n    \"qqq_2020\":   {\"stocks_tuples\": [(\"QQQ\", 1.0)], \"underlying\": \"QQQ\"},\n}\n\nSTRATEGY_MAP: dict = {}  # populated after strategy builder definitions\n\n\n# ── Stock lists ────────────────────────────────────────────────────────\n\ndef _stocks_from_tuples(tuples):\n    from options_portfolio_backtester.core.types import Stock\n    return [Stock(sym, pct) for sym, pct in tuples]\n\n\ndef ivy_stocks():\n    return _stocks_from_tuples(IVY_STOCKS_TUPLES)\n\n\ndef generated_stocks():\n    return _stocks_from_tuples(GENERATED_STOCKS_TUPLES)\n\n\ndef prod_spy_stocks():\n    return _stocks_from_tuples(PROD_SPY_STOCKS_TUPLES)\n\n\ndef slice_stocks(slice_id):\n    return _stocks_from_tuples(PROD_SLICES[slice_id][\"stocks_tuples\"])\n\n\n# ── Data loaders ───────────────────────────────────────────────────────\n\ndef load_small_stocks():\n    from options_portfolio_backtester.data.providers import TiingoData\n    s = TiingoData(os.path.join(_TEST_DIR, \"ivy_5assets_data.csv\"))\n    s._data[\"adjClose\"] = 10\n    return s\n\n\ndef load_small_options():\n    from options_portfolio_backtester.data.providers import HistoricalOptionsData\n    o = HistoricalOptionsData(os.path.join(_TEST_DIR, \"options_data.csv\"))\n    o._data.at[2, \"ask\"] = 1\n    o._data.at[2, \"bid\"] = 0.5\n    o._data.at[51, \"ask\"] = 1.5\n    o._data.at[50, \"bid\"] = 0.5\n    o._data.at[130, \"bid\"] = 0.5\n    o._data.at[131, \"bid\"] = 1.5\n    o._data.at[206, \"bid\"] = 0.5\n    o._data.at[207, \"bid\"] = 1.5\n    return o\n\n\ndef load_large_stocks():\n    from options_portfolio_backtester.data.providers import TiingoData\n    return TiingoData(os.path.join(_TEST_DIR, \"test_data_stocks.csv\"))\n\n\ndef load_large_options():\n    from options_portfolio_backtester.data.providers import HistoricalOptionsData\n    return HistoricalOptionsData(os.path.join(_TEST_DIR, \"test_data_options.csv\"))\n\n\ndef load_generated_stocks():\n    from options_portfolio_backtester.data.providers import TiingoData\n    return TiingoData(os.path.join(_DATA_DIR, \"large_stocks.csv\"))\n\n\ndef load_generated_options():\n    from options_portfolio_backtester.data.providers import HistoricalOptionsData\n    return HistoricalOptionsData(os.path.join(_DATA_DIR, \"large_options.csv\"))\n\n\ndef load_prod_stocks():\n    from options_portfolio_backtester.data.providers import TiingoData\n    return TiingoData(os.path.join(_DATA_DIR, \"prod_stocks_1y.csv\"))\n\n\ndef load_prod_options():\n    from options_portfolio_backtester.data.providers import HistoricalOptionsData\n    return HistoricalOptionsData(os.path.join(_DATA_DIR, \"prod_options_1y.csv\"))\n\n\ndef slice_data_exists(slice_id):\n    return (\n        os.path.isfile(os.path.join(_DATA_DIR, f\"{slice_id}_stocks.csv\"))\n        and os.path.isfile(os.path.join(_DATA_DIR, f\"{slice_id}_options.csv\"))\n    )\n\n\ndef load_slice_stocks(slice_id):\n    from options_portfolio_backtester.data.providers import TiingoData\n    return TiingoData(os.path.join(_DATA_DIR, f\"{slice_id}_stocks.csv\"))\n\n\ndef load_slice_options(slice_id):\n    from options_portfolio_backtester.data.providers import HistoricalOptionsData\n    return HistoricalOptionsData(\n        os.path.join(_DATA_DIR, f\"{slice_id}_options.csv\")\n    )\n\n\n# ── Strategy builders ─────────────────────────────────────────────────\n\ndef buy_put_strategy(schema, underlying=\"SPX\", dte_min=60, dte_max=None,\n                     dte_exit=30):\n    from options_portfolio_backtester.strategy.strategy import Strategy\n    from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\n    from options_portfolio_backtester.core.types import OptionType as Type, Direction\n\n    strat = Strategy(schema)\n    leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT,\n                      direction=Direction.BUY)\n    filt = (schema.underlying == underlying) & (schema.dte >= dte_min)\n    if dte_max is not None:\n        filt = filt & (schema.dte <= dte_max)\n    leg.entry_filter = filt\n    leg.exit_filter = schema.dte <= dte_exit\n    strat.add_legs([leg])\n    return strat\n\n\ndef buy_call_strategy(schema, underlying=\"SPX\", dte_min=60, dte_exit=30):\n    from options_portfolio_backtester.strategy.strategy import Strategy\n    from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\n    from options_portfolio_backtester.core.types import OptionType as Type, Direction\n\n    strat = Strategy(schema)\n    leg = StrategyLeg(\"leg_1\", schema, option_type=Type.CALL,\n                      direction=Direction.BUY)\n    leg.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min)\n    leg.exit_filter = schema.dte <= dte_exit\n    strat.add_legs([leg])\n    return strat\n\n\ndef sell_put_strategy(schema, underlying=\"SPX\", dte_min=60, dte_exit=30):\n    from options_portfolio_backtester.strategy.strategy import Strategy\n    from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\n    from options_portfolio_backtester.core.types import OptionType as Type, Direction\n\n    strat = Strategy(schema)\n    leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT,\n                      direction=Direction.SELL)\n    leg.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min)\n    leg.exit_filter = schema.dte <= dte_exit\n    strat.add_legs([leg])\n    return strat\n\n\ndef sell_call_strategy(schema, underlying=\"SPX\", dte_min=60, dte_exit=30):\n    from options_portfolio_backtester.strategy.strategy import Strategy\n    from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\n    from options_portfolio_backtester.core.types import OptionType as Type, Direction\n\n    strat = Strategy(schema)\n    leg = StrategyLeg(\"leg_1\", schema, option_type=Type.CALL,\n                      direction=Direction.SELL)\n    leg.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min)\n    leg.exit_filter = schema.dte <= dte_exit\n    strat.add_legs([leg])\n    return strat\n\n\ndef strangle_strategy(schema, underlying=\"SPX\", dte_min=60, dte_exit=30):\n    from options_portfolio_backtester.strategy.strategy import Strategy\n    from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\n    from options_portfolio_backtester.core.types import OptionType as Type, Direction\n\n    strat = Strategy(schema)\n    leg1 = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.SELL)\n    leg1.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min)\n    leg1.exit_filter = schema.dte <= dte_exit\n    leg2 = StrategyLeg(\"leg_2\", schema, option_type=Type.CALL, direction=Direction.SELL)\n    leg2.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min)\n    leg2.exit_filter = schema.dte <= dte_exit\n    strat.add_legs([leg1, leg2])\n    return strat\n\n\ndef straddle_strategy(schema, underlying=\"SPX\", dte_min=60, dte_exit=30):\n    from options_portfolio_backtester.strategy.strategy import Strategy\n    from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\n    from options_portfolio_backtester.core.types import OptionType as Type, Direction\n\n    strat = Strategy(schema)\n    leg1 = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\n    leg1.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min)\n    leg1.exit_filter = schema.dte <= dte_exit\n    leg2 = StrategyLeg(\"leg_2\", schema, option_type=Type.CALL, direction=Direction.BUY)\n    leg2.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min)\n    leg2.exit_filter = schema.dte <= dte_exit\n    strat.add_legs([leg1, leg2])\n    return strat\n\n\ndef buy_put_spread_strategy(schema, underlying=\"SPX\", dte_min=60, dte_exit=30):\n    from options_portfolio_backtester.strategy.strategy import Strategy\n    from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\n    from options_portfolio_backtester.core.types import OptionType as Type, Direction\n\n    strat = Strategy(schema)\n    leg1 = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\n    leg1.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min)\n    leg1.exit_filter = schema.dte <= dte_exit\n    leg2 = StrategyLeg(\"leg_2\", schema, option_type=Type.PUT, direction=Direction.SELL)\n    leg2.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min)\n    leg2.exit_filter = schema.dte <= dte_exit\n    strat.add_legs([leg1, leg2])\n    return strat\n\n\ndef sell_call_spread_strategy(schema, underlying=\"SPX\", dte_min=60, dte_exit=30):\n    from options_portfolio_backtester.strategy.strategy import Strategy\n    from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\n    from options_portfolio_backtester.core.types import OptionType as Type, Direction\n\n    strat = Strategy(schema)\n    leg1 = StrategyLeg(\"leg_1\", schema, option_type=Type.CALL, direction=Direction.SELL)\n    leg1.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min)\n    leg1.exit_filter = schema.dte <= dte_exit\n    leg2 = StrategyLeg(\"leg_2\", schema, option_type=Type.CALL, direction=Direction.BUY)\n    leg2.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min)\n    leg2.exit_filter = schema.dte <= dte_exit\n    strat.add_legs([leg1, leg2])\n    return strat\n\n\ndef two_leg_strategy(schema, dir1, type1, dir2, type2,\n                     underlying=\"SPX\", dte_min=60, dte_exit=30):\n    from options_portfolio_backtester.strategy.strategy import Strategy\n    from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\n    from options_portfolio_backtester.core.types import OptionType as Type, Direction\n\n    type_map = {\"put\": Type.PUT, \"call\": Type.CALL}\n    dir_map = {\"buy\": Direction.BUY, \"sell\": Direction.SELL}\n\n    strat = Strategy(schema)\n    leg1 = StrategyLeg(\"leg_1\", schema,\n                       option_type=type_map[type1], direction=dir_map[dir1])\n    leg1.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min)\n    leg1.exit_filter = schema.dte <= dte_exit\n    leg2 = StrategyLeg(\"leg_2\", schema,\n                       option_type=type_map[type2], direction=dir_map[dir2])\n    leg2.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min)\n    leg2.exit_filter = schema.dte <= dte_exit\n    strat.add_legs([leg1, leg2])\n    return strat\n\n\n# Populate STRATEGY_MAP after definitions\nSTRATEGY_MAP.update({\n    \"buy_put\": buy_put_strategy,\n    \"buy_call\": buy_call_strategy,\n    \"sell_put\": sell_put_strategy,\n    \"sell_call\": sell_call_strategy,\n    \"buy_put_spread\": buy_put_spread_strategy,\n    \"sell_call_spread\": sell_call_spread_strategy,\n    \"strangle\": strangle_strategy,\n    \"straddle\": straddle_strategy,\n})\n\n\n# ── Execution model factories ─────────────────────────────────────────\n\ndef make_cost_model(name):\n    from options_portfolio_backtester.execution.cost_model import (\n        NoCosts, PerContractCommission, TieredCommission,\n    )\n    if name == \"NoCosts\":\n        return NoCosts()\n    elif name == \"PerContract\":\n        return PerContractCommission(rate=0.65)\n    elif name == \"Tiered\":\n        return TieredCommission(tiers=[(10_000, 0.65), (100_000, 0.50)])\n    raise ValueError(name)\n\n\ndef make_fill_model(name):\n    from options_portfolio_backtester.execution.fill_model import (\n        MarketAtBidAsk, MidPrice, VolumeAwareFill,\n    )\n    if name == \"MarketAtBidAsk\":\n        return MarketAtBidAsk()\n    elif name == \"MidPrice\":\n        return MidPrice()\n    elif name == \"VolumeAware\":\n        return VolumeAwareFill(full_volume_threshold=100)\n    raise ValueError(name)\n\n\ndef make_signal_selector(name):\n    from options_portfolio_backtester.execution.signal_selector import (\n        FirstMatch, NearestDelta, MaxOpenInterest,\n    )\n    if name == \"FirstMatch\":\n        return FirstMatch()\n    elif name == \"NearestDelta\":\n        return NearestDelta(target_delta=-0.30)\n    elif name == \"MaxOpenInterest\":\n        return MaxOpenInterest()\n    raise ValueError(name)\n\n\n# ── Engine runner ──────────────────────────────────────────────────────\n\ndef _make_engine(alloc, capital, stocks, stocks_data, options_data,\n                 strategy_fn, **engine_kwargs):\n    from options_portfolio_backtester.engine.engine import BacktestEngine\n    from options_portfolio_backtester.execution.cost_model import NoCosts\n    from options_portfolio_backtester.execution.fill_model import MarketAtBidAsk\n    from options_portfolio_backtester.execution.signal_selector import FirstMatch\n\n    engine_kwargs.setdefault(\"cost_model\", NoCosts())\n    engine_kwargs.setdefault(\"fill_model\", MarketAtBidAsk())\n    engine_kwargs.setdefault(\"signal_selector\", FirstMatch())\n\n    eng = BacktestEngine(alloc, initial_capital=capital, **engine_kwargs)\n    eng.stocks = stocks\n    eng.stocks_data = stocks_data\n    eng.options_data = options_data\n    eng.options_strategy = strategy_fn(options_data.schema)\n    return eng\n\n\ndef run_backtest(alloc=None, capital=None, strategy_fn=None,\n                 rebalance_freq=1, rebalance_unit='BMS',\n                 stocks=None, stocks_data=None, options_data=None,\n                 sma_days=None, **engine_kwargs):\n    \"\"\"Run a single backtest and return the engine.\"\"\"\n    s = stocks_data or load_small_stocks()\n    o = options_data or load_small_options()\n    stks = stocks or ivy_stocks()\n    a = alloc or DEFAULT_ALLOC\n    c = capital or DEFAULT_CAPITAL\n    sf = strategy_fn or buy_put_strategy\n\n    eng = _make_engine(a, c, stks, s, o, sf, **engine_kwargs)\n    eng.run(rebalance_freq=rebalance_freq, sma_days=sma_days,\n            rebalance_unit=rebalance_unit)\n    return eng\n\n\n# ── Invariant assertions ──────────────────────────────────────────────\n\ndef assert_invariants(eng, min_trades=0, label=\"\", allow_negative_capital=False):\n    \"\"\"Assert standard invariants on a single backtest engine result.\"\"\"\n    prefix = f\"[{label}] \" if label else \"\"\n\n    bal = eng.balance\n    assert len(bal) > 0, f\"{prefix}empty balance\"\n\n    tc = bal[\"total capital\"]\n\n    # Total capital never negative (sell strategies can go negative)\n    if not allow_negative_capital:\n        assert (tc >= -1.0).all(), f\"{prefix}negative total capital: min={tc.min()}\"\n\n    # Balance dates monotonic\n    assert bal.index.is_monotonic_increasing, f\"{prefix}balance index not monotonic\"\n\n    # Cash column exists\n    assert \"cash\" in bal.columns, f\"{prefix}'cash' column missing\"\n\n    # Capital = sum of parts (skip first row — initial allocation)\n    if \"options capital\" in bal.columns and \"stocks capital\" in bal.columns:\n        reconstructed = bal[\"cash\"] + bal[\"stocks capital\"] + bal[\"options capital\"]\n        assert np.allclose(\n            tc.values[1:], reconstructed.values[1:],\n            rtol=1e-4, atol=1.0,\n        ), f\"{prefix}total capital != cash + stocks + options\"\n\n    # Trade log\n    if min_trades > 0:\n        assert len(eng.trade_log) >= min_trades, (\n            f\"{prefix}expected >= {min_trades} trades, got {len(eng.trade_log)}\"\n        )\n\n    # Entry quantities positive\n    if not eng.trade_log.empty:\n        qtys = eng.trade_log[\"totals\"][\"qty\"].values\n        assert all(q > 0 for q in qtys), f\"{prefix}found non-positive qty\"\n\n    # No negative stock quantities (sell strategies can cause negative via margin)\n    if not allow_negative_capital:\n        for col in bal.columns:\n            if col.endswith(\" qty\"):\n                vals = pd.to_numeric(bal[col], errors=\"coerce\").dropna()\n                assert (vals >= -0.01).all(), (\n                    f\"{prefix}negative qty in '{col}': min={vals.min()}\"\n                )\n"
  },
  {
    "path": "tests/bench/extract_prod_slices.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Extract diverse time-period slices from raw parquet data for parity testing.\n\nReads raw options + underlying parquets and outputs backtester-format CSV slices\ninto tests/data/. Uses the same column mapping as data/fetch_data.py.\n\nUsage:\n    python tests/bench/extract_prod_slices.py\n\"\"\"\n\nfrom __future__ import annotations\n\nimport sys\nfrom pathlib import Path\n\nimport pandas as pd\nimport pyarrow.parquet as pq\n\nPROJECT_ROOT = Path(__file__).resolve().parent.parent.parent\nDATA_DIR = PROJECT_ROOT / \"tests\" / \"data\"\n\n# ── Slice definitions ─────────────────────────────────────────────────\n\nSLICES = {\n    \"spy_crisis\": {\n        \"options_parquet\": \"data/raw/release/SPY_options.parquet\",\n        \"underlying_parquet\": \"data/raw/release/SPY_underlying.parquet\",\n        \"symbol\": \"SPY\",\n        \"start\": \"2008-06-01\",\n        \"end\": \"2009-06-30\",\n    },\n    \"spy_lowvol\": {\n        \"options_parquet\": \"data/raw/release/SPY_options.parquet\",\n        \"underlying_parquet\": \"data/raw/release/SPY_underlying.parquet\",\n        \"symbol\": \"SPY\",\n        \"start\": \"2017-01-01\",\n        \"end\": \"2018-01-31\",\n    },\n    \"spy_covid\": {\n        \"options_parquet\": \"data/raw/release/SPY_options.parquet\",\n        \"underlying_parquet\": \"data/raw/release/SPY_underlying.parquet\",\n        \"symbol\": \"SPY\",\n        \"start\": \"2020-01-01\",\n        \"end\": \"2021-03-31\",\n    },\n    \"spy_bear\": {\n        \"options_parquet\": \"data/raw/release/SPY_options.parquet\",\n        \"underlying_parquet\": \"data/raw/release/SPY_underlying.parquet\",\n        \"symbol\": \"SPY\",\n        \"start\": \"2022-01-01\",\n        \"end\": \"2023-01-31\",\n    },\n    \"iwm_2020\": {\n        \"options_parquet\": \"data/raw/options-data/IWM/options.parquet\",\n        \"underlying_parquet\": \"data/raw/options-dataset-hist/IWM/underlying_prices.parquet\",\n        \"symbol\": \"IWM\",\n        \"start\": \"2020-01-01\",\n        \"end\": \"2021-01-31\",\n    },\n    \"qqq_2020\": {\n        \"options_parquet\": \"data/raw/options-data/QQQ/options.parquet\",\n        \"underlying_parquet\": \"data/raw/options-dataset-hist/QQQ/underlying_prices.parquet\",\n        \"symbol\": \"QQQ\",\n        \"start\": \"2020-01-01\",\n        \"end\": \"2021-01-01\",\n    },\n}\n\n\n# ── Column mapping (matches data/fetch_data.py lines 259-278) ────────\n\ndef _convert_options(opts: pd.DataFrame, symbol: str,\n                     und_prices: pd.DataFrame | None) -> pd.DataFrame:\n    \"\"\"Convert raw parquet options to backtester CSV format.\"\"\"\n    if und_prices is not None:\n        opts = opts.merge(und_prices, on=\"date\", how=\"left\")\n    else:\n        opts[\"underlying_last\"] = float(\"nan\")\n\n    if \"last\" in opts.columns:\n        _last = opts[\"last\"].fillna((opts[\"bid\"] + opts[\"ask\"]) / 2)\n    else:\n        _last = (opts[\"bid\"] + opts[\"ask\"]) / 2\n\n    return pd.DataFrame({\n        \"underlying\": symbol,\n        \"underlying_last\": opts[\"underlying_last\"].values,\n        \"optionroot\": opts[\"contract_id\"].values,\n        \"type\": opts[\"type\"].values,\n        \"expiration\": pd.to_datetime(opts[\"expiration\"]).values,\n        \"quotedate\": opts[\"date\"].values,\n        \"strike\": opts[\"strike\"].values,\n        \"last\": _last.values,\n        \"bid\": opts[\"bid\"].values,\n        \"ask\": opts[\"ask\"].values,\n        \"volume\": opts[\"volume\"].values,\n        \"openinterest\": opts[\"open_interest\"].values,\n        \"impliedvol\": opts[\"implied_volatility\"].values,\n        \"delta\": opts[\"delta\"].values,\n        \"gamma\": opts[\"gamma\"].values,\n        \"theta\": opts[\"theta\"].values,\n        \"vega\": opts[\"vega\"].values,\n        \"optionalias\": opts[\"contract_id\"].values,\n    })\n\n\ndef _convert_underlying(und: pd.DataFrame, symbol: str) -> pd.DataFrame:\n    \"\"\"Convert underlying parquet to Tiingo-format stocks CSV.\"\"\"\n    # Some datasets have None/NaN for adjusted_close, dividend_amount,\n    # split_coefficient.  Fall back to close (no adjustment) and safe defaults.\n    adj_close = und[\"adjusted_close\"].fillna(und[\"close\"])\n    div_cash = und[\"dividend_amount\"].fillna(0.0)\n    split_factor = und[\"split_coefficient\"].fillna(1.0)\n    ratio = adj_close / und[\"close\"]\n\n    return pd.DataFrame({\n        \"symbol\": symbol,\n        \"date\": und[\"date\"].values,\n        \"close\": und[\"close\"].values,\n        \"high\": und[\"high\"].values,\n        \"low\": und[\"low\"].values,\n        \"open\": und[\"open\"].values,\n        \"volume\": und[\"volume\"].values,\n        \"adjClose\": adj_close.values,\n        \"adjHigh\": (und[\"high\"] * ratio).values,\n        \"adjLow\": (und[\"low\"] * ratio).values,\n        \"adjOpen\": (und[\"open\"] * ratio).values,\n        \"adjVolume\": und[\"volume\"].values,\n        \"divCash\": div_cash.values,\n        \"splitFactor\": split_factor.values,\n    })\n\n\ndef extract_slice(slice_id: str, spec: dict) -> None:\n    \"\"\"Extract a single slice to CSV files.\"\"\"\n    options_path = PROJECT_ROOT / spec[\"options_parquet\"]\n    underlying_path = PROJECT_ROOT / spec[\"underlying_parquet\"]\n    symbol = spec[\"symbol\"]\n    start = pd.Timestamp(spec[\"start\"])\n    end = pd.Timestamp(spec[\"end\"])\n\n    if not options_path.exists():\n        print(f\"  SKIP {slice_id}: {options_path} not found\")\n        return\n    if not underlying_path.exists():\n        print(f\"  SKIP {slice_id}: {underlying_path} not found\")\n        return\n\n    # Read underlying\n    print(f\"  Reading underlying from {underlying_path.name}...\")\n    und = pd.read_parquet(underlying_path)\n    und[\"date\"] = pd.to_datetime(und[\"date\"])\n    und = und[(und[\"date\"] >= start) & (und[\"date\"] <= end)]\n    if und.empty:\n        print(f\"  SKIP {slice_id}: no underlying data in [{start.date()}, {end.date()}]\")\n        return\n\n    und = und.sort_values(\"date\")\n    stocks_df = _convert_underlying(und, symbol)\n    und_prices = und[[\"date\", \"close\"]].rename(columns={\"close\": \"underlying_last\"})\n\n    # Read options (use filters for efficiency on large parquets)\n    print(f\"  Reading options from {options_path.name} \"\n          f\"[{start.date()}, {end.date()}]...\")\n    opts = pd.read_parquet(options_path)\n    opts[\"date\"] = pd.to_datetime(opts[\"date\"])\n    opts = opts[(opts[\"date\"] >= start) & (opts[\"date\"] <= end)]\n    if opts.empty:\n        print(f\"  SKIP {slice_id}: no options data in [{start.date()}, {end.date()}]\")\n        return\n\n    options_df = _convert_options(opts, symbol, und_prices)\n    options_df = options_df.sort_values(\n        [\"quotedate\", \"underlying\", \"expiration\", \"strike\", \"type\"]\n    )\n\n    # Align dates: keep only days present in both stocks and options\n    stock_dates = set(pd.to_datetime(stocks_df[\"date\"]).dt.normalize())\n    option_dates = set(pd.to_datetime(options_df[\"quotedate\"]).dt.normalize())\n    shared = stock_dates & option_dates\n\n    stocks_df = stocks_df[\n        pd.to_datetime(stocks_df[\"date\"]).dt.normalize().isin(shared)\n    ]\n    options_df = options_df[\n        pd.to_datetime(options_df[\"quotedate\"]).dt.normalize().isin(shared)\n    ]\n\n    # Write CSVs\n    DATA_DIR.mkdir(parents=True, exist_ok=True)\n    stocks_out = DATA_DIR / f\"{slice_id}_stocks.csv\"\n    options_out = DATA_DIR / f\"{slice_id}_options.csv\"\n\n    stocks_df.to_csv(stocks_out, index=False)\n    options_df.to_csv(options_out, index=False)\n\n    print(f\"  {slice_id}: {len(stocks_df)} stock rows, \"\n          f\"{len(options_df)} option rows, \"\n          f\"{len(shared)} trading days → {stocks_out.name}, {options_out.name}\")\n\n\ndef main():\n    print(\"Extracting production slices for parity testing...\")\n    print(f\"Output directory: {DATA_DIR}\\n\")\n\n    for slice_id, spec in SLICES.items():\n        print(f\"[{slice_id}]\")\n        extract_slice(slice_id, spec)\n        print()\n\n    print(\"Done.\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "tests/bench/generate_test_data.py",
    "content": "\"\"\"Generate large deterministic synthetic datasets for 3-way parity tests.\n\nProduces stock and options CSVs with the same schema as the real test data\nin backtester/test/test_data/, but covering 500 trading days with fixed\nstrikes per expiration cycle (like real listed options).\n\nUsage:\n    python -m tests.bench.generate_test_data\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom math import erf, sqrt, log, exp\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\n\nSEED = 42\nOUTPUT_DIR = Path(__file__).resolve().parent.parent / \"data\"\n\n# 7 stocks matching test_data_stocks.csv symbols\nSTOCK_SYMBOLS = [\"VOO\", \"TLT\", \"EWY\", \"PDBC\", \"IAU\", \"VNQI\", \"VTIP\"]\nSTOCK_INITIAL_PRICES = [210.0, 120.0, 55.0, 18.0, 23.0, 52.0, 80.0]\n\nUNDERLYING = \"SPX\"\nN_TRADING_DAYS = 500\n\n\ndef _generate_trading_dates(n: int, start: str = \"2017-01-03\") -> pd.DatetimeIndex:\n    return pd.bdate_range(start=start, periods=n)\n\n\ndef _random_walk(rng: np.random.Generator, initial: float, n: int,\n                 drift: float = 0.0002, vol: float = 0.015) -> np.ndarray:\n    log_returns = rng.normal(drift, vol, size=n)\n    prices = initial * np.exp(np.cumsum(log_returns))\n    return np.round(prices, 6)\n\n\ndef generate_stocks(dates: pd.DatetimeIndex, rng: np.random.Generator) -> pd.DataFrame:\n    rows = []\n    for sym, p0 in zip(STOCK_SYMBOLS, STOCK_INITIAL_PRICES):\n        prices = _random_walk(rng, p0, len(dates))\n        for date, price in zip(dates, prices):\n            high = round(price * (1 + rng.uniform(0.001, 0.015)), 6)\n            low = round(price * (1 - rng.uniform(0.001, 0.015)), 6)\n            opn = round(price * (1 + rng.uniform(-0.005, 0.005)), 6)\n            vol = int(rng.integers(500_000, 10_000_000))\n            rows.append({\n                \"symbol\": sym, \"date\": date.strftime(\"%Y-%m-%d\"),\n                \"close\": round(price, 2), \"high\": round(high, 2),\n                \"low\": round(low, 2), \"open\": round(opn, 2),\n                \"volume\": vol, \"adjClose\": price, \"adjHigh\": high,\n                \"adjLow\": low, \"adjOpen\": opn, \"adjVolume\": vol,\n                \"divCash\": 0.0, \"splitFactor\": 1.0,\n            })\n    df = pd.DataFrame(rows)\n    df = df.sort_values([\"symbol\", \"date\"]).reset_index(drop=True)\n    return df\n\n\ndef _bs_approx(S: float, K: float, T: float, vol: float, is_call: bool) -> dict:\n    \"\"\"Quick Black-Scholes approximation for option pricing.\"\"\"\n    T = max(T, 1 / 365)\n    sqrt_T = sqrt(T)\n    d1 = (log(S / K) + (0.02 + 0.5 * vol**2) * T) / (vol * sqrt_T)\n    nd1 = 0.5 * (1 + erf(d1 / sqrt(2)))\n    if is_call:\n        delta = round(nd1, 4)\n        intrinsic = max(S - K, 0)\n    else:\n        delta = round(nd1 - 1, 4)\n        intrinsic = max(K - S, 0)\n    time_value = vol * S * sqrt_T * 0.4\n    mid_price = max(intrinsic + time_value * abs(delta), 0.05)\n    spread = max(mid_price * 0.03, 0.05)\n    bid = round(max(mid_price - spread / 2, 0.0), 2)\n    ask = round(mid_price + spread / 2, 2)\n    if bid <= 0:\n        bid = 0.0\n    gamma = round(max(0.0001, 0.01 * exp(-0.5 * d1**2) / (S * vol * sqrt_T)), 4)\n    theta = round(-S * vol * gamma / (2 * sqrt_T), 4)\n    vega = round(S * sqrt_T * gamma * 0.01, 4)\n    return {\n        \"bid\": bid, \"ask\": ask, \"last\": round((bid + ask) / 2, 2),\n        \"delta\": delta, \"gamma\": gamma, \"theta\": theta, \"vega\": vega,\n        \"impliedvol\": round(vol, 4),\n    }\n\n\ndef generate_options(dates: pd.DatetimeIndex, rng: np.random.Generator) -> pd.DataFrame:\n    \"\"\"Generate options data with FIXED strikes per expiration cycle.\n\n    Like real listed options: once an expiration is listed, its strikes\n    remain constant across all quote dates until expiration.\n    \"\"\"\n    underlying_prices = _random_walk(rng, 2260.0, len(dates), drift=0.0003, vol=0.01)\n\n    # Monthly expirations (3rd Friday)\n    all_expirations = pd.bdate_range(\n        start=dates[0] + pd.Timedelta(days=30),\n        end=dates[-1] + pd.Timedelta(days=150),\n        freq=\"WOM-3FRI\",\n    )\n\n    # Pre-compute FIXED strikes for each expiration based on the underlying\n    # price at the time the expiration first becomes active (~90 DTE).\n    # Use 5 strike levels: 90%, 95%, 100%, 105%, 110% of the reference price.\n    exp_strikes: dict[pd.Timestamp, list[int]] = {}\n    date_to_price = dict(zip(dates, underlying_prices))\n    for exp_date in all_expirations:\n        # Reference date: ~90 days before expiration\n        ref_date_target = exp_date - pd.Timedelta(days=90)\n        # Find the closest actual trading date\n        ref_date = min(dates, key=lambda d: abs((d - ref_date_target).days))\n        ref_price = date_to_price.get(ref_date, 2260.0)\n        # Round strikes to nearest 50 (like real SPX options)\n        exp_strikes[exp_date] = [\n            int(round(ref_price * pct / 50) * 50)\n            for pct in [0.90, 0.95, 1.00, 1.05, 1.10]\n        ]\n\n    rows = []\n    for i, (qdate, spx_price) in enumerate(zip(dates, underlying_prices)):\n        active_exps = [\n            exp_date for exp_date in all_expirations\n            if 5 <= (exp_date - qdate).days <= 120\n        ]\n        if not active_exps:\n            continue\n\n        exps_to_use = active_exps[:4]  # up to 4 active cycles\n        vol_base = 0.15 + 0.05 * np.sin(i / 50)\n\n        for exp_date in exps_to_use:\n            dte = (exp_date - qdate).days\n            exp_str = exp_date.strftime(\"%Y-%m-%d\")\n            T = dte / 365.0\n            strikes = exp_strikes[exp_date]\n\n            for strike in strikes:\n                for opt_type in [\"call\", \"put\"]:\n                    is_call = opt_type == \"call\"\n                    vol = vol_base + rng.uniform(-0.02, 0.02)\n                    greeks = _bs_approx(spx_price, strike, T, vol, is_call)\n\n                    exp_code = exp_date.strftime(\"%y%m%d\")\n                    type_code = \"C\" if is_call else \"P\"\n                    strike_code = f\"{int(strike):08d}00\"\n                    contract = f\"SPX{exp_code}{type_code}{strike_code}\"\n\n                    rows.append({\n                        \"underlying\": UNDERLYING,\n                        \"underlying_last\": round(spx_price, 2),\n                        \" exchange\": \"*\",\n                        \"optionroot\": contract,\n                        \"optionext\": \"\",\n                        \"type\": opt_type,\n                        \"expiration\": exp_str,\n                        \"quotedate\": qdate.strftime(\"%Y-%m-%d\"),\n                        \"strike\": strike,\n                        \"last\": greeks[\"last\"],\n                        \"bid\": greeks[\"bid\"],\n                        \"ask\": greeks[\"ask\"],\n                        \"volume\": int(rng.integers(0, 5000)),\n                        \"openinterest\": int(rng.integers(0, 50000)),\n                        \"impliedvol\": greeks[\"impliedvol\"],\n                        \"delta\": greeks[\"delta\"],\n                        \"gamma\": greeks[\"gamma\"],\n                        \"theta\": greeks[\"theta\"],\n                        \"vega\": greeks[\"vega\"],\n                        \"optionalias\": contract,\n                        \"dte\": dte,\n                    })\n\n    df = pd.DataFrame(rows)\n    df = df.sort_values([\"quotedate\", \"optionroot\"]).reset_index(drop=True)\n    return df\n\n\ndef main():\n    rng = np.random.default_rng(SEED)\n    dates = _generate_trading_dates(N_TRADING_DAYS)\n\n    print(f\"Generating {N_TRADING_DAYS} trading days: {dates[0].date()} to {dates[-1].date()}\")\n\n    stocks_df = generate_stocks(dates, rng)\n    print(f\"Stocks: {len(stocks_df)} rows, {stocks_df['symbol'].nunique()} symbols\")\n\n    options_df = generate_options(dates, rng)\n    print(f\"Options: {len(options_df)} rows, {options_df['quotedate'].nunique()} dates, \"\n          f\"~{len(options_df) / options_df['quotedate'].nunique():.0f} contracts/date\")\n\n    # Verify contracts persist across dates\n    sample_contract = options_df['optionroot'].iloc[0]\n    dates_for_contract = options_df[options_df['optionroot'] == sample_contract]['quotedate'].nunique()\n    print(f\"Sample contract {sample_contract} appears on {dates_for_contract} dates\")\n\n    os.makedirs(OUTPUT_DIR, exist_ok=True)\n    stocks_path = OUTPUT_DIR / \"large_stocks.csv\"\n    options_path = OUTPUT_DIR / \"large_options.csv\"\n\n    stocks_df.to_csv(stocks_path, index=False)\n    options_df.to_csv(options_path, index=False)\n    print(f\"\\nWritten:\\n  {stocks_path}\\n  {options_path}\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "tests/bench/test_edge_cases.py",
    "content": "\"\"\"Edge-case regression tests.\n\nEach test runs the backtest ONCE and checks invariants.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport pytest\n\nfrom tests.bench._test_helpers import (\n    RUST_AVAILABLE,\n    DEFAULT_ALLOC,\n    DEFAULT_CAPITAL,\n    _make_engine,\n    ivy_stocks,\n    load_small_stocks,\n    load_small_options,\n    buy_put_strategy,\n    buy_call_strategy,\n    sell_put_strategy,\n    run_backtest,\n    assert_invariants,\n)\n\npytestmark = pytest.mark.skipif(\n    not RUST_AVAILABLE, reason=\"Rust extension not installed\"\n)\n\n\n# ── Allocation edge cases ─────────────────────────────────────────────\n\nclass TestAllocationEdgeCases:\n\n    def test_zero_options(self):\n        alloc = {\"stocks\": 0.9, \"options\": 0.0, \"cash\": 0.1}\n        eng = run_backtest(alloc=alloc)\n        assert len(eng.balance) > 0\n        assert eng.trade_log.empty\n\n    def test_high_options(self):\n        alloc = {\"stocks\": 0.09, \"options\": 0.90, \"cash\": 0.01}\n        eng = run_backtest(alloc=alloc)\n        assert_invariants(eng, label=\"high_options\")\n\n    def test_tiny_stocks(self):\n        alloc = {\"stocks\": 0.01, \"options\": 0.89, \"cash\": 0.10}\n        eng = run_backtest(alloc=alloc)\n        assert_invariants(eng, label=\"tiny_stocks\")\n\n\n# ── Capital edge cases ────────────────────────────────────────────────\n\nclass TestCapitalEdgeCases:\n\n    def test_tiny_capital(self):\n        eng = run_backtest(capital=1_000)\n        assert_invariants(eng, label=\"tiny_capital\")\n\n    def test_huge_capital(self):\n        eng = run_backtest(capital=100_000_000)\n        assert_invariants(eng, label=\"huge_capital\")\n\n\n# ── Rebalance edge cases ─────────────────────────────────────────────\n\nclass TestRebalanceEdgeCases:\n\n    def test_weekly_rebalance(self):\n        eng = run_backtest(rebalance_unit=\"W-MON\")\n        assert_invariants(eng, min_trades=1, label=\"weekly_rebalance\")\n\n\n# ── Direction and type ────────────────────────────────────────────────\n\nclass TestDirectionAndType:\n\n    def test_sell_put(self):\n        eng = run_backtest(strategy_fn=sell_put_strategy)\n        # Sell strategies can go deeply negative (unlimited downside risk)\n        assert len(eng.balance) > 0\n        assert not eng.trade_log.empty\n\n    def test_buy_call(self):\n        eng = run_backtest(strategy_fn=buy_call_strategy)\n        assert_invariants(eng, label=\"buy_call\")\n\n\n# ── SMA gating ───────────────────────────────────────────────────────\n\nclass TestSMAGating:\n\n    def test_sma_50(self):\n        eng = run_backtest(sma_days=50)\n        assert_invariants(eng, label=\"sma_50\")\n\n\n# ── Options budget pct ───────────────────────────────────────────────\n\nclass TestOptionsBudgetPct:\n\n    def test_budget_limits_spending(self):\n        eng = _make_engine(\n            DEFAULT_ALLOC, DEFAULT_CAPITAL,\n            ivy_stocks(), load_small_stocks(), load_small_options(),\n            buy_put_strategy,\n        )\n        eng.options_budget_pct = 0.005\n        eng.run(rebalance_freq=1, rebalance_unit=\"BMS\")\n        assert_invariants(eng, label=\"budget_pct\")\n\n\n# ── No matching entries ──────────────────────────────────────────────\n\nclass TestNoMatchingEntries:\n\n    def test_filter_matches_nothing(self):\n        eng = run_backtest(\n            strategy_fn=lambda schema: buy_put_strategy(\n                schema, underlying=\"NONEXISTENT\"\n            ),\n        )\n        assert len(eng.balance) > 0\n        assert eng.trade_log.empty\n"
  },
  {
    "path": "tests/bench/test_execution_models.py",
    "content": "\"\"\"Regression tests for execution models (cost, fill, signal, risk, exits).\"\"\"\n\nfrom __future__ import annotations\n\nimport pytest\n\nfrom tests.bench._test_helpers import (\n    RUST_AVAILABLE,\n    DEFAULT_ALLOC,\n    DEFAULT_CAPITAL,\n    buy_put_strategy,\n    strangle_strategy,\n    run_backtest,\n    assert_invariants,\n    make_cost_model,\n    make_fill_model,\n    make_signal_selector,\n)\n\npytestmark = [\n    pytest.mark.skipif(not RUST_AVAILABLE, reason=\"Rust extension not installed\"),\n    pytest.mark.bench,\n]\n\n\n# ── Cost models ───────────────────────────────────────────────────────\n\nclass TestCostModels:\n    @pytest.mark.parametrize(\"cost_name\", [\"NoCosts\", \"PerContract\", \"Tiered\"])\n    def test_cost_model(self, cost_name):\n        eng = run_backtest(cost_model=make_cost_model(cost_name))\n        assert_invariants(eng, min_trades=1, label=cost_name)\n\n\n# ── Fill models ───────────────────────────────────────────────────────\n\nclass TestFillModels:\n    @pytest.mark.parametrize(\"fill_name\", [\"MarketAtBidAsk\", \"MidPrice\"])\n    def test_fill_model(self, fill_name):\n        eng = run_backtest(fill_model=make_fill_model(fill_name))\n        assert_invariants(eng, min_trades=1, label=fill_name)\n\n\n# ── Signal selectors ─────────────────────────────────────────────────\n\nclass TestSignalSelectors:\n    @pytest.mark.parametrize(\"signal_name\", [\"FirstMatch\", \"NearestDelta\", \"MaxOpenInterest\"])\n    def test_signal_selector(self, signal_name):\n        eng = run_backtest(signal_selector=make_signal_selector(signal_name))\n        assert_invariants(eng, label=signal_name)\n\n\n# ── Risk constraints ─────────────────────────────────────────────────\n\nclass TestRiskConstraints:\n    def test_max_delta(self):\n        from options_portfolio_backtester.portfolio.risk import RiskManager, MaxDelta\n        rm = RiskManager()\n        rm.add_constraint(MaxDelta(limit=100))\n        eng = run_backtest(risk_manager=rm)\n        assert_invariants(eng)\n\n    def test_max_drawdown(self):\n        from options_portfolio_backtester.portfolio.risk import RiskManager, MaxDrawdown\n        rm = RiskManager()\n        rm.add_constraint(MaxDrawdown(max_dd_pct=0.20))\n        eng = run_backtest(risk_manager=rm)\n        assert_invariants(eng)\n\n\n# ── Exit thresholds ──────────────────────────────────────────────────\n\nclass TestExitThresholds:\n    def test_profit_exit(self):\n        from tests.bench._test_helpers import (\n            _make_engine, load_small_stocks, load_small_options, ivy_stocks,\n        )\n        eng = _make_engine(\n            DEFAULT_ALLOC, DEFAULT_CAPITAL, ivy_stocks(),\n            load_small_stocks(), load_small_options(), buy_put_strategy,\n        )\n        eng.profit_target = 0.5\n        eng.run(rebalance_freq=1, rebalance_unit=\"BMS\")\n        assert_invariants(eng)\n\n    def test_loss_exit(self):\n        from tests.bench._test_helpers import (\n            _make_engine, load_small_stocks, load_small_options, ivy_stocks,\n        )\n        eng = _make_engine(\n            DEFAULT_ALLOC, DEFAULT_CAPITAL, ivy_stocks(),\n            load_small_stocks(), load_small_options(), buy_put_strategy,\n        )\n        eng.stop_loss = 0.3\n        eng.run(rebalance_freq=1, rebalance_unit=\"BMS\")\n        assert_invariants(eng)\n\n\n# ── Full model grid (3 x 2 x 3 = 18 combos) ────────────────────────\n\nclass TestModelGrid:\n    @pytest.mark.parametrize(\"cost_name\", [\"NoCosts\", \"PerContract\", \"Tiered\"])\n    @pytest.mark.parametrize(\"fill_name\", [\"MarketAtBidAsk\", \"MidPrice\"])\n    @pytest.mark.parametrize(\"signal_name\", [\"FirstMatch\", \"NearestDelta\", \"MaxOpenInterest\"])\n    def test_model_combo(self, cost_name, fill_name, signal_name):\n        eng = run_backtest(\n            cost_model=make_cost_model(cost_name),\n            fill_model=make_fill_model(fill_name),\n            signal_selector=make_signal_selector(signal_name),\n        )\n        assert_invariants(eng, label=f\"{cost_name}_{fill_name}_{signal_name}\")\n"
  },
  {
    "path": "tests/bench/test_invariants.py",
    "content": "\"\"\"Balance sheet and trade log invariants.\n\nTests run each backtest ONCE and verify structural invariants.\nCovers small, generated, and production datasets.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport numpy as np\nimport pandas as pd\nimport pytest\n\nfrom tests.bench._test_helpers import (\n    RUST_AVAILABLE,\n    DEFAULT_ALLOC,\n    DEFAULT_CAPITAL,\n    IVY_STOCKS_TUPLES,\n    ivy_stocks,\n    generated_stocks,\n    prod_spy_stocks,\n    load_generated_stocks,\n    load_generated_options,\n    load_prod_stocks,\n    load_prod_options,\n    buy_put_strategy,\n    sell_put_strategy,\n    strangle_strategy,\n    run_backtest,\n    assert_invariants,\n)\n\npytestmark = pytest.mark.skipif(\n    not RUST_AVAILABLE, reason=\"Rust extension not installed\"\n)\n\n\n# ── Small dataset invariants ───────────────────────────────────────────\n\nclass TestBalanceSheetInvariants:\n\n    @pytest.fixture(autouse=True)\n    def _engine(self):\n        self.eng = run_backtest()\n\n    def test_total_capital_equals_parts(self):\n        assert_invariants(self.eng)\n\n    def test_capital_never_negative(self):\n        tc = self.eng.balance[\"total capital\"]\n        assert (tc >= -1.0).all()\n\n    def test_initial_capital_correct(self):\n        first_tc = self.eng.balance[\"total capital\"].iloc[0]\n        assert abs(first_tc - DEFAULT_CAPITAL) < 1.0\n\n    def test_balance_dates_monotonic(self):\n        assert self.eng.balance.index.is_monotonic_increasing\n\n    def test_balance_not_empty(self):\n        assert len(self.eng.balance) > 1\n\n    def test_cash_column_exists(self):\n        assert \"cash\" in self.eng.balance.columns\n\n\nclass TestTradeLogInvariants:\n\n    @pytest.fixture(autouse=True)\n    def _engine(self):\n        self.eng = run_backtest()\n\n    def test_trade_log_not_empty(self):\n        assert not self.eng.trade_log.empty\n\n    def test_entry_costs_nonzero(self):\n        if self.eng.trade_log.empty:\n            pytest.skip(\"No trades\")\n        costs = self.eng.trade_log[\"totals\"][\"cost\"].values\n        assert all(c != 0 for c in costs)\n\n    def test_qty_positive_on_entry(self):\n        if self.eng.trade_log.empty:\n            pytest.skip(\"No trades\")\n        qtys = self.eng.trade_log[\"totals\"][\"qty\"].values\n        assert all(q > 0 for q in qtys)\n\n    def test_trade_dates_within_data_range(self):\n        if self.eng.trade_log.empty:\n            pytest.skip(\"No trades\")\n        trade_dates = pd.to_datetime(self.eng.trade_log[\"totals\"][\"date\"]).unique()\n        data_start = pd.Timestamp(self.eng.options_data._data[\"quotedate\"].min())\n        data_end = pd.Timestamp(self.eng.options_data._data[\"quotedate\"].max())\n        for td in trade_dates:\n            assert data_start <= td <= data_end\n\n\nclass TestBalanceColumns:\n\n    @pytest.fixture(autouse=True)\n    def _engine(self):\n        self.eng = run_backtest()\n\n    def test_required_columns(self):\n        required = {\n            \"cash\", \"stocks capital\", \"options capital\",\n            \"total capital\", \"calls capital\", \"puts capital\",\n            \"% change\", \"accumulated return\",\n        }\n        actual = set(self.eng.balance.columns)\n        missing = required - actual\n        assert not missing, f\"Missing columns: {missing}\"\n\n    def test_per_stock_columns(self):\n        for sym, _ in IVY_STOCKS_TUPLES:\n            assert sym in self.eng.balance.columns\n            assert f\"{sym} qty\" in self.eng.balance.columns\n\n\n# ── Generated dataset invariants ───────────────────────────────────────\n\nclass TestGeneratedDataInvariants:\n\n    @pytest.fixture(autouse=True)\n    def _engine(self):\n        self.eng = run_backtest(\n            stocks=generated_stocks(),\n            stocks_data=load_generated_stocks(),\n            options_data=load_generated_options(),\n        )\n\n    def test_invariants(self):\n        assert_invariants(self.eng, min_trades=5, label=\"generated\")\n\n    def test_many_balance_rows(self):\n        assert len(self.eng.balance) >= 10\n\n    def test_initial_capital(self):\n        first_tc = self.eng.balance[\"total capital\"].iloc[0]\n        assert abs(first_tc - DEFAULT_CAPITAL) < 1.0\n\n\n# ── Production SPY invariants ──────────────────────────────────────────\n\nclass TestProductionDataInvariants:\n\n    @pytest.fixture(autouse=True)\n    def _engine(self):\n        self.eng = run_backtest(\n            strategy_fn=lambda schema: buy_put_strategy(schema, underlying=\"SPY\"),\n            stocks=prod_spy_stocks(),\n            stocks_data=load_prod_stocks(),\n            options_data=load_prod_options(),\n        )\n\n    def test_invariants(self):\n        assert_invariants(self.eng, min_trades=3, label=\"production\")\n\n    def test_capital_never_negative(self):\n        tc = self.eng.balance[\"total capital\"]\n        assert (tc >= -1.0).all()\n"
  },
  {
    "path": "tests/bench/test_multi_leg.py",
    "content": "\"\"\"Multi-leg strategy regression tests.\n\nEach test runs the backtest ONCE and checks invariants.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport pytest\n\nfrom tests.bench._test_helpers import (\n    RUST_AVAILABLE,\n    DEFAULT_ALLOC,\n    DEFAULT_CAPITAL,\n    strangle_strategy,\n    straddle_strategy,\n    buy_put_spread_strategy,\n    sell_call_spread_strategy,\n    two_leg_strategy,\n    run_backtest,\n    assert_invariants,\n)\n\npytestmark = pytest.mark.skipif(\n    not RUST_AVAILABLE, reason=\"Rust extension not installed\"\n)\n\n\nclass TestMultiLegStrategies:\n\n    def test_strangle(self):\n        eng = run_backtest(strategy_fn=strangle_strategy)\n        assert_invariants(eng, allow_negative_capital=True)\n\n    def test_straddle(self):\n        eng = run_backtest(strategy_fn=straddle_strategy)\n        assert_invariants(eng)\n\n    def test_put_spread(self):\n        eng = run_backtest(strategy_fn=buy_put_spread_strategy)\n        assert_invariants(eng, allow_negative_capital=True)\n\n    def test_call_spread(self):\n        eng = run_backtest(strategy_fn=sell_call_spread_strategy)\n        assert_invariants(eng, allow_negative_capital=True)\n\n\nclass TestMixedDirections:\n\n    _COMBOS = [\n        (\"buy\", \"put\", \"sell\", \"call\"),\n        (\"sell\", \"put\", \"buy\", \"call\"),\n        (\"buy\", \"put\", \"buy\", \"call\"),\n        (\"sell\", \"put\", \"sell\", \"call\"),\n    ]\n\n    @pytest.mark.parametrize(\"d1,t1,d2,t2\", _COMBOS)\n    def test_direction_combo(self, d1, t1, d2, t2):\n        eng = run_backtest(\n            strategy_fn=lambda schema: two_leg_strategy(schema, d1, t1, d2, t2)\n        )\n        has_sell = \"sell\" in (d1, d2)\n        assert_invariants(eng, label=f\"{d1}_{t1}_{d2}_{t2}\",\n                          allow_negative_capital=has_sell)\n\n\nclass TestPerLegOverrides:\n\n    def test_per_leg_signal_selector(self):\n        from options_portfolio_backtester.execution.signal_selector import NearestDelta\n\n        eng = run_backtest(\n            strategy_fn=strangle_strategy,\n            signal_selector=NearestDelta(target_delta=-0.30),\n        )\n        assert_invariants(eng, allow_negative_capital=True)\n\n    def test_per_leg_fill_model(self):\n        from options_portfolio_backtester.execution.fill_model import MidPrice\n\n        eng = run_backtest(\n            strategy_fn=strangle_strategy,\n            fill_model=MidPrice(),\n        )\n        assert_invariants(eng, allow_negative_capital=True)\n"
  },
  {
    "path": "tests/bench/test_partial_exits.py",
    "content": "\"\"\"Regression tests for partial exit (sell_some_options) scenarios.\n\nHigh options allocation (85%) forces sell_some_options to trigger when\nmark-to-market changes shift the allocation balance.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport numpy as np\nimport pytest\n\nfrom tests.bench._test_helpers import (\n    RUST_AVAILABLE,\n    DEFAULT_CAPITAL,\n    generated_stocks,\n    prod_spy_stocks,\n    load_generated_stocks,\n    load_generated_options,\n    load_prod_stocks,\n    load_prod_options,\n    buy_put_strategy,\n    sell_put_strategy,\n    strangle_strategy,\n    run_backtest,\n    assert_invariants,\n)\n\npytestmark = pytest.mark.skipif(\n    not RUST_AVAILABLE, reason=\"Rust extension not installed\"\n)\n\nHIGH_OPTIONS_ALLOC = {\"stocks\": 0.05, \"options\": 0.85, \"cash\": 0.10}\n\n\nclass TestPartialExitGenerated:\n\n    def _run(self, strategy_fn):\n        return run_backtest(\n            alloc=HIGH_OPTIONS_ALLOC, capital=DEFAULT_CAPITAL,\n            strategy_fn=strategy_fn,\n            stocks=generated_stocks(),\n            stocks_data=load_generated_stocks(),\n            options_data=load_generated_options(),\n        )\n\n    def test_buy_put(self):\n        eng = self._run(buy_put_strategy)\n        assert_invariants(eng, min_trades=3, label=\"partial_buy_put\")\n\n    def test_sell_put(self):\n        eng = self._run(sell_put_strategy)\n        assert_invariants(eng, min_trades=3, label=\"partial_sell_put\",\n                          allow_negative_capital=True)\n\n    def test_strangle(self):\n        eng = self._run(strangle_strategy)\n        assert_invariants(eng, label=\"partial_strangle\",\n                          allow_negative_capital=True)\n\n\nclass TestPartialExitProduction:\n\n    def _run(self, strategy_fn):\n        return run_backtest(\n            alloc=HIGH_OPTIONS_ALLOC, capital=DEFAULT_CAPITAL,\n            strategy_fn=lambda schema: strategy_fn(schema, underlying=\"SPY\"),\n            stocks=prod_spy_stocks(),\n            stocks_data=load_prod_stocks(),\n            options_data=load_prod_options(),\n        )\n\n    def test_buy_put_spy(self):\n        eng = self._run(buy_put_strategy)\n        assert_invariants(eng, label=\"partial_prod_buy_put\")\n\n    def test_sell_put_spy(self):\n        eng = self._run(sell_put_strategy)\n        assert_invariants(eng, label=\"partial_prod_sell_put\",\n                          allow_negative_capital=True)\n\n\nclass TestPartialExitCashAccounting:\n\n    def test_cash_never_deeply_negative(self):\n        eng = run_backtest(\n            alloc=HIGH_OPTIONS_ALLOC, capital=DEFAULT_CAPITAL,\n            strategy_fn=buy_put_strategy,\n            stocks=generated_stocks(),\n            stocks_data=load_generated_stocks(),\n            options_data=load_generated_options(),\n        )\n        # High options allocation can cause cash to go moderately negative\n        # due to timing of mark-to-market and rebalancing\n        cash = eng.balance[\"cash\"]\n        assert (cash >= -50_000.0).all(), f\"Cash deeply negative: min={cash.min()}\"\n"
  },
  {
    "path": "tests/bench/test_sweep.py",
    "content": "\"\"\"Regression tests for Rust parallel_sweep API.\"\"\"\n\nimport pandas as pd\nimport pytest\n\ntry:\n    import polars as pl\n    from options_portfolio_backtester._ob_rust import (\n        parallel_sweep as rust_parallel_sweep,\n        run_backtest_py as rust_run_backtest,\n    )\n    from options_portfolio_backtester.analytics.optimization import rust_grid_sweep\n    RUST_AVAILABLE = True\nexcept ImportError:\n    RUST_AVAILABLE = False\n\npytestmark = pytest.mark.skipif(not RUST_AVAILABLE, reason=\"Rust extension not installed\")\n\n\ndef _pd_to_pl(df: pd.DataFrame) -> \"pl.DataFrame\":\n    return pl.from_pandas(df)\n\n\ndef _dates_to_ns(dates: list[str]) -> list[int]:\n    return [int(pd.Timestamp(d).value) for d in dates]\n\n\ndef _ensure_datetime_cols(df: pd.DataFrame, cols: list[str]) -> pd.DataFrame:\n    df = df.copy()\n    for col in cols:\n        if col in df.columns:\n            df[col] = pd.to_datetime(df[col])\n    return df\n\n\ndef _make_test_data():\n    dates = [\"2024-01-01\"] * 4 + [\"2024-01-15\"] * 4 + [\"2024-02-01\"] * 4\n    opts = pd.DataFrame({\n        \"optionroot\": [\"A\", \"B\", \"C\", \"D\"] * 3,\n        \"underlying\": [\"SPX\"] * 12,\n        \"underlying_last\": [4500.0] * 12,\n        \"quotedate\": dates,\n        \"type\": [\"put\", \"put\", \"call\", \"put\"] * 3,\n        \"expiration\": [\"2024-03-01\"] * 12,\n        \"strike\": [4400.0, 4300.0, 4500.0, 4200.0] * 3,\n        \"bid\": [5.0, 8.0, 3.0, 12.0,\n                4.0, 7.0, 2.0, 11.0,\n                3.5, 6.5, 1.5, 10.5],\n        \"ask\": [6.0, 9.0, 4.0, 13.0,\n                5.0, 8.0, 3.0, 12.0,\n                4.5, 7.5, 2.5, 11.5],\n        \"volume\": [100] * 12,\n        \"open_interest\": [1000] * 12,\n        \"dte\": [60] * 12,\n    })\n    stocks = pd.DataFrame({\n        \"date\": [\"2024-01-01\", \"2024-01-15\", \"2024-02-01\"] * 2,\n        \"symbol\": [\"SPY\"] * 3 + [\"TLT\"] * 3,\n        \"adjClose\": [450.0, 455.0, 460.0, 100.0, 101.0, 102.0],\n    })\n    opts = _ensure_datetime_cols(opts, [\"quotedate\", \"expiration\"])\n    stocks = _ensure_datetime_cols(stocks, [\"date\"])\n    return _pd_to_pl(opts), _pd_to_pl(stocks)\n\n\ndef _base_config():\n    return {\n        \"allocation\": {\"stocks\": 0.5, \"options\": 0.3, \"cash\": 0.2},\n        \"initial_capital\": 100000.0,\n        \"shares_per_contract\": 100,\n        \"legs\": [{\n            \"name\": \"leg_1\",\n            \"entry_filter\": \"(type == 'put') & (ask > 0)\",\n            \"exit_filter\": \"type == 'put'\",\n            \"direction\": \"ask\",\n            \"type\": \"put\",\n            \"entry_sort_col\": None,\n            \"entry_sort_asc\": True,\n        }],\n        \"profit_pct\": None,\n        \"loss_pct\": None,\n        \"stocks\": [(\"SPY\", 0.6), (\"TLT\", 0.4)],\n        \"rebalance_dates\": _dates_to_ns([\"2024-01-01\", \"2024-01-15\", \"2024-02-01\"]),\n    }\n\n\ndef _schema():\n    return {\n        \"contract\": \"optionroot\",\n        \"date\": \"quotedate\",\n        \"stocks_date\": \"date\",\n        \"stocks_symbol\": \"symbol\",\n        \"stocks_price\": \"adjClose\",\n    }\n\n\nclass TestSweep:\n\n    def test_single_config_matches_direct(self):\n        opts, stocks = _make_test_data()\n        config = _base_config()\n        schema = _schema()\n\n        _balance, _trade_log, direct_stats = rust_run_backtest(\n            opts, stocks, config, schema,\n        )\n        sweep_results = rust_parallel_sweep(\n            opts, stocks, config, schema, [{\"label\": \"base\"}],\n        )\n\n        assert len(sweep_results) == 1\n        r = sweep_results[0]\n        assert r[\"label\"] == \"base\"\n        assert r[\"error\"] is None\n        assert abs(r[\"total_return\"] - direct_stats[\"total_return\"]) < 1e-10\n        assert abs(r[\"final_cash\"] - direct_stats[\"final_cash\"]) < 1e-6\n\n    def test_multiple_configs(self):\n        opts, stocks = _make_test_data()\n        config = _base_config()\n        schema = _schema()\n\n        overrides = [\n            {\"label\": \"tight\", \"profit_pct\": 0.01, \"loss_pct\": 0.01},\n            {\"label\": \"wide\", \"profit_pct\": 0.99, \"loss_pct\": 0.99},\n        ]\n        results = rust_parallel_sweep(opts, stocks, config, schema, overrides)\n\n        assert len(results) == 2\n        for r in results:\n            assert r[\"error\"] is None\n\n    def test_per_leg_filter_overrides(self):\n        opts, stocks = _make_test_data()\n        config = _base_config()\n        schema = _schema()\n\n        overrides = [\n            {\"label\": \"broad\", \"leg_entry_filters\": [\"(type == 'put') & (ask > 0)\"]},\n            {\"label\": \"narrow\", \"leg_entry_filters\": [\"(type == 'put') & (ask > 0) & (strike < 4300)\"]},\n        ]\n        results = rust_parallel_sweep(opts, stocks, config, schema, overrides)\n\n        by_label = {r[\"label\"]: r for r in results}\n        assert by_label[\"narrow\"][\"total_trades\"] <= by_label[\"broad\"][\"total_trades\"]\n\n    def test_bad_filter_returns_error(self):\n        opts, stocks = _make_test_data()\n        config = _base_config()\n        schema = _schema()\n\n        overrides = [{\"label\": \"bad\", \"leg_entry_filters\": [\"(((invalid syntax!!!\"]}]\n        results = rust_parallel_sweep(opts, stocks, config, schema, overrides)\n\n        assert len(results) == 1\n        r = results[0]\n        assert r[\"error\"] is not None or r[\"total_trades\"] == 0\n\n    def test_empty_param_grid(self):\n        opts, stocks = _make_test_data()\n        results = rust_parallel_sweep(\n            opts, stocks, _base_config(), _schema(), [],\n        )\n        assert results == []\n\n    def test_deterministic(self):\n        opts, stocks = _make_test_data()\n        config = _base_config()\n        schema = _schema()\n        overrides = [\n            {\"label\": \"a\", \"profit_pct\": 0.5},\n            {\"label\": \"b\", \"loss_pct\": 0.5},\n        ]\n\n        r1 = rust_parallel_sweep(opts, stocks, config, schema, overrides, n_workers=1)\n        r2 = rust_parallel_sweep(opts, stocks, config, schema, overrides)\n\n        r1.sort(key=lambda x: x[\"label\"])\n        r2.sort(key=lambda x: x[\"label\"])\n        for a, b in zip(r1, r2):\n            assert abs(a[\"total_return\"] - b[\"total_return\"]) < 1e-10\n\n\nclass TestGridSweepWrapper:\n\n    def test_sorted_by_sharpe(self):\n        opts, stocks = _make_test_data()\n        overrides = [{\"label\": \"a\"}, {\"label\": \"b\", \"profit_pct\": 0.01}]\n        results = rust_grid_sweep(opts, stocks, _base_config(), _schema(), overrides)\n        sharpes = [r[\"sharpe_ratio\"] for r in results]\n        assert sharpes == sorted(sharpes, reverse=True)\n"
  },
  {
    "path": "tests/compat/__init__.py",
    "content": ""
  },
  {
    "path": "tests/compat/test_bt_overlap_gate.py",
    "content": "from __future__ import annotations\n\nfrom pathlib import Path\n\nimport pytest\n\nfrom scripts.compare_with_bt import normalize_weights, run_bt, run_options_portfolio_backtester\n\n\n@pytest.mark.bench\ndef test_bt_overlap_gate_stock_only():\n    stocks_file = Path(\"data/processed/stocks.csv\")\n    if not stocks_file.exists():\n        pytest.skip(\"stocks.csv is not available\")\n\n    bt_available = True\n    try:\n        import bt  # noqa: F401\n    except Exception:\n        bt_available = False\n    if not bt_available:\n        pytest.skip(\"bt is not installed\")\n\n    symbols = [\"SPY\"]\n    weights = normalize_weights(symbols, None)\n    ob = run_options_portfolio_backtester(\n        stocks_file=str(stocks_file),\n        symbols=symbols,\n        weights=weights,\n        initial_capital=1_000_000.0,\n        rebalance_months=1,\n        runs=1,\n    )\n    bt_res = run_bt(\n        stocks_file=str(stocks_file),\n        symbols=symbols,\n        weights=weights,\n        initial_capital=1_000_000.0,\n        runs=1,\n    )\n    assert bt_res is not None\n    common = ob.equity.index.intersection(bt_res.equity.index)\n    assert len(common) > 200\n    ob_n = ob.equity.loc[common] / ob.equity.loc[common].iloc[0]\n    bt_n = bt_res.equity.loc[common] / bt_res.equity.loc[common].iloc[0]\n    delta = (ob_n - bt_n).abs().max()\n    assert float(delta) < 0.10\n"
  },
  {
    "path": "tests/conftest.py",
    "content": "from __future__ import annotations\n\nfrom pathlib import Path\n\nimport pytest\n\n\ndef pytest_collection_modifyitems(config, items):\n    del config\n    for item in items:\n        path = Path(str(item.fspath)).as_posix()\n        if \"/tests/bench/\" in path:\n            item.add_marker(pytest.mark.bench)\n"
  },
  {
    "path": "tests/convexity/__init__.py",
    "content": ""
  },
  {
    "path": "tests/convexity/conftest.py",
    "content": "\"\"\"Shared fixtures for convexity tests.\"\"\"\n\nimport numpy as np\nimport pandas as pd\nimport pytest\n\nfrom options_portfolio_backtester.convexity.config import BacktestConfig, InstrumentConfig\n\n\nclass MockOptionsData:\n    \"\"\"Mock HistoricalOptionsData with ._data attribute.\"\"\"\n\n    def __init__(self, df: pd.DataFrame):\n        self._data = df\n\n\nclass MockStocksData:\n    \"\"\"Mock TiingoData with ._data attribute.\"\"\"\n\n    def __init__(self, df: pd.DataFrame):\n        self._data = df\n\n\ndef _make_put_row(date, strike, bid, ask, delta, underlying, dte, iv, expiration):\n    return {\n        \"quotedate\": pd.Timestamp(date),\n        \"expiration\": pd.Timestamp(expiration),\n        \"type\": \"put\",\n        \"strike\": strike,\n        \"bid\": bid,\n        \"ask\": ask,\n        \"delta\": delta,\n        \"underlying_last\": underlying,\n        \"dte\": dte,\n        \"impliedvol\": iv,\n    }\n\n\n@pytest.fixture\ndef instrument_config():\n    return InstrumentConfig(\n        symbol=\"TEST\",\n        options_file=\"test_options.csv\",\n        stocks_file=\"test_stocks.csv\",\n        target_delta=-0.10,\n        dte_min=14,\n        dte_max=60,\n        tail_drop=0.20,\n    )\n\n\n@pytest.fixture\ndef backtest_config():\n    return BacktestConfig(\n        initial_capital=100_000.0,\n        budget_pct=0.005,\n    )\n\n\n@pytest.fixture\ndef synthetic_options():\n    \"\"\"Three months of synthetic put options, 3 strikes per day.\"\"\"\n    rows = []\n    dates = pd.bdate_range(\"2020-01-02\", \"2020-03-31\")\n    for date in dates:\n        expiration = date + pd.Timedelta(days=30)\n        underlying = 400.0\n        for strike, bid, ask, delta, iv in [\n            (360.0, 2.5, 3.0, -0.08, 0.20),\n            (370.0, 3.5, 4.0, -0.12, 0.22),\n            (380.0, 5.0, 5.5, -0.18, 0.25),\n        ]:\n            rows.append(_make_put_row(date, strike, bid, ask, delta, underlying, 30, iv, expiration))\n\n    df = pd.DataFrame(rows)\n    return MockOptionsData(df)\n\n\n@pytest.fixture\ndef synthetic_stocks():\n    \"\"\"Three months of synthetic stock prices.\"\"\"\n    dates = pd.bdate_range(\"2020-01-02\", \"2020-03-31\")\n    np.random.seed(42)\n    prices = 400.0 * np.cumprod(1 + np.random.normal(0.0003, 0.01, len(dates)))\n    df = pd.DataFrame({\"date\": dates, \"adjClose\": prices})\n    return MockStocksData(df)\n\n\n@pytest.fixture\ndef empty_options():\n    \"\"\"Empty options DataFrame.\"\"\"\n    df = pd.DataFrame(columns=[\n        \"quotedate\", \"expiration\", \"type\", \"strike\", \"bid\", \"ask\",\n        \"delta\", \"underlying_last\", \"dte\", \"impliedvol\",\n    ])\n    return MockOptionsData(df)\n\n\n@pytest.fixture\ndef empty_stocks():\n    \"\"\"Empty stocks DataFrame.\"\"\"\n    df = pd.DataFrame(columns=[\"date\", \"adjClose\"])\n    return MockStocksData(df)\n"
  },
  {
    "path": "tests/convexity/test_allocator.py",
    "content": "\"\"\"Tests for allocation strategies.\"\"\"\n\nimport pytest\n\nfrom options_portfolio_backtester.convexity.allocator import (\n    allocate_equal_weight,\n    allocate_inverse_vol,\n    pick_cheapest,\n)\n\n\nclass TestPickCheapest:\n    def test_picks_highest_ratio(self):\n        scores = {\"SPY\": 1.5, \"HYG\": 3.2, \"EEM\": 2.0}\n        assert pick_cheapest(scores) == \"HYG\"\n\n    def test_single_instrument(self):\n        assert pick_cheapest({\"SPY\": 1.0}) == \"SPY\"\n\n    def test_empty_raises(self):\n        with pytest.raises(ValueError):\n            pick_cheapest({})\n\n\nclass TestEqualWeight:\n    def test_splits_evenly(self):\n        alloc = allocate_equal_weight([\"SPY\", \"HYG\", \"EEM\"], 6000.0)\n        assert alloc == {\"SPY\": 2000.0, \"HYG\": 2000.0, \"EEM\": 2000.0}\n\n    def test_single(self):\n        alloc = allocate_equal_weight([\"SPY\"], 5000.0)\n        assert alloc == {\"SPY\": 5000.0}\n\n    def test_empty(self):\n        assert allocate_equal_weight([], 5000.0) == {}\n\n\nclass TestInverseVol:\n    def test_lower_vol_gets_more(self):\n        alloc = allocate_inverse_vol({\"SPY\": 0.20, \"HYG\": 0.40}, 6000.0)\n        assert alloc[\"SPY\"] > alloc[\"HYG\"]\n        assert abs(alloc[\"SPY\"] + alloc[\"HYG\"] - 6000.0) < 0.01\n\n    def test_equal_vol(self):\n        alloc = allocate_inverse_vol({\"SPY\": 0.20, \"HYG\": 0.20}, 4000.0)\n        assert abs(alloc[\"SPY\"] - 2000.0) < 0.01\n        assert abs(alloc[\"HYG\"] - 2000.0) < 0.01\n\n    def test_zero_vol_falls_back(self):\n        alloc = allocate_inverse_vol({\"SPY\": 0.0, \"HYG\": 0.0}, 4000.0)\n        assert abs(alloc[\"SPY\"] - 2000.0) < 0.01\n"
  },
  {
    "path": "tests/convexity/test_backtest.py",
    "content": "\"\"\"Tests for convexity backtest module.\"\"\"\n\nimport pandas as pd\nimport pytest\n\nfrom options_portfolio_backtester.convexity.backtest import (\n    BacktestResult,\n    run_backtest,\n    run_unhedged,\n)\n\n\nclass TestRunBacktest:\n    def test_returns_monthly_records(\n        self, synthetic_options, synthetic_stocks, backtest_config,\n    ):\n        result = run_backtest(synthetic_options, synthetic_stocks, backtest_config)\n        assert isinstance(result, BacktestResult)\n        assert len(result.records) == 3\n\n    def test_daily_balance_populated(\n        self, synthetic_options, synthetic_stocks, backtest_config,\n    ):\n        result = run_backtest(synthetic_options, synthetic_stocks, backtest_config)\n        assert not result.daily_balance.empty\n        assert (result.daily_balance[\"balance\"] > 0).all()\n\n    def test_budget_deducted(\n        self, synthetic_options, synthetic_stocks, backtest_config,\n    ):\n        result = run_backtest(synthetic_options, synthetic_stocks, backtest_config)\n        assert result.records[\"put_cost\"].iloc[0] > 0\n        assert result.records[\"contracts\"].iloc[0] > 0\n\n    def test_empty_options(\n        self, empty_options, synthetic_stocks, backtest_config,\n    ):\n        result = run_backtest(empty_options, synthetic_stocks, backtest_config)\n        assert result.records.empty\n        assert result.daily_balance.empty\n\n\nclass TestRunUnhedged:\n    def test_returns_correct_shape(self, synthetic_stocks, backtest_config):\n        daily = run_unhedged(synthetic_stocks, backtest_config)\n        assert not daily.empty\n        assert \"balance\" in daily.columns\n        assert \"pct_change\" in daily.columns\n        dates = pd.bdate_range(\"2020-01-02\", \"2020-03-31\")\n        assert len(daily) == len(dates)\n\n    def test_initial_value_matches_capital(self, synthetic_stocks, backtest_config):\n        daily = run_unhedged(synthetic_stocks, backtest_config)\n        assert abs(daily[\"balance\"].iloc[0] - backtest_config.initial_capital) < 0.01\n"
  },
  {
    "path": "tests/convexity/test_config.py",
    "content": "\"\"\"Tests for convexity config.\"\"\"\n\nfrom options_portfolio_backtester.convexity.config import (\n    BacktestConfig,\n    InstrumentConfig,\n    default_config,\n)\n\n\nclass TestConfig:\n    def test_instrument_defaults(self):\n        inst = InstrumentConfig(symbol=\"SPY\", options_file=\"o.csv\", stocks_file=\"s.csv\")\n        assert inst.target_delta == -0.10\n        assert inst.dte_min == 14\n        assert inst.dte_max == 60\n        assert inst.tail_drop == 0.20\n\n    def test_backtest_defaults(self):\n        cfg = BacktestConfig()\n        assert cfg.initial_capital == 1_000_000.0\n        assert cfg.budget_pct == 0.005\n\n    def test_default_config(self):\n        cfg = default_config()\n        assert len(cfg.instruments) == 1\n        assert cfg.instruments[0].symbol == \"SPY\"\n"
  },
  {
    "path": "tests/core/__init__.py",
    "content": ""
  },
  {
    "path": "tests/core/test_types.py",
    "content": "\"\"\"Tests for core domain types.\"\"\"\n\nfrom options_portfolio_backtester.core.types import (\n    Direction, OptionType, Order, Signal, Greeks, Fill, OptionContract,\n    StockAllocation, Stock, get_order,\n)\n\n\n# ---------------------------------------------------------------------------\n# Direction\n# ---------------------------------------------------------------------------\n\nclass TestDirection:\n    def test_buy_price_column_is_ask(self):\n        assert Direction.BUY.price_column == \"ask\"\n\n    def test_sell_price_column_is_bid(self):\n        assert Direction.SELL.price_column == \"bid\"\n\n    def test_invert_buy(self):\n        assert ~Direction.BUY == Direction.SELL\n\n    def test_invert_sell(self):\n        assert ~Direction.SELL == Direction.BUY\n\n    def test_decoupled_from_column_name(self):\n        \"\"\"Direction.value is 'buy'/'sell', NOT 'ask'/'bid'.\"\"\"\n        assert Direction.BUY.value == \"buy\"\n        assert Direction.SELL.value == \"sell\"\n\n\n# ---------------------------------------------------------------------------\n# OptionType\n# ---------------------------------------------------------------------------\n\nclass TestOptionType:\n    def test_invert_call(self):\n        assert ~OptionType.CALL == OptionType.PUT\n\n    def test_invert_put(self):\n        assert ~OptionType.PUT == OptionType.CALL\n\n\n# ---------------------------------------------------------------------------\n# Order\n# ---------------------------------------------------------------------------\n\nclass TestOrder:\n    def test_invert_bto(self):\n        assert ~Order.BTO == Order.STC\n\n    def test_invert_stc(self):\n        assert ~Order.STC == Order.BTO\n\n    def test_invert_sto(self):\n        assert ~Order.STO == Order.BTC\n\n    def test_invert_btc(self):\n        assert ~Order.BTC == Order.STO\n\n\n# ---------------------------------------------------------------------------\n# get_order\n# ---------------------------------------------------------------------------\n\nclass TestGetOrder:\n    def test_buy_entry(self):\n        assert get_order(Direction.BUY, Signal.ENTRY) == Order.BTO\n\n    def test_buy_exit(self):\n        assert get_order(Direction.BUY, Signal.EXIT) == Order.STC\n\n    def test_sell_entry(self):\n        assert get_order(Direction.SELL, Signal.ENTRY) == Order.STO\n\n    def test_sell_exit(self):\n        assert get_order(Direction.SELL, Signal.EXIT) == Order.BTC\n\n\n# ---------------------------------------------------------------------------\n# Greeks\n# ---------------------------------------------------------------------------\n\nclass TestGreeks:\n    def test_default_zeros(self):\n        g = Greeks()\n        assert g.delta == 0.0\n        assert g.gamma == 0.0\n        assert g.theta == 0.0\n        assert g.vega == 0.0\n\n    def test_addition(self):\n        a = Greeks(delta=0.5, gamma=0.01, theta=-0.02, vega=0.1)\n        b = Greeks(delta=-0.3, gamma=0.02, theta=-0.01, vega=0.05)\n        result = a + b\n        assert abs(result.delta - 0.2) < 1e-10\n        assert abs(result.gamma - 0.03) < 1e-10\n        assert abs(result.theta - (-0.03)) < 1e-10\n        assert abs(result.vega - 0.15) < 1e-10\n\n    def test_scalar_multiply(self):\n        g = Greeks(delta=0.5, gamma=0.01, theta=-0.02, vega=0.1)\n        result = g * 10\n        assert abs(result.delta - 5.0) < 1e-10\n        assert abs(result.gamma - 0.1) < 1e-10\n\n    def test_rmul(self):\n        g = Greeks(delta=0.5, gamma=0.01, theta=-0.02, vega=0.1)\n        result = 10 * g\n        assert abs(result.delta - 5.0) < 1e-10\n\n    def test_negation(self):\n        g = Greeks(delta=0.5, gamma=0.01, theta=-0.02, vega=0.1)\n        neg = -g\n        assert abs(neg.delta - (-0.5)) < 1e-10\n        assert abs(neg.vega - (-0.1)) < 1e-10\n\n    def test_as_dict(self):\n        g = Greeks(delta=0.5, gamma=0.01, theta=-0.02, vega=0.1)\n        d = g.as_dict\n        assert d == {\"delta\": 0.5, \"gamma\": 0.01, \"theta\": -0.02, \"vega\": 0.1}\n\n    def test_frozen(self):\n        g = Greeks(delta=0.5)\n        try:\n            g.delta = 1.0  # type: ignore\n            assert False, \"Should have raised\"\n        except AttributeError:\n            pass\n\n\n# ---------------------------------------------------------------------------\n# Fill\n# ---------------------------------------------------------------------------\n\nclass TestFill:\n    def test_buy_fill_notional(self):\n        f = Fill(price=2.50, quantity=10, direction=Direction.BUY, shares_per_contract=100)\n        # -1 * 2.50 * 10 * 100 = -2500\n        assert f.notional == -2500.0\n\n    def test_sell_fill_notional(self):\n        f = Fill(price=2.50, quantity=10, direction=Direction.SELL, shares_per_contract=100)\n        # 1 * 2.50 * 10 * 100 = 2500\n        assert f.notional == 2500.0\n\n    def test_fill_with_commission(self):\n        f = Fill(price=2.50, quantity=10, direction=Direction.BUY,\n                 shares_per_contract=100, commission=6.50)\n        # -2500 - 6.50 = -2506.50\n        assert f.notional == -2506.50\n\n    def test_fill_with_slippage(self):\n        f = Fill(price=2.50, quantity=10, direction=Direction.BUY,\n                 shares_per_contract=100, slippage=5.0)\n        assert f.notional == -2505.0\n\n    def test_fill_with_commission_and_slippage(self):\n        f = Fill(price=2.50, quantity=10, direction=Direction.BUY,\n                 shares_per_contract=100, commission=6.50, slippage=5.0)\n        assert f.notional == -2511.50\n\n\n# ---------------------------------------------------------------------------\n# OptionContract\n# ---------------------------------------------------------------------------\n\nclass TestOptionContract:\n    def test_creation(self):\n        c = OptionContract(\n            contract_id=\"SPY240119C00500000\",\n            underlying=\"SPY\",\n            expiration=\"2024-01-19\",\n            option_type=OptionType.CALL,\n            strike=500.0,\n        )\n        assert c.contract_id == \"SPY240119C00500000\"\n        assert c.option_type == OptionType.CALL\n        assert c.strike == 500.0\n\n\n# ---------------------------------------------------------------------------\n# StockAllocation / Stock\n# ---------------------------------------------------------------------------\n\nclass TestStockAllocation:\n    def test_creation(self):\n        s = StockAllocation(\"SPY\", 0.60)\n        assert s.symbol == \"SPY\"\n        assert s.percentage == 0.60\n\n    def test_stock_alias(self):\n        s = Stock(\"VOO\", 1.0)\n        assert s.symbol == \"VOO\"\n        assert isinstance(s, StockAllocation)\n"
  },
  {
    "path": "tests/core/test_types_pbt.py",
    "content": "\"\"\"Property-based tests for core domain types.\n\nFuzzes Greeks algebra, Fill notional, Direction/Order/OptionType enum inversions,\nand get_order mapping with Hypothesis.\n\"\"\"\n\nimport numpy as np\nimport pytest\nfrom hypothesis import given, settings, assume\nfrom hypothesis import strategies as st\n\nfrom options_portfolio_backtester.core.types import (\n    Direction, OptionType, Order, Signal, Greeks, Fill, get_order,\n    StockAllocation,\n)\n\n# ---------------------------------------------------------------------------\n# Hypothesis strategies\n# ---------------------------------------------------------------------------\n\ngreek_float = st.floats(min_value=-1000, max_value=1000, allow_nan=False, allow_infinity=False)\nscalar = st.floats(min_value=-100, max_value=100, allow_nan=False, allow_infinity=False)\nprice = st.floats(min_value=0.01, max_value=10_000, allow_nan=False, allow_infinity=False)\nquantity = st.integers(min_value=1, max_value=10_000)\nspc = st.sampled_from([1, 10, 100, 1000])\ncommission = st.floats(min_value=0.0, max_value=1000, allow_nan=False, allow_infinity=False)\nslippage = st.floats(min_value=0.0, max_value=1000, allow_nan=False, allow_infinity=False)\ndirection = st.sampled_from([Direction.BUY, Direction.SELL])\noption_type = st.sampled_from([OptionType.CALL, OptionType.PUT])\nsignal = st.sampled_from([Signal.ENTRY, Signal.EXIT])\norder = st.sampled_from([Order.BTO, Order.BTC, Order.STO, Order.STC])\npct = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)\n\ngreeks = st.builds(\n    Greeks,\n    delta=greek_float,\n    gamma=greek_float,\n    theta=greek_float,\n    vega=greek_float,\n)\n\n\n# ---------------------------------------------------------------------------\n# Direction\n# ---------------------------------------------------------------------------\n\n\nclass TestDirectionPBT:\n    @given(direction)\n    def test_price_column_is_bid_or_ask(self, d):\n        assert d.price_column in {\"bid\", \"ask\"}\n\n    @given(direction)\n    def test_buy_maps_to_ask(self, d):\n        if d == Direction.BUY:\n            assert d.price_column == \"ask\"\n        else:\n            assert d.price_column == \"bid\"\n\n    @given(direction)\n    def test_invert_changes_price_column(self, d):\n        assert d.price_column != (~d).price_column\n\n    @given(direction)\n    def test_double_invert_identity(self, d):\n        assert ~~d == d\n\n    @given(direction)\n    def test_invert_is_different(self, d):\n        assert ~d != d\n\n\n# ---------------------------------------------------------------------------\n# OptionType\n# ---------------------------------------------------------------------------\n\n\nclass TestOptionTypePBT:\n    @given(option_type)\n    def test_double_invert_identity(self, ot):\n        assert ~~ot == ot\n\n    @given(option_type)\n    def test_invert_is_different(self, ot):\n        assert ~ot != ot\n\n    @given(option_type)\n    def test_call_inverts_to_put(self, ot):\n        if ot == OptionType.CALL:\n            assert ~ot == OptionType.PUT\n        else:\n            assert ~ot == OptionType.CALL\n\n\n# ---------------------------------------------------------------------------\n# Order\n# ---------------------------------------------------------------------------\n\n\nclass TestOrderPBT:\n    @given(order)\n    def test_double_invert_identity(self, o):\n        assert ~~o == o\n\n    @given(order)\n    def test_invert_changes_buy_sell(self, o):\n        \"\"\"BTO↔STC, STO↔BTC: invert swaps buy/sell side.\"\"\"\n        inv = ~o\n        assert inv != o\n\n    @given(direction, signal)\n    def test_get_order_exhaustive(self, d, s):\n        \"\"\"get_order always returns a valid Order.\"\"\"\n        o = get_order(d, s)\n        assert isinstance(o, Order)\n\n    @given(direction, signal)\n    def test_get_order_entry_exit_paired(self, d, s):\n        \"\"\"Entry and exit orders for same direction are inverses.\"\"\"\n        entry = get_order(d, Signal.ENTRY)\n        exit_ = get_order(d, Signal.EXIT)\n        assert ~entry == exit_\n\n    @given(direction)\n    def test_buy_entry_is_bto(self, d):\n        if d == Direction.BUY:\n            assert get_order(d, Signal.ENTRY) == Order.BTO\n        else:\n            assert get_order(d, Signal.ENTRY) == Order.STO\n\n\n# ---------------------------------------------------------------------------\n# Greeks — extensive algebra properties\n# ---------------------------------------------------------------------------\n\n\nclass TestGreeksFieldsPBT:\n    @given(greeks)\n    def test_has_four_fields(self, g):\n        d = g.as_dict\n        assert set(d.keys()) == {\"delta\", \"gamma\", \"theta\", \"vega\"}\n\n    @given(greeks)\n    def test_frozen(self, g):\n        with pytest.raises(AttributeError):\n            g.delta = 999.0\n\n    @given(greek_float, greek_float, greek_float, greek_float)\n    def test_construction(self, d, ga, th, v):\n        g = Greeks(delta=d, gamma=ga, theta=th, vega=v)\n        assert g.delta == d\n        assert g.gamma == ga\n        assert g.theta == th\n        assert g.vega == v\n\n\nclass TestGreeksAdditionPBT:\n    @given(greeks, greeks)\n    @settings(max_examples=200)\n    def test_commutative(self, a, b):\n        assert _greeks_close(a + b, b + a)\n\n    @given(greeks, greeks, greeks)\n    @settings(max_examples=200)\n    def test_associative(self, a, b, c):\n        assert _greeks_close((a + b) + c, a + (b + c), tol=1e-8)\n\n    @given(greeks)\n    @settings(max_examples=100)\n    def test_zero_identity(self, g):\n        assert _greeks_close(g + Greeks(), g)\n\n    @given(greeks)\n    @settings(max_examples=100)\n    def test_inverse(self, g):\n        assert _greeks_close(g + (-g), Greeks(), tol=1e-10)\n\n\nclass TestGreeksScalarMulPBT:\n    @given(greeks, scalar)\n    @settings(max_examples=200)\n    def test_componentwise(self, g, s):\n        r = g * s\n        assert abs(r.delta - g.delta * s) < 1e-6\n        assert abs(r.gamma - g.gamma * s) < 1e-6\n        assert abs(r.theta - g.theta * s) < 1e-6\n        assert abs(r.vega - g.vega * s) < 1e-6\n\n    @given(greeks)\n    @settings(max_examples=50)\n    def test_identity(self, g):\n        assert _greeks_close(g * 1.0, g)\n\n    @given(greeks)\n    @settings(max_examples=50)\n    def test_zero(self, g):\n        assert _greeks_close(g * 0.0, Greeks(), tol=1e-10)\n\n    @given(greeks, scalar)\n    @settings(max_examples=200)\n    def test_rmul(self, g, s):\n        assert _greeks_close(s * g, g * s)\n\n    @given(greeks, greeks, scalar)\n    @settings(max_examples=200)\n    def test_distributes_over_addition(self, a, b, s):\n        r1 = (a + b) * s\n        r2 = (a * s) + (b * s)\n        tol = max(abs(r1.delta), abs(r2.delta), 1) * 1e-6 + 1e-10\n        assert abs(r1.delta - r2.delta) < tol\n        assert abs(r1.vega - r2.vega) < tol\n\n\n# ---------------------------------------------------------------------------\n# Fill\n# ---------------------------------------------------------------------------\n\n\nclass TestFillPBT:\n    @given(price, quantity, direction, spc)\n    @settings(max_examples=200)\n    def test_direction_sign_matches(self, p, q, d, s):\n        f = Fill(price=p, quantity=q, direction=d, shares_per_contract=s)\n        expected = -1 if d == Direction.BUY else 1\n        assert f.direction_sign == expected\n\n    @given(price, quantity, spc, commission, slippage)\n    @settings(max_examples=200)\n    def test_sell_notional_exceeds_buy(self, p, q, s, comm, slip):\n        \"\"\"SELL notional > BUY notional for same price/qty (costs reduce both).\"\"\"\n        buy = Fill(price=p, quantity=q, direction=Direction.BUY,\n                   shares_per_contract=s, commission=comm, slippage=slip)\n        sell = Fill(price=p, quantity=q, direction=Direction.SELL,\n                    shares_per_contract=s, commission=comm, slippage=slip)\n        assert sell.notional > buy.notional\n\n    @given(price, quantity, direction, spc)\n    @settings(max_examples=100)\n    def test_zero_costs_notional(self, p, q, d, s):\n        f = Fill(price=p, quantity=q, direction=d, shares_per_contract=s)\n        expected = f.direction_sign * p * q * s\n        assert abs(f.notional - expected) < 1e-6\n\n    @given(price, quantity, direction, spc, commission, slippage)\n    @settings(max_examples=200)\n    def test_costs_reduce_notional(self, p, q, d, s, comm, slip):\n        f_clean = Fill(price=p, quantity=q, direction=d, shares_per_contract=s)\n        f_dirty = Fill(price=p, quantity=q, direction=d, shares_per_contract=s,\n                       commission=comm, slippage=slip)\n        assert f_dirty.notional <= f_clean.notional + 1e-10\n\n    @given(price, quantity, direction, spc,\n           st.floats(min_value=0, max_value=500, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=0, max_value=500, allow_nan=False, allow_infinity=False))\n    @settings(max_examples=100)\n    def test_higher_commission_lower_notional(self, p, q, d, s, c1, c2):\n        assume(c2 > c1)\n        f1 = Fill(price=p, quantity=q, direction=d, shares_per_contract=s, commission=c1)\n        f2 = Fill(price=p, quantity=q, direction=d, shares_per_contract=s, commission=c2)\n        assert f2.notional <= f1.notional + 1e-10\n\n\n# ---------------------------------------------------------------------------\n# StockAllocation\n# ---------------------------------------------------------------------------\n\n\nclass TestStockAllocationPBT:\n    @given(st.text(min_size=1, max_size=10), pct)\n    @settings(max_examples=50)\n    def test_named_tuple_fields(self, sym, p):\n        sa = StockAllocation(symbol=sym, percentage=p)\n        assert sa.symbol == sym\n        assert sa.percentage == p\n        assert sa[0] == sym\n        assert sa[1] == p\n\n\n# ---------------------------------------------------------------------------\n# Helpers\n# ---------------------------------------------------------------------------\n\ndef _greeks_close(a: Greeks, b: Greeks, tol: float = 1e-10) -> bool:\n    return (\n        abs(a.delta - b.delta) < tol\n        and abs(a.gamma - b.gamma) < tol\n        and abs(a.theta - b.theta) < tol\n        and abs(a.vega - b.vega) < tol\n    )\n"
  },
  {
    "path": "tests/data/__init__.py",
    "content": ""
  },
  {
    "path": "tests/data/test_filter.py",
    "content": "\"\"\"Tests for Schema DSL: Field filter operations.\"\"\"\n\nfrom options_portfolio_backtester.data.schema import Field\n\n\ndef test_strike_eq_100():\n    \"\"\"Test filter for 'strike' == 100\"\"\"\n    strike_field = Field(\"strike\", \"strike\")\n    ft = strike_field == 100\n    assert ft.query == \"strike == 100\"\n\n\ndef test_strike_lt_100():\n    \"\"\"Test filter for 'strike' < 100\"\"\"\n    strike_field = Field(\"strike\", \"strike\")\n    ft = strike_field < 100\n    assert ft.query == \"strike < 100\"\n\n\ndef test_strike_ge_100():\n    \"\"\"Test filter for 'strike' >= 100\"\"\"\n    strike_field = Field(\"strike\", \"strike\")\n    ft = strike_field >= 100\n    assert ft.query == \"strike >= 100\"\n\n\ndef test_negate_filter():\n    \"\"\"Test negations of a filter\"\"\"\n    symbol_field = Field(\"underlying\", \"underlying\")\n    ft = symbol_field == \"SPX\"\n    negated = ~ft\n    assert negated.query == \"!(underlying == 'SPX')\"\n\n\ndef test_compose_filters_with_and():\n    \"\"\"Test composition of two filters with and\"\"\"\n    symbol_field = Field(\"underlying\", \"underlying\")\n    strike_field = Field(\"strike\", \"strike\")\n    ft1 = symbol_field == \"SPX\"\n    ft2 = strike_field < 200\n    composed = ft1 & ft2\n    assert composed.query == \"(underlying == 'SPX') & (strike < 200)\"\n\n\ndef test_compose_filters_with_or():\n    \"\"\"Test composition of two filters with or\"\"\"\n    strike_field = Field(\"strike\", \"strike\")\n    ft1 = strike_field >= 200\n    ft2 = strike_field < 100\n    composed = ft1 | ft2\n    assert composed.query == \"((strike >= 200) | (strike < 100))\"\n\n\ndef test_compose_many_filters():\n    \"\"\"Test composition of three filters mixing and + or\"\"\"\n    symbol_field = Field(\"underlying\", \"underlying\")\n    strike_field = Field(\"strike\", \"strike\")\n    ft1 = symbol_field == \"SPX\"\n    ft2 = strike_field >= 200\n    ft3 = strike_field < 100\n    composed = ft1 & (ft2 | ft3)\n    assert composed.query == \"(underlying == 'SPX') & (((strike >= 200) | (strike < 100)))\"\n\n\ndef test_add_number_to_field():\n    \"\"\"Test addition of a number to a field\"\"\"\n    strike_field = Field(\"strike\", \"strike\")\n    field = strike_field + 10\n    assert field.name == \"strike + 10\"\n    assert field.mapping == \"strike + 10\"\n\n\ndef test_subtract_number_from_field():\n    \"\"\"Test subtraction of a number from a field\"\"\"\n    strike_field = Field(\"strike\", \"strike\")\n    field = strike_field - 10\n    assert field.name == \"strike - 10\"\n    assert field.mapping == \"strike - 10\"\n\n\ndef test_multiply_field_by_number():\n    \"\"\"Test multiplication of a field by a number\"\"\"\n    underlying_last = Field(\"last\", \"underlying_last\")\n    field = underlying_last * 1.5\n    assert field.name == \"last * 1.5\"\n    assert field.mapping == \"underlying_last * 1.5\"\n\n\ndef test_multiply_on_left():\n    \"\"\"Test multiplication of a field by a number on the *left*\"\"\"\n    underlying_last = Field(\"last\", \"underlying_last\")\n    field = 1.5 * underlying_last\n    assert field.name == \"1.5 * last\"\n    assert field.mapping == \"1.5 * underlying_last\"\n\n\ndef test_filter_from_combined_field():\n    \"\"\"Test filter from a linear combination of fields\"\"\"\n    underlying_last = Field(\"last\", \"underlying_last\")\n    strike_field = Field(\"strike\", \"strike\")\n    combined_filter = underlying_last == strike_field * 1.2\n    assert combined_filter.query == \"underlying_last == strike * 1.2\"\n"
  },
  {
    "path": "tests/data/test_property_based.py",
    "content": "\"\"\"Property-based tests for Schema, Field, and Filter DSL.\"\"\"\n\nimport numpy as np\nimport pandas as pd\nfrom hypothesis import given, settings, assume, HealthCheck\nfrom hypothesis import strategies as st\n\nfrom options_portfolio_backtester.data.schema import Schema, Field, Filter\n\n\n# ---------------------------------------------------------------------------\n# Strategies\n# ---------------------------------------------------------------------------\n\nnumeric_value = st.floats(min_value=-1000.0, max_value=1000.0, allow_nan=False, allow_infinity=False)\npositive_numeric = st.floats(min_value=0.01, max_value=1000.0, allow_nan=False, allow_infinity=False)\n\n\ndef _make_df(n_rows, col_name=\"strike\", values=None):\n    \"\"\"Build a simple numeric DataFrame for filter testing.\"\"\"\n    if values is None:\n        rng = np.random.default_rng(42)\n        values = rng.uniform(50, 500, size=n_rows)\n    return pd.DataFrame({col_name: values})\n\n\n# ---------------------------------------------------------------------------\n# Filter properties\n# ---------------------------------------------------------------------------\n\nclass TestFilterProperties:\n    @given(\n        st.floats(min_value=100.0, max_value=400.0, allow_nan=False),\n        st.integers(min_value=10, max_value=200),\n    )\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_filter_returns_subset(self, threshold, n_rows):\n        \"\"\"Compiled filter result is a subset of input rows.\"\"\"\n        assume(n_rows > 0)\n        df = _make_df(n_rows)\n        f = Field(\"strike\", \"strike\")\n        filt = f >= threshold\n        mask = filt(df)\n        filtered = df[mask]\n        assert len(filtered) <= len(df)\n\n    @given(st.integers(min_value=5, max_value=200))\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_impossible_range_empty(self, n_rows):\n        \"\"\"min > max range → empty result.\"\"\"\n        df = _make_df(n_rows)\n        f = Field(\"strike\", \"strike\")\n        filt = (f >= 9999) & (f <= 0)\n        mask = filt(df)\n        assert mask.sum() == 0\n\n    @given(\n        st.floats(min_value=100.0, max_value=400.0, allow_nan=False),\n        st.integers(min_value=10, max_value=200),\n    )\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_numeric_filter_bounds(self, threshold, n_rows):\n        \"\"\"All matched values satisfy the filter condition.\"\"\"\n        assume(n_rows > 0)\n        df = _make_df(n_rows)\n        f = Field(\"strike\", \"strike\")\n        filt = f >= threshold\n        mask = filt(df)\n        matched = df.loc[mask, \"strike\"]\n        assert (matched >= threshold - 1e-10).all()\n\n    @given(\n        st.floats(min_value=100.0, max_value=300.0, allow_nan=False),\n        st.floats(min_value=300.0, max_value=500.0, allow_nan=False),\n        st.integers(min_value=10, max_value=200),\n    )\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_and_is_intersection(self, lo, hi, n_rows):\n        \"\"\"AND of two filters = intersection of their individual results.\"\"\"\n        assume(lo < hi and n_rows > 0)\n        df = _make_df(n_rows)\n        f = Field(\"strike\", \"strike\")\n        f1 = f >= lo\n        f2 = f <= hi\n        combined = f1 & f2\n\n        mask_1 = f1(df)\n        mask_2 = f2(df)\n        mask_and = combined(df)\n\n        expected = mask_1 & mask_2\n        assert (mask_and == expected).all()\n\n    @given(\n        st.floats(min_value=100.0, max_value=300.0, allow_nan=False),\n        st.floats(min_value=300.0, max_value=500.0, allow_nan=False),\n        st.integers(min_value=10, max_value=200),\n    )\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_or_is_union(self, lo, hi, n_rows):\n        \"\"\"OR of two filters = union of their individual results.\"\"\"\n        assume(lo < hi and n_rows > 0)\n        df = _make_df(n_rows)\n        f = Field(\"strike\", \"strike\")\n        f1 = f <= lo\n        f2 = f >= hi\n        combined = f1 | f2\n\n        mask_1 = f1(df)\n        mask_2 = f2(df)\n        mask_or = combined(df)\n\n        expected = mask_1 | mask_2\n        assert (mask_or == expected).all()\n"
  },
  {
    "path": "tests/data/test_providers.py",
    "content": "\"\"\"Tests for data providers.\"\"\"\n\nimport os\nimport pytest\nimport pandas as pd\n\nfrom options_portfolio_backtester.data.providers import (\n    CsvOptionsProvider, CsvStocksProvider,\n    DataProvider, OptionsDataProvider, StocksDataProvider,\n)\nfrom options_portfolio_backtester.data.schema import Schema\n\nTEST_DIR = os.path.join(os.path.dirname(__file__), \"..\", \"test_data\")\nSTOCKS_FILE = os.path.join(TEST_DIR, \"test_data_stocks.csv\")\nOPTIONS_FILE = os.path.join(TEST_DIR, \"test_data_options.csv\")\n\n\n@pytest.fixture\ndef options_provider():\n    return CsvOptionsProvider(OPTIONS_FILE)\n\n\n@pytest.fixture\ndef stocks_provider():\n    return CsvStocksProvider(STOCKS_FILE)\n\n\nclass TestCsvOptionsProvider:\n    def test_is_data_provider(self, options_provider):\n        assert isinstance(options_provider, DataProvider)\n        assert isinstance(options_provider, OptionsDataProvider)\n\n    def test_has_schema(self, options_provider):\n        assert options_provider.schema is not None\n\n    def test_data_is_dataframe(self, options_provider):\n        assert isinstance(options_provider.data, pd.DataFrame)\n\n    def test_start_end_dates(self, options_provider):\n        assert isinstance(options_provider.start_date, pd.Timestamp)\n        assert isinstance(options_provider.end_date, pd.Timestamp)\n        assert options_provider.start_date <= options_provider.end_date\n\n    def test_len(self, options_provider):\n        assert len(options_provider) > 0\n\n    def test_iter_dates(self, options_provider):\n        groups = list(options_provider.iter_dates())\n        assert len(groups) > 0\n\n\nclass TestCsvStocksProvider:\n    def test_is_data_provider(self, stocks_provider):\n        assert isinstance(stocks_provider, DataProvider)\n        assert isinstance(stocks_provider, StocksDataProvider)\n\n    def test_has_schema(self, stocks_provider):\n        assert stocks_provider.schema is not None\n\n    def test_data_is_dataframe(self, stocks_provider):\n        assert isinstance(stocks_provider.data, pd.DataFrame)\n\n    def test_start_end_dates(self, stocks_provider):\n        assert isinstance(stocks_provider.start_date, pd.Timestamp)\n        assert isinstance(stocks_provider.end_date, pd.Timestamp)\n\n    def test_len(self, stocks_provider):\n        assert len(stocks_provider) > 0\n\n\nclass TestSchemaReExport:\n    def test_schema_import(self):\n        from options_portfolio_backtester.data.schema import Schema, Field, Filter\n        assert Schema is not None\n        assert Field is not None\n        assert Filter is not None\n\n    def test_options_schema(self):\n        s = Schema.options()\n        assert \"bid\" in s\n        assert \"ask\" in s\n"
  },
  {
    "path": "tests/data/test_providers_extended.py",
    "content": "\"\"\"Extended tests for data providers — accessors, iteration, edge cases.\"\"\"\n\nimport os\nimport pandas as pd\nimport pytest\n\nfrom options_portfolio_backtester.data.providers import (\n    TiingoData, HistoricalOptionsData,\n    CsvOptionsProvider, CsvStocksProvider,\n)\nfrom options_portfolio_backtester.data.schema import Schema, Filter\n\n\n@pytest.fixture\ndef stocks_csv(tmp_path):\n    \"\"\"Create a minimal stocks CSV for testing.\"\"\"\n    csv = tmp_path / \"stocks.csv\"\n    csv.write_text(\n        \"symbol,date,open,close,high,low,volume,adjClose,adjHigh,adjLow,adjOpen,adjVolume,divCash,splitFactor\\n\"\n        \"SPY,2020-01-02,320,322,323,319,1000000,322,323,319,320,1000000,0,1\\n\"\n        \"SPY,2020-01-03,322,321,324,320,1100000,321,324,320,322,1100000,0,1\\n\"\n        \"SPY,2020-01-06,321,323,325,320,1200000,323,325,320,321,1200000,0,1\\n\"\n    )\n    return str(csv)\n\n\n@pytest.fixture\ndef options_csv(tmp_path):\n    \"\"\"Create a minimal options CSV for testing.\"\"\"\n    csv = tmp_path / \"options.csv\"\n    csv.write_text(\n        \"underlying,underlying_last,quotedate,optionroot,type,expiration,strike,bid,ask,volume,openinterest,last,impliedvol,delta,gamma,theta,vega\\n\"\n        \"SPY,322,2020-01-02,SPY_C_450,call,2020-02-21,450,1.5,2.0,500,1000,1.75,0.25,0.30,0.02,-0.05,0.15\\n\"\n        \"SPY,322,2020-01-02,SPY_P_300,put,2020-02-21,300,0.8,1.2,300,800,1.00,0.20,-0.20,0.01,-0.03,0.10\\n\"\n        \"SPY,321,2020-01-03,SPY_C_450,call,2020-02-21,450,1.4,1.9,400,1000,1.65,0.24,0.28,0.02,-0.05,0.14\\n\"\n        \"SPY,321,2020-01-03,SPY_P_300,put,2020-02-21,300,0.9,1.3,350,800,1.10,0.21,-0.21,0.01,-0.03,0.11\\n\"\n        \"SPY,323,2020-01-06,SPY_C_450,call,2020-02-21,450,1.6,2.1,600,1000,1.85,0.26,0.32,0.02,-0.05,0.16\\n\"\n        \"SPY,323,2020-01-06,SPY_P_300,put,2020-02-21,300,0.7,1.1,250,800,0.90,0.19,-0.19,0.01,-0.03,0.09\\n\"\n    )\n    return str(csv)\n\n\nclass TestTiingoData:\n    def test_len(self, stocks_csv):\n        td = TiingoData(stocks_csv)\n        assert len(td) == 3\n\n    def test_getitem_schema_key(self, stocks_csv):\n        td = TiingoData(stocks_csv)\n        result = td[\"symbol\"]\n        assert isinstance(result, pd.Series)\n        assert (result == \"SPY\").all()\n\n    def test_setitem(self, stocks_csv):\n        td = TiingoData(stocks_csv)\n        td[\"custom\"] = [1, 2, 3]\n        assert \"custom\" in td.schema\n        assert td._data[\"custom\"].tolist() == [1, 2, 3]\n\n    def test_repr(self, stocks_csv):\n        td = TiingoData(stocks_csv)\n        r = repr(td)\n        assert \"SPY\" in r\n\n    def test_start_end_dates(self, stocks_csv):\n        td = TiingoData(stocks_csv)\n        assert td.start_date == pd.Timestamp(\"2020-01-02\")\n        assert td.end_date == pd.Timestamp(\"2020-01-06\")\n\n    def test_iter_dates(self, stocks_csv):\n        td = TiingoData(stocks_csv)\n        dates = list(td.iter_dates())\n        assert len(dates) == 3\n\n    def test_apply_filter(self, stocks_csv):\n        td = TiingoData(stocks_csv)\n        f = Filter(\"adjClose > 321\")\n        result = td.apply_filter(f)\n        assert len(result) == 2  # 322 and 323\n\n    def test_getattr_passthrough_method(self, stocks_csv):\n        \"\"\"__getattr__ delegates to _data; head() is a DataFrame method.\"\"\"\n        td = TiingoData(stocks_csv)\n        result = td.head(2)\n        assert isinstance(result, pd.DataFrame)\n        assert len(result) == 2\n\n    def test_getattr_passthrough_property(self, stocks_csv):\n        \"\"\"__getattr__ delegates to _data; shape is a property.\"\"\"\n        td = TiingoData(stocks_csv)\n        assert td.shape == (3, 14)  # 3 rows, 14 columns\n\n    def test_iter_months(self, stocks_csv):\n        td = TiingoData(stocks_csv)\n        months = list(td.iter_months())\n        # All 3 dates are in January 2020, so iter_months groups to 1 month\n        assert len(months) >= 1\n\n    def test_sma(self, stocks_csv):\n        td = TiingoData(stocks_csv)\n        td.sma(2)\n        assert \"sma\" in td._data.columns\n        assert \"sma\" in td.schema\n\n\nclass TestHistoricalOptionsData:\n    def test_len(self, options_csv):\n        hod = HistoricalOptionsData(options_csv)\n        assert len(hod) == 6\n\n    def test_dte_column_added(self, options_csv):\n        hod = HistoricalOptionsData(options_csv)\n        assert \"dte\" in hod._data.columns\n        assert (hod._data[\"dte\"] > 0).all()\n\n    def test_getitem_schema_key(self, options_csv):\n        hod = HistoricalOptionsData(options_csv)\n        result = hod[\"underlying\"]\n        assert (result == \"SPY\").all()\n\n    def test_getitem_series_indexing(self, options_csv):\n        hod = HistoricalOptionsData(options_csv)\n        mask = hod._data[\"type\"] == \"call\"\n        result = hod[mask]\n        assert len(result) == 3\n\n    def test_setitem(self, options_csv):\n        hod = HistoricalOptionsData(options_csv)\n        hod[\"flag\"] = True\n        assert \"flag\" in hod.schema\n\n    def test_repr(self, options_csv):\n        hod = HistoricalOptionsData(options_csv)\n        r = repr(hod)\n        assert \"SPY\" in r\n\n    def test_iter_dates(self, options_csv):\n        hod = HistoricalOptionsData(options_csv)\n        dates = list(hod.iter_dates())\n        assert len(dates) == 3\n\n    def test_iter_months(self, options_csv):\n        hod = HistoricalOptionsData(options_csv)\n        months = list(hod.iter_months())\n        assert len(months) >= 1\n\n    def test_getattr_passthrough(self, options_csv):\n        hod = HistoricalOptionsData(options_csv)\n        result = hod.head(3)\n        assert isinstance(result, pd.DataFrame)\n        assert len(result) == 3\n\n    def test_apply_filter(self, options_csv):\n        hod = HistoricalOptionsData(options_csv)\n        f = Filter(\"strike > 400\")\n        result = hod.apply_filter(f)\n        assert len(result) == 3  # only the call rows at strike 450\n\n    def test_start_end_dates(self, options_csv):\n        hod = HistoricalOptionsData(options_csv)\n        assert hod.start_date == pd.Timestamp(\"2020-01-02\")\n        assert hod.end_date == pd.Timestamp(\"2020-01-06\")\n\n\nclass TestCsvStocksProvider:\n    def test_data_property(self, stocks_csv):\n        p = CsvStocksProvider(stocks_csv)\n        assert isinstance(p.data, pd.DataFrame)\n        assert len(p.data) == 3\n\n    def test_underscore_data(self, stocks_csv):\n        p = CsvStocksProvider(stocks_csv)\n        assert p._data is p.data\n\n    def test_schema(self, stocks_csv):\n        p = CsvStocksProvider(stocks_csv)\n        assert isinstance(p.schema, Schema)\n\n    def test_setitem_getitem(self, stocks_csv):\n        p = CsvStocksProvider(stocks_csv)\n        p[\"flag\"] = [1, 2, 3]\n        assert p._data[\"flag\"].tolist() == [1, 2, 3]\n\n    def test_len(self, stocks_csv):\n        p = CsvStocksProvider(stocks_csv)\n        assert len(p) == 3\n\n    def test_iter_dates(self, stocks_csv):\n        p = CsvStocksProvider(stocks_csv)\n        dates = list(p.iter_dates())\n        assert len(dates) == 3\n\n    def test_iter_months(self, stocks_csv):\n        p = CsvStocksProvider(stocks_csv)\n        months = list(p.iter_months())\n        assert len(months) >= 1\n\n    def test_apply_filter(self, stocks_csv):\n        p = CsvStocksProvider(stocks_csv)\n        f = Filter(\"adjClose > 321\")\n        result = p.apply_filter(f)\n        assert len(result) == 2\n\n    def test_start_end_date(self, stocks_csv):\n        p = CsvStocksProvider(stocks_csv)\n        assert p.start_date == pd.Timestamp(\"2020-01-02\")\n        assert p.end_date == pd.Timestamp(\"2020-01-06\")\n\n    def test_sma(self, stocks_csv):\n        p = CsvStocksProvider(stocks_csv)\n        p.sma(2)\n        assert \"sma\" in p._data.columns\n\n\nclass TestCsvOptionsProvider:\n    def test_data_property(self, options_csv):\n        p = CsvOptionsProvider(options_csv)\n        assert isinstance(p.data, pd.DataFrame)\n        assert len(p.data) == 6\n\n    def test_underscore_data(self, options_csv):\n        p = CsvOptionsProvider(options_csv)\n        assert p._data is p.data\n\n    def test_setitem_getitem(self, options_csv):\n        p = CsvOptionsProvider(options_csv)\n        p[\"flag\"] = range(6)\n        result = p[\"flag\"]\n        assert len(result) == 6\n\n    def test_len(self, options_csv):\n        p = CsvOptionsProvider(options_csv)\n        assert len(p) == 6\n\n    def test_iter_dates(self, options_csv):\n        p = CsvOptionsProvider(options_csv)\n        dates = list(p.iter_dates())\n        assert len(dates) == 3\n\n    def test_iter_months(self, options_csv):\n        p = CsvOptionsProvider(options_csv)\n        months = list(p.iter_months())\n        assert len(months) >= 1\n\n    def test_apply_filter(self, options_csv):\n        p = CsvOptionsProvider(options_csv)\n        f = Filter(\"strike > 400\")\n        result = p.apply_filter(f)\n        assert len(result) == 3\n\n    def test_start_end_date(self, options_csv):\n        p = CsvOptionsProvider(options_csv)\n        assert p.start_date == pd.Timestamp(\"2020-01-02\")\n        assert p.end_date == pd.Timestamp(\"2020-01-06\")\n\n    def test_schema(self, options_csv):\n        p = CsvOptionsProvider(options_csv)\n        assert isinstance(p.schema, Schema)\n"
  },
  {
    "path": "tests/data/test_schema.py",
    "content": "\"\"\"Tests for Schema, Field, and Filter DSL.\"\"\"\n\nimport pandas as pd\nimport pytest\n\nfrom options_portfolio_backtester.data.schema import Schema, Field, Filter\n\n\nclass TestSchema:\n    def test_stocks_factory(self):\n        s = Schema.stocks()\n        assert \"symbol\" in s\n        assert \"date\" in s\n        assert \"adjClose\" in s\n\n    def test_options_factory(self):\n        s = Schema.options()\n        assert \"underlying\" in s\n        assert \"strike\" in s\n        assert \"bid\" in s\n        assert \"ask\" in s\n\n    def test_getitem(self):\n        s = Schema.stocks()\n        assert s[\"symbol\"] == \"symbol\"\n\n    def test_getattr_returns_field(self):\n        s = Schema.stocks()\n        f = s.symbol\n        assert isinstance(f, Field)\n        assert f.mapping == \"symbol\"\n\n    def test_update(self):\n        s = Schema.stocks()\n        s.update({\"custom\": \"custom_col\"})\n        assert s[\"custom\"] == \"custom_col\"\n\n    def test_contains(self):\n        s = Schema.stocks()\n        assert \"symbol\" in s\n        assert \"nonexistent\" not in s\n\n    def test_setitem(self):\n        s = Schema.stocks()\n        s[\"new_field\"] = \"new_col\"\n        assert s[\"new_field\"] == \"new_col\"\n\n    def test_iter(self):\n        s = Schema.stocks()\n        pairs = list(s)\n        assert any(k == \"symbol\" for k, _ in pairs)\n\n    def test_repr(self):\n        s = Schema.stocks()\n        r = repr(s)\n        assert \"Schema\" in r\n\n    def test_equality(self):\n        s1 = Schema.stocks()\n        s2 = Schema.stocks()\n        assert s1 == s2\n\n    def test_inequality_different_schema(self):\n        s1 = Schema.stocks()\n        s2 = Schema.options()\n        assert s1 != s2\n\n    def test_equality_with_non_schema(self):\n        s = Schema.stocks()\n        assert s != \"not a schema\"\n\n\nclass TestField:\n    def test_repr(self):\n        f = Field(\"strike\", \"strike\")\n        assert \"Field\" in repr(f)\n        assert \"strike\" in repr(f)\n\n    def test_comparison_operators(self):\n        s = Schema.options()\n        f = s.strike > 100\n        assert isinstance(f, Filter)\n        assert \"100\" in f.query\n\n    def test_equality_operator_string(self):\n        s = Schema.options()\n        f = s.underlying == \"SPY\"\n        assert isinstance(f, Filter)\n        assert \"'SPY'\" in f.query\n\n    def test_arithmetic_field_field(self):\n        s = Schema.options()\n        combined = s.strike + s.bid\n        assert isinstance(combined, Field)\n        assert \"+\" in combined.mapping\n\n    def test_arithmetic_field_scalar(self):\n        s = Schema.options()\n        combined = s.strike * 1.05\n        assert isinstance(combined, Field)\n        assert \"*\" in combined.mapping\n\n    def test_radd(self):\n        s = Schema.options()\n        combined = 100 + s.strike\n        assert isinstance(combined, Field)\n\n    def test_rsub(self):\n        s = Schema.options()\n        combined = 100 - s.strike\n        assert isinstance(combined, Field)\n\n    def test_rtruediv(self):\n        s = Schema.options()\n        combined = 1 / s.strike\n        assert isinstance(combined, Field)\n\n    def test_rmul(self):\n        s = Schema.options()\n        combined = 2 * s.strike\n        assert isinstance(combined, Field)\n\n    def test_ne_operator(self):\n        s = Schema.options()\n        f = s.underlying != \"SPY\"\n        assert isinstance(f, Filter)\n        assert \"!=\" in f.query\n\n\nclass TestFilter:\n    def test_and(self):\n        s = Schema.options()\n        f = (s.strike > 100) & (s.strike < 200)\n        assert isinstance(f, Filter)\n        assert \"&\" in f.query\n\n    def test_or(self):\n        s = Schema.options()\n        f = (s.strike > 100) | (s.strike < 50)\n        assert isinstance(f, Filter)\n        assert \"|\" in f.query\n\n    def test_invert(self):\n        s = Schema.options()\n        f = ~(s.strike > 100)\n        assert isinstance(f, Filter)\n        assert \"!\" in f.query\n\n    def test_call_on_dataframe(self):\n        df = pd.DataFrame({\"strike\": [100, 200, 300]})\n        s = Schema.options()\n        f = s.strike > 150\n        result = f(df)\n        assert isinstance(result, pd.Series)\n        assert result.sum() == 2\n\n    def test_repr(self):\n        f = Filter(\"strike > 100\")\n        assert \"Filter\" in repr(f)\n        assert \"strike > 100\" in repr(f)\n"
  },
  {
    "path": "tests/engine/__init__.py",
    "content": ""
  },
  {
    "path": "tests/engine/test_algo_adapters.py",
    "content": "from __future__ import annotations\n\nimport warnings\n\nimport pandas as pd\nimport pytest\n\nfrom options_portfolio_backtester.engine.algo_adapters import (\n    BudgetPercent,\n    EngineRunMonthly,\n    EnginePipelineContext,\n    EngineStepDecision,\n    ExitOnThreshold,\n    MaxGreekExposure,\n    RangeFilter,\n    SelectByDTE,\n    SelectByDelta,\n    IVRankFilter,\n)\nfrom options_portfolio_backtester.engine.engine import BacktestEngine\nfrom options_portfolio_backtester.core.types import Greeks\n\nfrom tests.engine.test_engine import _buy_strategy, _ivy_stocks, _options_data, _stocks_data\n\n\ndef _run_with_algos(algos):\n    stocks = _ivy_stocks()\n    stocks_data = _stocks_data()\n    options_data = _options_data()\n    schema = options_data.schema\n\n    engine = BacktestEngine(\n        {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0.0},\n        algos=algos,\n    )\n    engine.stocks = stocks\n    engine.stocks_data = stocks_data\n    engine.options_data = options_data\n    engine.options_strategy = _buy_strategy(schema)\n    engine.run(rebalance_freq=1)\n    return engine\n\n\ndef _dummy_ctx(**overrides) -> EnginePipelineContext:\n    defaults = dict(\n        date=pd.Timestamp(\"2024-01-02\"),\n        stocks=pd.DataFrame(),\n        options=pd.DataFrame(),\n        total_capital=100_000.0,\n        current_cash=50_000.0,\n        current_greeks=Greeks(delta=0.5, gamma=0.01, theta=-0.02, vega=0.1),\n        options_allocation=3000.0,\n    )\n    defaults.update(overrides)\n    return EnginePipelineContext(**defaults)\n\n\n# ---------------------------------------------------------------------------\n# EngineRunMonthly\n# ---------------------------------------------------------------------------\n\ndef test_engine_algo_monthly_gate_translates():\n    \"\"\"EngineRunMonthly is consumed by _translate_algos_to_config (no-op for Rust).\"\"\"\n    stocks = _ivy_stocks()\n    stocks_data = _stocks_data()\n    options_data = _options_data()\n    schema = options_data.schema\n    engine = BacktestEngine(\n        {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0.0},\n        algos=[EngineRunMonthly()],\n    )\n    engine.stocks = stocks\n    engine.stocks_data = stocks_data\n    engine.options_data = options_data\n    engine.options_strategy = _buy_strategy(schema)\n    # EngineRunMonthly should be consumed without error; Rust handles rebalancing.\n    result = engine.run(rebalance_freq=1, rebalance_unit=\"B\")\n    assert not result.empty  # run() returns a balance DataFrame\n    assert len(engine.algos) == 0  # algos consumed by translation\n\n\ndef test_engine_run_monthly_reset():\n    algo = EngineRunMonthly()\n    ctx_jan = _dummy_ctx(date=pd.Timestamp(\"2024-01-02\"))\n    ctx_jan2 = _dummy_ctx(date=pd.Timestamp(\"2024-01-15\"))\n    assert algo(ctx_jan).status == \"continue\"\n    assert algo(ctx_jan2).status == \"skip_day\"\n    algo.reset()\n    assert algo(ctx_jan).status == \"continue\"\n\n\n# ---------------------------------------------------------------------------\n# BudgetPercent\n# ---------------------------------------------------------------------------\n\ndef test_budget_percent_zero_blocks_option_entries():\n    engine = _run_with_algos([BudgetPercent(0.0)])\n    # With 0% budget, options allocation is zero — no options should be bought\n    assert engine.trade_log.empty or (engine.trade_log[\"totals\"][\"qty\"] <= 0).all()\n\n\ndef test_budget_percent_sets_allocation():\n    algo = BudgetPercent(0.05)\n    ctx = _dummy_ctx(total_capital=200_000.0)\n    algo(ctx)\n    assert ctx.options_allocation == 10_000.0\n\n\ndef test_budget_percent_clamps_negative_capital():\n    algo = BudgetPercent(0.05)\n    ctx = _dummy_ctx(total_capital=-100.0)\n    algo(ctx)\n    assert ctx.options_allocation == 0.0\n\n\n# ---------------------------------------------------------------------------\n# RangeFilter (item 8 dedup)\n# ---------------------------------------------------------------------------\n\ndef test_range_filter_appends_entry_filter():\n    flt = RangeFilter(column=\"delta\", min_val=-0.3, max_val=-0.1)\n    ctx = _dummy_ctx()\n    result = flt(ctx)\n    assert result.status == \"continue\"\n    assert len(ctx.entry_filters) == 1\n\n    df = pd.DataFrame({\"delta\": [-0.5, -0.2, -0.1, 0.0, 0.3]})\n    mask = ctx.entry_filters[0](df)\n    assert mask.tolist() == [False, True, True, False, False]\n\n\ndef test_range_filter_missing_column_passes_all():\n    flt = RangeFilter(column=\"nonexistent\", min_val=0, max_val=1)\n    ctx = _dummy_ctx()\n    flt(ctx)\n    df = pd.DataFrame({\"other\": [1, 2, 3]})\n    mask = ctx.entry_filters[0](df)\n    assert mask.all()\n\n\n# ---------------------------------------------------------------------------\n# SelectByDelta / SelectByDTE / IVRankFilter (backward-compat aliases)\n# ---------------------------------------------------------------------------\n\ndef test_select_by_delta_returns_range_filter():\n    flt = SelectByDelta(min_delta=-0.5, max_delta=-0.1)\n    assert isinstance(flt, RangeFilter)\n    assert flt.column == \"delta\"\n\n\ndef test_select_by_dte_returns_range_filter():\n    flt = SelectByDTE(min_dte=30, max_dte=60)\n    assert isinstance(flt, RangeFilter)\n    assert flt.column == \"dte\"\n    assert flt.min_val == 30.0\n    assert flt.max_val == 60.0\n\n\ndef test_iv_rank_filter_returns_range_filter():\n    flt = IVRankFilter(min_rank=0.3, max_rank=0.8, column=\"iv_rank\")\n    assert isinstance(flt, RangeFilter)\n    assert flt.column == \"iv_rank\"\n\n\ndef test_select_by_dte_strict_filter_skips_candidates():\n    \"\"\"SelectByDTE(0,1) translates to a tight filter that blocks most entries.\"\"\"\n    engine = _run_with_algos([SelectByDTE(min_dte=0, max_dte=1)])\n    # With DTE 0-1, almost no options qualify → few or no trades\n    tl = engine.trade_log\n    assert tl.empty or len(tl) <= 2  # at most a couple if data happens to match\n\n\n# ---------------------------------------------------------------------------\n# MaxGreekExposure\n# ---------------------------------------------------------------------------\n\ndef test_max_greek_exposure_delta_blocks():\n    algo = MaxGreekExposure(max_abs_delta=0.3)\n    ctx = _dummy_ctx(current_greeks=Greeks(delta=0.5, gamma=0, theta=0, vega=0))\n    result = algo(ctx)\n    assert result.status == \"skip_day\"\n    assert \"delta\" in result.message\n\n\ndef test_max_greek_exposure_vega_blocks():\n    algo = MaxGreekExposure(max_abs_vega=0.05)\n    ctx = _dummy_ctx(current_greeks=Greeks(delta=0, gamma=0, theta=0, vega=0.1))\n    result = algo(ctx)\n    assert result.status == \"skip_day\"\n    assert \"vega\" in result.message\n\n\ndef test_max_greek_exposure_within_limits_continues():\n    algo = MaxGreekExposure(max_abs_delta=1.0, max_abs_vega=1.0)\n    ctx = _dummy_ctx(current_greeks=Greeks(delta=0.1, gamma=0, theta=0, vega=0.05))\n    result = algo(ctx)\n    assert result.status == \"continue\"\n\n\ndef test_max_greek_exposure_none_limits_pass():\n    algo = MaxGreekExposure()\n    ctx = _dummy_ctx(current_greeks=Greeks(delta=999, gamma=0, theta=0, vega=999))\n    result = algo(ctx)\n    assert result.status == \"continue\"\n\n\n# ---------------------------------------------------------------------------\n# ExitOnThreshold (item 17)\n# ---------------------------------------------------------------------------\n\ndef test_exit_on_threshold_sets_override():\n    algo = ExitOnThreshold(profit_pct=0.5, loss_pct=0.3)\n    ctx = _dummy_ctx()\n    algo(ctx)\n    assert ctx.exit_threshold_override == (0.5, 0.3)\n\n\ndef test_exit_on_threshold_warns_on_all_inf():\n    with warnings.catch_warnings(record=True) as w:\n        warnings.simplefilter(\"always\")\n        ExitOnThreshold()\n        assert len(w) == 1\n        assert \"no effect\" in str(w[0].message).lower()\n\n\ndef test_exit_on_threshold_no_warn_when_finite():\n    with warnings.catch_warnings(record=True) as w:\n        warnings.simplefilter(\"always\")\n        ExitOnThreshold(profit_pct=0.5)\n        assert len(w) == 0\n\n\n# ---------------------------------------------------------------------------\n# Events dataframe structure (item 14 + item 18 flattened data)\n# ---------------------------------------------------------------------------\n\ndef test_events_dataframe_has_flattened_columns():\n    engine = _run_with_algos([])\n    events = engine.events_dataframe()\n    assert \"date\" in events.columns\n    assert \"event\" in events.columns\n    assert \"status\" in events.columns\n    # \"data\" column should NOT exist (flattened into top-level)\n    assert \"data\" not in events.columns\n\n\ndef test_events_dataframe_contains_cash_from_rebalance_start():\n    engine = _run_with_algos([])\n    events = engine.events_dataframe()\n    rebal_starts = events[events[\"event\"] == \"rebalance_start\"]\n    if not rebal_starts.empty:\n        assert \"cash\" in rebal_starts.columns\n        assert pd.notna(rebal_starts[\"cash\"].iloc[0])\n\n\ndef test_events_dataframe_empty_when_no_events():\n    from options_portfolio_backtester.engine.engine import BacktestEngine\n    engine = BacktestEngine({\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0.0})\n    events = engine.events_dataframe()\n    assert events.empty\n    assert \"date\" in events.columns\n    assert \"event\" in events.columns\n    assert \"status\" in events.columns\n"
  },
  {
    "path": "tests/engine/test_capital_conservation.py",
    "content": "\"\"\"Capital conservation invariant: no money should be created or destroyed.\n\nAt every row in the balance sheet:\n    cash + stocks_capital + options_capital ≈ total_capital\n\nThis catches bugs like the one where _execute_option_entries unconditionally\nadded options_allocation to current_cash, creating money from thin air in\nAQR framing.\n\"\"\"\n\nimport math\nimport os\n\nimport numpy as np\nimport pytest\n\nfrom options_portfolio_backtester.core.types import (\n    Direction,\n    OptionType as Type,\n    Stock,\n)\nfrom options_portfolio_backtester.data.providers import (\n    HistoricalOptionsData,\n    TiingoData,\n)\nfrom options_portfolio_backtester.engine.engine import BacktestEngine\nfrom options_portfolio_backtester.execution.cost_model import NoCosts\nfrom options_portfolio_backtester.strategy.strategy import Strategy\nfrom options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\n\nTEST_DIR = os.path.join(os.path.dirname(__file__), \"..\", \"test_data\")\nSTOCKS_FILE = os.path.join(TEST_DIR, \"ivy_5assets_data.csv\")\nOPTIONS_FILE = os.path.join(TEST_DIR, \"options_data.csv\")\n\n\ndef _ivy_stocks():\n    return [\n        Stock(\"VTI\", 0.2), Stock(\"VEU\", 0.2), Stock(\"BND\", 0.2),\n        Stock(\"VNQ\", 0.2), Stock(\"DBC\", 0.2),\n    ]\n\n\ndef _stocks_data():\n    data = TiingoData(STOCKS_FILE)\n    data._data[\"adjClose\"] = 10\n    return data\n\n\ndef _options_data():\n    data = HistoricalOptionsData(OPTIONS_FILE)\n    data._data.at[2, \"ask\"] = 1\n    data._data.at[2, \"bid\"] = 0.5\n    data._data.at[51, \"ask\"] = 1.5\n    data._data.at[50, \"bid\"] = 0.5\n    data._data.at[130, \"bid\"] = 0.5\n    data._data.at[131, \"bid\"] = 1.5\n    data._data.at[206, \"bid\"] = 0.5\n    data._data.at[207, \"bid\"] = 1.5\n    return data\n\n\ndef _buy_strategy(schema):\n    strat = Strategy(schema)\n    leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\n    leg.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n    leg.exit_filter = schema.dte <= 30\n    strat.add_legs([leg])\n    strat.add_exit_thresholds(profit_pct=math.inf, loss_pct=math.inf)\n    return strat\n\n\ndef _assert_balance_components_sum(balance, rtol=1e-6):\n    \"\"\"Assert cash + stocks + options = total at every row.\"\"\"\n    component_sum = (\n        balance[\"cash\"]\n        + balance[\"stocks capital\"]\n        + balance[\"options capital\"]\n    )\n    total = balance[\"total capital\"]\n    mismatches = ~np.isclose(component_sum, total, rtol=rtol, atol=0.01)\n    if mismatches.any():\n        bad = balance[mismatches][[\"cash\", \"stocks capital\", \"options capital\", \"total capital\"]].head(5)\n        sums = component_sum[mismatches].head(5)\n        raise AssertionError(\n            f\"Components don't sum to total at {mismatches.sum()} rows.\\n\"\n            f\"First mismatches:\\n{bad}\\nComponent sums:\\n{sums}\"\n        )\n\n\ndef _assert_no_capital_spike(balance, initial_capital, max_first_day_ratio=1.01):\n    \"\"\"Assert total capital never jumps above initial on the first day.\n\n    The first rebalance should not create money — total capital on day 1\n    should be ≤ initial_capital (plus a small tolerance for rounding).\n    \"\"\"\n    first_total = balance[\"total capital\"].iloc[1] if len(balance) > 1 else balance[\"total capital\"].iloc[0]\n    assert first_total <= initial_capital * max_first_day_ratio, (\n        f\"Capital spiked on first day: {first_total:.2f} > {initial_capital * max_first_day_ratio:.2f}. \"\n        f\"Possible money creation.\"\n    )\n\n\nclass TestCapitalConservationAQR:\n    \"\"\"AQR framing: sell stocks to fund puts. No external money.\"\"\"\n\n    @pytest.fixture(autouse=True)\n    def setup(self):\n        stocks_data = _stocks_data()\n        options_data = _options_data()\n        schema = options_data.schema\n\n        self.engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n            initial_capital=100_000,\n        )\n        self.engine.stocks = _ivy_stocks()\n        self.engine.stocks_data = stocks_data\n        self.engine.options_data = options_data\n        self.engine.options_strategy = _buy_strategy(schema)\n        self.engine.run(rebalance_freq=1)\n\n    def test_components_sum_to_total(self):\n        _assert_balance_components_sum(self.engine.balance)\n\n    def test_no_first_day_spike(self):\n        _assert_no_capital_spike(self.engine.balance, 100_000)\n\n    def test_final_capital_plausible(self):\n        \"\"\"With NoCosts and OTM puts, total capital should stay near initial.\"\"\"\n        final = self.engine.balance[\"total capital\"].iloc[-1]\n        # Should not grow by more than 50% from options alone on small test data\n        assert final < 100_000 * 1.5, f\"Suspiciously high final capital: {final}\"\n        # Should not go negative\n        assert final > 0\n\n\nclass TestCapitalConservationSpitznagel:\n    \"\"\"Spitznagel framing: 100% stocks + external put budget.\"\"\"\n\n    @pytest.fixture(autouse=True)\n    def setup(self):\n        stocks_data = _stocks_data()\n        options_data = _options_data()\n        schema = options_data.schema\n\n        self.engine = BacktestEngine(\n            {\"stocks\": 1.0, \"options\": 0.0, \"cash\": 0},\n            cost_model=NoCosts(),\n            initial_capital=100_000,\n        )\n        self.engine.options_budget_pct = 0.03\n        self.engine.stocks = _ivy_stocks()\n        self.engine.stocks_data = stocks_data\n        self.engine.options_data = options_data\n        self.engine.options_strategy = _buy_strategy(schema)\n        self.engine.run(rebalance_freq=1)\n\n    def test_components_sum_to_total(self):\n        _assert_balance_components_sum(self.engine.balance)\n\n\nclass TestCapitalConservationNoTrades:\n    \"\"\"With impossible entry filter, no trades should happen and capital should be stable.\"\"\"\n\n    @pytest.fixture(autouse=True)\n    def setup(self):\n        stocks_data = _stocks_data()\n        options_data = _options_data()\n        schema = options_data.schema\n\n        strat = Strategy(schema)\n        leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\n        # Impossible filter: delta > 0 for puts (never true)\n        leg.entry_filter = (schema.underlying == \"SPX\") & (schema.delta > 0)\n        leg.exit_filter = schema.dte <= 30\n        strat.add_legs([leg])\n\n        self.engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n            initial_capital=100_000,\n        )\n        self.engine.stocks = _ivy_stocks()\n        self.engine.stocks_data = stocks_data\n        self.engine.options_data = options_data\n        self.engine.options_strategy = strat\n        self.engine.run(rebalance_freq=1)\n\n    def test_components_sum_to_total(self):\n        _assert_balance_components_sum(self.engine.balance)\n\n    def test_options_capital_always_zero(self):\n        assert (self.engine.balance[\"options capital\"] == 0).all()\n\n    def test_no_trades(self):\n        assert self.engine.trade_log.empty\n\n\nclass TestCapitalConservationHighBudget:\n    \"\"\"Stress test: 50% AQR allocation. Should NOT create money.\"\"\"\n\n    @pytest.fixture(autouse=True)\n    def setup(self):\n        stocks_data = _stocks_data()\n        options_data = _options_data()\n        schema = options_data.schema\n\n        self.engine = BacktestEngine(\n            {\"stocks\": 0.50, \"options\": 0.50, \"cash\": 0},\n            cost_model=NoCosts(),\n            initial_capital=100_000,\n        )\n        self.engine.stocks = _ivy_stocks()\n        self.engine.stocks_data = stocks_data\n        self.engine.options_data = options_data\n        self.engine.options_strategy = _buy_strategy(schema)\n        self.engine.run(rebalance_freq=1)\n\n    def test_components_sum_to_total(self):\n        _assert_balance_components_sum(self.engine.balance)\n\n    def test_no_first_day_spike(self):\n        _assert_no_capital_spike(self.engine.balance, 100_000)\n\n\n# ---------------------------------------------------------------------------\n# New tests for the skip-day cash conservation fix\n# ---------------------------------------------------------------------------\n\n\nclass TestSkipDayCashConservation:\n    \"\"\"When _execute_option_entries returns early (no candidates), the options\n    allocation money must stay as cash -- it must not be destroyed.\n\n    Uses an impossible entry filter (delta > 0 for puts) so puts are never\n    found.  Verifies:\n      - cash + stocks = total at every step (options capital is always 0)\n      - cash is never lower than the stocks-only floor (i.e. options money\n        is always preserved in cash on skip days)\n    \"\"\"\n\n    @pytest.fixture(autouse=True)\n    def setup(self):\n        stocks_data = _stocks_data()\n        options_data = _options_data()\n        schema = options_data.schema\n\n        # Impossible filter: delta > 0 for puts (never satisfied)\n        strat = Strategy(schema)\n        leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\n        leg.entry_filter = (schema.underlying == \"SPX\") & (schema.delta > 0)\n        leg.exit_filter = schema.dte <= 30\n        strat.add_legs([leg])\n        strat.add_exit_thresholds(profit_pct=math.inf, loss_pct=math.inf)\n\n        self.engine = BacktestEngine(\n            {\"stocks\": 0.90, \"options\": 0.10, \"cash\": 0},\n            cost_model=NoCosts(),\n            initial_capital=100_000,\n        )\n        self.engine.stocks = _ivy_stocks()\n        self.engine.stocks_data = stocks_data\n        self.engine.options_data = options_data\n        self.engine.options_strategy = strat\n        self.engine.run(rebalance_freq=1)\n\n    def test_components_sum_to_total(self):\n        _assert_balance_components_sum(self.engine.balance)\n\n    def test_options_capital_always_zero(self):\n        assert (self.engine.balance[\"options capital\"] == 0).all(), (\n            \"Options capital should be zero when no puts are ever entered.\"\n        )\n\n    def test_cash_never_below_options_floor(self):\n        \"\"\"Cash should always hold at least the options portion of total\n        capital (since puts were never bought, the money stays as cash).\"\"\"\n        bal = self.engine.balance\n        total = bal[\"total capital\"]\n        # On skip days, cash = total - stocks.  Since options_allocation\n        # was 10% of total, cash should be >= 10% of total (minus rounding).\n        expected_min_cash = total * 0.10 - 0.01\n        # Skip the first row (initial balance row, before any rebalance)\n        actual_cash = bal[\"cash\"].iloc[1:]\n        expected = expected_min_cash.iloc[1:]\n        violations = actual_cash < expected\n        if violations.any():\n            bad = bal.iloc[1:][violations][[\"cash\", \"stocks capital\", \"total capital\"]].head(5)\n            raise AssertionError(\n                f\"Cash fell below expected options floor at {violations.sum()} rows.\\n\"\n                f\"First violations:\\n{bad}\"\n            )\n\n    def test_no_trades(self):\n        assert self.engine.trade_log.empty\n\n    def test_total_stable_with_flat_prices(self):\n        \"\"\"With constant stock prices and no options, total capital should be\n        approximately constant (NoCosts means no transaction fees).\"\"\"\n        bal = self.engine.balance\n        total = bal[\"total capital\"].iloc[1:]  # skip pre-rebalance row\n        assert total.max() <= 100_000 * 1.001, (\n            f\"Total grew unexpectedly: {total.max()}\"\n        )\n        assert total.min() >= 100_000 * 0.999, (\n            f\"Total shrunk unexpectedly: {total.min()}\"\n        )\n\n\nclass TestAQRDeploymentNeverExceedsTotal:\n    \"\"\"At every point: stocks_capital + options_capital + cash == total_capital.\n    Total capital must never exceed what is explained by stock returns and\n    option P&L.  This catches the original 'money from thin air' bug where\n    options_allocation was double-counted.\n\n    Tests across several AQR allocation ratios.\n    \"\"\"\n\n    @pytest.fixture(\n        params=[\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            {\"stocks\": 0.80, \"options\": 0.20, \"cash\": 0},\n            {\"stocks\": 0.50, \"options\": 0.50, \"cash\": 0},\n            {\"stocks\": 0.50, \"options\": 0.30, \"cash\": 0.20},\n        ],\n        ids=[\"97/3/0\", \"80/20/0\", \"50/50/0\", \"50/30/20\"],\n    )\n    def engine(self, request):\n        stocks_data = _stocks_data()\n        options_data = _options_data()\n        schema = options_data.schema\n\n        eng = BacktestEngine(\n            request.param,\n            cost_model=NoCosts(),\n            initial_capital=100_000,\n        )\n        eng.stocks = _ivy_stocks()\n        eng.stocks_data = stocks_data\n        eng.options_data = options_data\n        eng.options_strategy = _buy_strategy(schema)\n        eng.run(rebalance_freq=1)\n        return eng\n\n    def test_components_sum_to_total(self, engine):\n        _assert_balance_components_sum(engine.balance)\n\n    def test_total_never_above_initial_on_flat_prices(self, engine):\n        \"\"\"Stock prices are flat at 10 (set by _stocks_data), so stocks\n        generate no return.  Total capital should never materially exceed\n        initial capital by an unreasonable amount.  With large options\n        allocations, option MTM can legitimately swing, so we use a generous\n        bound (3x) that catches runaway money creation but not legitimate\n        option P&L.\"\"\"\n        bal = engine.balance\n        total = bal[\"total capital\"]\n        assert total.max() < 100_000 * 3.0, (\n            f\"Total capital suspiciously high: {total.max():.2f}. \"\n            f\"Possible money creation.\"\n        )\n\n    def test_deployment_never_exceeds_total(self, engine):\n        \"\"\"stocks_capital + options_capital should never exceed total_capital.\n        If it does, cash would be negative, meaning we spent money we didn't\n        have.\"\"\"\n        bal = engine.balance\n        deployed = bal[\"stocks capital\"] + bal[\"options capital\"]\n        total = bal[\"total capital\"]\n        overdeployed = deployed > total + 0.01\n        if overdeployed.any():\n            bad = bal[overdeployed][\n                [\"cash\", \"stocks capital\", \"options capital\", \"total capital\"]\n            ].head(5)\n            raise AssertionError(\n                f\"Deployed capital exceeds total at {overdeployed.sum()} rows.\\n\"\n                f\"First overdeployments:\\n{bad}\"\n            )\n\n    def test_cash_never_negative(self, engine):\n        \"\"\"Cash should never go meaningfully negative (small float noise OK).\"\"\"\n        bal = engine.balance\n        cash = bal[\"cash\"]\n        bad_cash = cash < -0.01\n        if bad_cash.any():\n            bad = bal[bad_cash][\n                [\"cash\", \"stocks capital\", \"options capital\", \"total capital\"]\n            ].head(5)\n            raise AssertionError(\n                f\"Cash went negative at {bad_cash.sum()} rows.\\n\"\n                f\"First violations:\\n{bad}\"\n            )\n\n\nclass TestAQRRebalanceCycleAccounting:\n    \"\"\"After a full cycle (buy puts -> puts expire/exit -> rebalance), verify\n    that total_capital change is explained only by stock price moves and\n    option P&L -- not by cash appearing or disappearing.\n\n    Since _stocks_data() sets all prices to 10 (flat), and we use NoCosts,\n    the total capital change should come purely from option value changes.\n    We verify this by checking that cash + stocks + options = total at every\n    row, and that the net change in total capital matches the net change in\n    the component sum.\n    \"\"\"\n\n    @pytest.fixture(autouse=True)\n    def setup(self):\n        stocks_data = _stocks_data()\n        options_data = _options_data()\n        schema = options_data.schema\n\n        self.engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n            initial_capital=100_000,\n        )\n        self.engine.stocks = _ivy_stocks()\n        self.engine.stocks_data = stocks_data\n        self.engine.options_data = options_data\n        self.engine.options_strategy = _buy_strategy(schema)\n        self.engine.run(rebalance_freq=1)\n\n    def test_components_sum_to_total(self):\n        _assert_balance_components_sum(self.engine.balance)\n\n    def test_total_change_equals_component_change(self):\n        \"\"\"Delta(total) == Delta(cash) + Delta(stocks) + Delta(options).\n        If not, money leaked in or out.\"\"\"\n        bal = self.engine.balance\n        d_total = bal[\"total capital\"].diff().iloc[1:]\n        d_cash = bal[\"cash\"].diff().iloc[1:]\n        d_stocks = bal[\"stocks capital\"].diff().iloc[1:]\n        d_options = bal[\"options capital\"].diff().iloc[1:]\n        d_components = d_cash + d_stocks + d_options\n        mismatches = ~np.isclose(d_total, d_components, rtol=1e-6, atol=0.01)\n        if mismatches.any():\n            bad_idx = d_total[mismatches].index[:5]\n            raise AssertionError(\n                f\"Total capital change does not match component changes at \"\n                f\"{mismatches.sum()} rows.\\n\"\n                f\"Dates: {bad_idx.tolist()}\\n\"\n                f\"d_total: {d_total[mismatches].head(5).tolist()}\\n\"\n                f\"d_components: {d_components[mismatches].head(5).tolist()}\"\n            )\n\n    def test_no_cash_leak_over_full_run(self):\n        \"\"\"Over the entire run, the total change in capital should equal\n        the sum of: stock returns + option P&L.  We verify this by checking\n        that [total_final - total_initial] == [sum of period-by-period\n        component changes].\"\"\"\n        bal = self.engine.balance\n        total_change = bal[\"total capital\"].iloc[-1] - bal[\"total capital\"].iloc[0]\n        component_changes = (\n            bal[\"cash\"].diff().sum()\n            + bal[\"stocks capital\"].diff().sum()\n            + bal[\"options capital\"].diff().sum()\n        )\n        assert np.isclose(total_change, component_changes, rtol=1e-6, atol=0.01), (\n            f\"Cumulative total change {total_change:.4f} != \"\n            f\"cumulative component changes {component_changes:.4f}. \"\n            f\"Cash leaked: {total_change - component_changes:.4f}\"\n        )\n\n\nclass TestAQRvsSpitznagelZeroBudget:\n    \"\"\"With an impossible filter (no puts ever bought), AQR has less equity\n    exposure than Spitznagel.  AQR allocates (1 - options_pct) to stocks,\n    while Spitznagel allocates 100% to stocks.\n\n    When puts are never bought:\n    - AQR 97/3: only 97% in stocks, 3% idle in cash\n    - Spitznagel 100/0 + 3% budget: 100% in stocks, budget never spent\n\n    With flat prices both should preserve capital, but Spitznagel should\n    have higher (or equal) equity exposure and thus higher (or equal)\n    returns in a rising market.  With flat prices (all = 10), they should\n    be approximately equal, but AQR should never beat Spitznagel since\n    AQR holds less stock.\n    \"\"\"\n\n    @pytest.fixture(autouse=True)\n    def setup(self):\n        stocks_data = _stocks_data()\n        options_data = _options_data()\n        schema = options_data.schema\n\n        # Impossible filter so no puts are ever entered\n        strat_impossible = Strategy(schema)\n        leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\n        leg.entry_filter = (schema.underlying == \"SPX\") & (schema.delta > 0)\n        leg.exit_filter = schema.dte <= 30\n        strat_impossible.add_legs([leg])\n        strat_impossible.add_exit_thresholds(profit_pct=math.inf, loss_pct=math.inf)\n\n        # AQR framing: 97% stocks, 3% options (from stock allocation)\n        self.aqr_engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n            initial_capital=100_000,\n        )\n        self.aqr_engine.stocks = _ivy_stocks()\n        self.aqr_engine.stocks_data = stocks_data\n        self.aqr_engine.options_data = options_data\n        self.aqr_engine.options_strategy = strat_impossible\n        self.aqr_engine.run(rebalance_freq=1)\n\n        # Spitznagel framing: 100% stocks + external 3% budget\n        self.spitz_engine = BacktestEngine(\n            {\"stocks\": 1.0, \"options\": 0.0, \"cash\": 0},\n            cost_model=NoCosts(),\n            initial_capital=100_000,\n        )\n        self.spitz_engine.options_budget_pct = 0.03\n        self.spitz_engine.stocks = _ivy_stocks()\n        self.spitz_engine.stocks_data = stocks_data\n        self.spitz_engine.options_data = options_data\n        self.spitz_engine.options_strategy = strat_impossible\n        self.spitz_engine.run(rebalance_freq=1)\n\n    def test_both_conserve_capital(self):\n        _assert_balance_components_sum(self.aqr_engine.balance)\n        _assert_balance_components_sum(self.spitz_engine.balance)\n\n    def test_aqr_less_equity_than_spitznagel(self):\n        \"\"\"AQR should have strictly less stock capital than Spitznagel,\n        since AQR reserves 3% for options (held as cash when puts not found)\n        while Spitznagel puts 100% in stocks.\"\"\"\n        aqr_stocks = self.aqr_engine.balance[\"stocks capital\"].iloc[1:]\n        spitz_stocks = self.spitz_engine.balance[\"stocks capital\"].iloc[1:]\n        # Align on common dates\n        common = aqr_stocks.index.intersection(spitz_stocks.index)\n        assert len(common) > 0, \"No overlapping dates between AQR and Spitznagel\"\n        assert (aqr_stocks.loc[common] <= spitz_stocks.loc[common] + 0.01).all(), (\n            \"AQR has more stock capital than Spitznagel -- allocation is wrong.\"\n        )\n\n    def test_aqr_return_leq_spitznagel(self):\n        \"\"\"With flat prices and no puts bought, AQR total return should be\n        less than or equal to Spitznagel (AQR holds less stock).\"\"\"\n        aqr_final = self.aqr_engine.balance[\"total capital\"].iloc[-1]\n        spitz_final = self.spitz_engine.balance[\"total capital\"].iloc[-1]\n        assert aqr_final <= spitz_final + 0.01, (\n            f\"AQR final ({aqr_final:.2f}) > Spitznagel final ({spitz_final:.2f}). \"\n            f\"AQR should not outperform with less equity and no options.\"\n        )\n\n    def test_no_trades_in_either(self):\n        assert self.aqr_engine.trade_log.empty, \"AQR should have no trades\"\n        assert self.spitz_engine.trade_log.empty, \"Spitznagel should have no trades\"\n\n    def test_aqr_has_cash_from_unspent_options(self):\n        \"\"\"In AQR framing with impossible filter, the 3% options allocation\n        should remain as cash since it was never spent on puts.\"\"\"\n        bal = self.aqr_engine.balance\n        # After first rebalance, cash should be roughly 3% of total\n        cash = bal[\"cash\"].iloc[1:]\n        total = bal[\"total capital\"].iloc[1:]\n        ratio = cash / total\n        # Should be close to 3% (the unspent options allocation)\n        assert (ratio > 0.02).all(), (\n            f\"AQR cash ratio too low -- options money was destroyed.\\n\"\n            f\"Min ratio: {ratio.min():.4f}, expected ~0.03\"\n        )\n\n\nclass TestExternallyFundedNoLeakage:\n    \"\"\"Verify that the externally-funded (budget_pct) path does not leak cash.\n\n    When budget_pct is set, the engine injects `remaining_budget` into cash\n    before buying puts, then must claw back the full unspent amount.  If the\n    put trade costs less than remaining_budget (due to floor(qty) rounding),\n    the difference must be removed from cash — not left as phantom money.\n\n    Uses flat stock prices (all 10) so any growth in total capital beyond\n    option MTM is evidence of a cash leak.\n    \"\"\"\n\n    @pytest.fixture(\n        params=[0.005, 0.01, 0.03, 0.10],\n        ids=[\"0.5%\", \"1%\", \"3%\", \"10%\"],\n    )\n    def engine(self, request):\n        stocks_data = _stocks_data()\n        options_data = _options_data()\n        schema = options_data.schema\n\n        eng = BacktestEngine(\n            {\"stocks\": 1.0, \"options\": 0.0, \"cash\": 0},\n            cost_model=NoCosts(),\n            initial_capital=100_000,\n        )\n        eng.options_budget_pct = request.param\n        eng.stocks = _ivy_stocks()\n        eng.stocks_data = stocks_data\n        eng.options_data = options_data\n        eng.options_strategy = _buy_strategy(schema)\n        eng.run(rebalance_freq=1)\n        return eng\n\n    def test_components_sum_to_total(self, engine):\n        _assert_balance_components_sum(engine.balance)\n\n    def test_no_phantom_cash_growth(self, engine):\n        \"\"\"With flat stock prices, total capital changes should come only from\n        option MTM, not from cash leaking in.  Cash should never exceed the\n        non-options portion of total capital.\"\"\"\n        bal = engine.balance\n        # In externally-funded mode, stocks get liquid_capital (= cash + stock_cap).\n        # Cash after buying stocks should be ~0 (all deployed to stocks), plus\n        # any unspent options budget should be clawed back.\n        # On days with no option positions, total ~= stock_cap ~= initial.\n        # Any row where cash > budget_amount suggests a leak.\n        total = bal[\"total capital\"].iloc[1:]\n        cash = bal[\"cash\"].iloc[1:]\n        # Cash should never be a significant fraction of total\n        # (it should be near 0 after buying stocks, with only rounding leftovers)\n        max_cash_ratio = (cash / total).max()\n        assert max_cash_ratio < 0.05, (\n            f\"Cash/total ratio reached {max_cash_ratio:.4f} — possible cash leak \"\n            f\"from externally-funded budget injection.\"\n        )\n\n    def test_cash_never_negative(self, engine):\n        bal = engine.balance\n        bad = bal[\"cash\"] < -0.01\n        assert not bad.any(), (\n            f\"Cash went negative at {bad.sum()} rows.\"\n        )\n\n    def test_total_capital_bounded(self, engine):\n        \"\"\"With flat prices and OTM puts that mostly expire worthless,\n        total capital should not grow significantly above initial.\"\"\"\n        final = engine.balance[\"total capital\"].iloc[-1]\n        assert final < 100_000 * 1.5, (\n            f\"Final capital {final:.0f} suspiciously high for flat stock prices.\"\n        )\n"
  },
  {
    "path": "tests/engine/test_chaos.py",
    "content": "\"\"\"Chaos / fault-injection tests — corrupted and adversarial data.\n\nFeed corrupted data through the engine. Assert: either raises a clear error\nOR completes with math.isfinite(final_capital). Never silently produces NaN/Inf.\n\nReuses the test_engine.py data pattern, then corrupts ._data in-memory.\n\"\"\"\n\nimport math\nimport os\n\nimport numpy as np\nimport pandas as pd\nimport pytest\n\nfrom options_portfolio_backtester.engine.engine import BacktestEngine\nfrom options_portfolio_backtester.execution.cost_model import NoCosts, PerContractCommission\nfrom options_portfolio_backtester.execution.fill_model import (\n    MarketAtBidAsk, MidPrice, VolumeAwareFill,\n)\nfrom options_portfolio_backtester.execution.signal_selector import NearestDelta, FirstMatch\nfrom options_portfolio_backtester.portfolio.risk import RiskManager, MaxDelta, MaxVega\nfrom options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData\nfrom options_portfolio_backtester.strategy.strategy import Strategy\nfrom options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\nfrom options_portfolio_backtester.core.types import Stock, OptionType as Type, Direction\n\nTEST_DIR = os.path.join(os.path.dirname(__file__), \"..\", \"test_data\")\nSTOCKS_FILE = os.path.join(TEST_DIR, \"ivy_5assets_data.csv\")\nOPTIONS_FILE = os.path.join(TEST_DIR, \"options_data.csv\")\n\npytestmark = pytest.mark.chaos\n\n\n# ---------------------------------------------------------------------------\n# Shared helpers\n# ---------------------------------------------------------------------------\n\ndef _ivy_stocks():\n    return [Stock(\"VTI\", 0.2), Stock(\"VEU\", 0.2), Stock(\"BND\", 0.2),\n            Stock(\"VNQ\", 0.2), Stock(\"DBC\", 0.2)]\n\n\ndef _stocks_data():\n    data = TiingoData(STOCKS_FILE)\n    data._data[\"adjClose\"] = 10\n    return data\n\n\ndef _options_data():\n    data = HistoricalOptionsData(OPTIONS_FILE)\n    data._data.at[2, \"ask\"] = 1\n    data._data.at[2, \"bid\"] = 0.5\n    data._data.at[51, \"ask\"] = 1.5\n    data._data.at[50, \"bid\"] = 0.5\n    data._data.at[130, \"bid\"] = 0.5\n    data._data.at[131, \"bid\"] = 1.5\n    data._data.at[206, \"bid\"] = 0.5\n    data._data.at[207, \"bid\"] = 1.5\n    return data\n\n\ndef _build_strategy(schema, direction=Direction.BUY):\n    strat = Strategy(schema)\n    leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=direction)\n    leg.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n    leg.exit_filter = schema.dte <= 30\n    strat.add_legs([leg])\n    return strat\n\n\ndef _run_chaos(options_data, stocks_data=None, cost_model=None,\n               fill_model=None, signal_selector=None, risk_manager=None,\n               direction=Direction.BUY, initial_capital=1_000_000):\n    \"\"\"Run engine with possibly-corrupted data. Returns engine or raises.\"\"\"\n    stocks = _ivy_stocks()\n    sd = stocks_data or _stocks_data()\n    schema = options_data.schema\n\n    engine = BacktestEngine(\n        {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n        cost_model=cost_model or NoCosts(),\n        fill_model=fill_model or MarketAtBidAsk(),\n        signal_selector=signal_selector or NearestDelta(target_delta=-0.30),\n        risk_manager=risk_manager or RiskManager(),\n        initial_capital=initial_capital,\n    )\n    engine.stocks = stocks\n    engine.stocks_data = sd\n    engine.options_data = options_data\n    engine.options_strategy = _build_strategy(schema, direction=direction)\n    engine.run(rebalance_freq=1)\n    return engine\n\n\ndef _assert_finite_or_error(fn):\n    \"\"\"Call fn(). If it succeeds, assert final capital is finite. If it raises, that's OK too.\"\"\"\n    try:\n        engine = fn()\n        final = engine.balance[\"total capital\"].iloc[-1]\n        assert math.isfinite(final), f\"Non-finite final capital: {final}\"\n        return engine\n    except (ValueError, KeyError, IndexError, ZeroDivisionError, AssertionError, RuntimeError):\n        pass  # Clear error is acceptable\n\n\n# ---------------------------------------------------------------------------\n# Chaos test classes\n# ---------------------------------------------------------------------------\n\nclass TestNaNInjection:\n    \"\"\"NaN injected into bid, ask, delta, volume columns.\"\"\"\n\n    def test_nan_all_bids(self):\n        od = _options_data()\n        od._data[\"bid\"] = np.nan\n        _assert_finite_or_error(lambda: _run_chaos(od))\n\n    def test_nan_all_asks(self):\n        od = _options_data()\n        od._data[\"ask\"] = np.nan\n        _assert_finite_or_error(lambda: _run_chaos(od))\n\n    def test_nan_scattered_bid(self):\n        od = _options_data()\n        mask = od._data.index % 2 == 0\n        od._data.loc[mask, \"bid\"] = np.nan\n        _assert_finite_or_error(lambda: _run_chaos(od))\n\n    def test_nan_scattered_ask(self):\n        od = _options_data()\n        mask = od._data.index % 2 == 0\n        od._data.loc[mask, \"ask\"] = np.nan\n        _assert_finite_or_error(lambda: _run_chaos(od))\n\n    def test_nan_delta(self):\n        od = _options_data()\n        if \"delta\" in od._data.columns:\n            od._data[\"delta\"] = np.nan\n        _assert_finite_or_error(lambda: _run_chaos(od))\n\n    def test_nan_volume(self):\n        od = _options_data()\n        od._data[\"volume\"] = np.nan\n        _assert_finite_or_error(lambda: _run_chaos(od))\n\n\nclass TestNegativePrices:\n    \"\"\"Negative bid/ask prices — should not crash or produce NaN.\"\"\"\n\n    def test_negative_bid(self):\n        od = _options_data()\n        od._data[\"bid\"] = -1.0\n        _assert_finite_or_error(lambda: _run_chaos(od))\n\n    def test_negative_ask(self):\n        od = _options_data()\n        od._data[\"ask\"] = -5.0\n        _assert_finite_or_error(lambda: _run_chaos(od))\n\n    def test_both_negative(self):\n        od = _options_data()\n        od._data[\"bid\"] = -2.0\n        od._data[\"ask\"] = -1.0\n        _assert_finite_or_error(lambda: _run_chaos(od))\n\n\nclass TestInvertedBidAsk:\n    \"\"\"Bid > ask (crossed market) — should still produce finite fills.\"\"\"\n\n    def test_inverted_spread(self):\n        od = _options_data()\n        original_bid = od._data[\"bid\"].copy()\n        original_ask = od._data[\"ask\"].copy()\n        od._data[\"bid\"] = original_ask\n        od._data[\"ask\"] = original_bid\n        _assert_finite_or_error(lambda: _run_chaos(od))\n\n    def test_bid_equals_ask(self):\n        od = _options_data()\n        od._data[\"ask\"] = od._data[\"bid\"]\n        _assert_finite_or_error(lambda: _run_chaos(od))\n\n\nclass TestMissingColumns:\n    \"\"\"Drop delta column with NearestDelta selector — should fall back to first match.\"\"\"\n\n    def test_missing_delta_column(self):\n        od = _options_data()\n        if \"delta\" in od._data.columns:\n            od._data = od._data.drop(columns=[\"delta\"])\n        engine = _assert_finite_or_error(\n            lambda: _run_chaos(od, signal_selector=NearestDelta(target_delta=-0.30))\n        )\n        # NearestDelta falls back to iloc[0] when delta column missing\n        if engine is not None:\n            assert math.isfinite(engine.balance[\"total capital\"].iloc[-1])\n\n\nclass TestNoMatchingContracts:\n    \"\"\"All DTE=0 — entry_filter (dte >= 60) never matches.\"\"\"\n\n    def test_all_dte_zero(self):\n        od = _options_data()\n        od._data[\"dte\"] = 0\n        engine = _assert_finite_or_error(lambda: _run_chaos(od))\n        if engine is not None:\n            # No trades should have occurred\n            assert len(engine.trade_log) == 0 or engine.trade_log.empty\n\n\nclass TestZeroVolume:\n    \"\"\"Volume=0 with VolumeAwareFill — should fill at mid.\"\"\"\n\n    def test_zero_volume_fill(self):\n        od = _options_data()\n        od._data[\"volume\"] = 0\n        engine = _assert_finite_or_error(\n            lambda: _run_chaos(od, fill_model=VolumeAwareFill(full_volume_threshold=100))\n        )\n        if engine is not None:\n            assert math.isfinite(engine.balance[\"total capital\"].iloc[-1])\n\n\nclass TestExtremeGreeks:\n    \"\"\"Extreme delta/vega values — risk constraints should block/allow correctly.\"\"\"\n\n    def test_extreme_delta_blocked(self):\n        od = _options_data()\n        if \"delta\" in od._data.columns:\n            od._data[\"delta\"] = 100.0\n        rm = RiskManager(constraints=[MaxDelta(limit=0.01)])\n        engine = _assert_finite_or_error(\n            lambda: _run_chaos(od, risk_manager=rm)\n        )\n        if engine is not None:\n            assert math.isfinite(engine.balance[\"total capital\"].iloc[-1])\n\n    def test_extreme_vega_blocked(self):\n        od = _options_data()\n        if \"vega\" in od._data.columns:\n            od._data[\"vega\"] = -999.0\n        rm = RiskManager(constraints=[MaxVega(limit=0.01)])\n        engine = _assert_finite_or_error(\n            lambda: _run_chaos(od, risk_manager=rm)\n        )\n        if engine is not None:\n            assert math.isfinite(engine.balance[\"total capital\"].iloc[-1])\n\n    def test_extreme_delta_allowed(self):\n        od = _options_data()\n        if \"delta\" in od._data.columns:\n            od._data[\"delta\"] = 100.0\n        rm = RiskManager(constraints=[MaxDelta(limit=999999)])\n        engine = _assert_finite_or_error(\n            lambda: _run_chaos(od, risk_manager=rm)\n        )\n        if engine is not None:\n            assert math.isfinite(engine.balance[\"total capital\"].iloc[-1])\n\n\nclass TestDuplicateDates:\n    \"\"\"Duplicate all rows in options and stocks — should not crash.\"\"\"\n\n    def test_duplicate_options_rows(self):\n        od = _options_data()\n        od._data = pd.concat([od._data, od._data], ignore_index=True)\n        sd = _stocks_data()\n        sd._data = pd.concat([sd._data, sd._data], ignore_index=True)\n        _assert_finite_or_error(lambda: _run_chaos(od, stocks_data=sd))\n\n\nclass TestCapitalExhaustion:\n    \"\"\"Initial capital = 1 — should not crash, zero or minimal trades.\"\"\"\n\n    def test_tiny_capital(self):\n        od = _options_data()\n        engine = _assert_finite_or_error(\n            lambda: _run_chaos(od, initial_capital=1)\n        )\n        if engine is not None:\n            final = engine.balance[\"total capital\"].iloc[-1]\n            assert math.isfinite(final)\n\n    def test_zero_capital(self):\n        od = _options_data()\n        engine = _assert_finite_or_error(\n            lambda: _run_chaos(od, initial_capital=0)\n        )\n        if engine is not None:\n            final = engine.balance[\"total capital\"].iloc[-1]\n            assert math.isfinite(final)\n\n\nclass TestMassiveSpread:\n    \"\"\"bid=0.01, ask=999 — extreme spread should produce finite fills.\"\"\"\n\n    def test_massive_spread(self):\n        od = _options_data()\n        od._data[\"bid\"] = 0.01\n        od._data[\"ask\"] = 999.0\n        _assert_finite_or_error(lambda: _run_chaos(od))\n\n    def test_massive_spread_mid_fill(self):\n        od = _options_data()\n        od._data[\"bid\"] = 0.01\n        od._data[\"ask\"] = 999.0\n        _assert_finite_or_error(\n            lambda: _run_chaos(od, fill_model=MidPrice())\n        )\n\n\nclass TestAllExpired:\n    \"\"\"All DTE=0 — no entries should happen, capital preserved.\"\"\"\n\n    def test_all_expired_capital_preserved(self):\n        od = _options_data()\n        od._data[\"dte\"] = 0\n        engine = _assert_finite_or_error(lambda: _run_chaos(od))\n        if engine is not None:\n            final = engine.balance[\"total capital\"].iloc[-1]\n            assert math.isfinite(final)\n            # Capital should be close to initial since no options trades\n            assert final > 0\n\n\nclass TestSingleDay:\n    \"\"\"Filter data to a single date — stats computation should not crash.\"\"\"\n\n    def test_single_date(self):\n        od = _options_data()\n        sd = _stocks_data()\n        first_date = od._data[\"quotedate\"].iloc[0]\n        od._data = od._data[od._data[\"quotedate\"] == first_date].copy()\n        sd._data = sd._data[sd._data[\"date\"] == first_date].copy()\n        _assert_finite_or_error(lambda: _run_chaos(od, stocks_data=sd))\n"
  },
  {
    "path": "tests/engine/test_clock.py",
    "content": "\"\"\"Tests for TradingClock — date iteration and rebalance scheduling.\"\"\"\n\nimport pandas as pd\nimport numpy as np\n\nfrom options_portfolio_backtester.engine.clock import TradingClock\n\n\ndef _make_data(n_dates=5):\n    \"\"\"Create minimal stocks + options DataFrames for clock tests.\"\"\"\n    dates = pd.bdate_range(\"2020-01-06\", periods=n_dates, freq=\"B\")\n    stocks = pd.DataFrame({\n        \"date\": np.repeat(dates, 2),\n        \"symbol\": [\"SPY\", \"IWM\"] * n_dates,\n        \"adjClose\": np.random.uniform(300, 400, n_dates * 2),\n    })\n    options = pd.DataFrame({\n        \"quotedate\": np.repeat(dates, 3),\n        \"optionroot\": [f\"SPY_C_{i}\" for i in range(n_dates * 3)],\n        \"volume\": np.random.randint(100, 10000, n_dates * 3),\n    })\n    return stocks, options, dates\n\n\nclass TestDailyIteration:\n    def test_yields_correct_number_of_dates(self):\n        stocks, options, dates = _make_data(5)\n        clock = TradingClock(stocks, options)\n        result = list(clock.iter_dates())\n        assert len(result) == 5\n\n    def test_yields_tuples_of_date_stocks_options(self):\n        stocks, options, dates = _make_data(3)\n        clock = TradingClock(stocks, options)\n        for date, s, o in clock.iter_dates():\n            assert isinstance(date, pd.Timestamp)\n            assert isinstance(s, pd.DataFrame)\n            assert isinstance(o, pd.DataFrame)\n\n\nclass TestAllDates:\n    def test_returns_all_unique_dates(self):\n        stocks, options, dates = _make_data(5)\n        clock = TradingClock(stocks, options)\n        assert len(clock.all_dates) == 5\n\n\nclass TestRebalanceDates:\n    def test_zero_freq_returns_empty(self):\n        stocks, options, dates = _make_data(5)\n        clock = TradingClock(stocks, options)\n        rb = clock.rebalance_dates(0)\n        assert len(rb) == 0\n\n    def test_negative_freq_returns_empty(self):\n        stocks, options, dates = _make_data(5)\n        clock = TradingClock(stocks, options)\n        rb = clock.rebalance_dates(-1)\n        assert len(rb) == 0\n\n    def test_positive_freq_returns_dates(self):\n        # Use enough dates to span multiple months\n        dates = pd.bdate_range(\"2020-01-06\", periods=60, freq=\"B\")\n        stocks = pd.DataFrame({\n            \"date\": np.repeat(dates, 1),\n            \"symbol\": [\"SPY\"] * 60,\n            \"adjClose\": np.random.uniform(300, 400, 60),\n        })\n        options = pd.DataFrame({\n            \"quotedate\": np.repeat(dates, 1),\n            \"optionroot\": [f\"SPY_C_{i}\" for i in range(60)],\n            \"volume\": np.random.randint(100, 10000, 60),\n        })\n        clock = TradingClock(stocks, options)\n        rb = clock.rebalance_dates(1)\n        assert len(rb) > 0\n        assert isinstance(rb, pd.DatetimeIndex)\n\n\nclass TestMonthlyIteration:\n    def test_monthly_mode_yields_first_of_month_dates(self):\n        # Span 3 months of business days\n        dates = pd.bdate_range(\"2020-01-06\", periods=60, freq=\"B\")\n        stocks = pd.DataFrame({\n            \"date\": np.repeat(dates, 1),\n            \"symbol\": [\"SPY\"] * 60,\n            \"adjClose\": np.random.uniform(300, 400, 60),\n        })\n        options = pd.DataFrame({\n            \"quotedate\": np.repeat(dates, 1),\n            \"optionroot\": [f\"SPY_C_{i}\" for i in range(60)],\n            \"volume\": np.random.randint(100, 10000, 60),\n        })\n        clock = TradingClock(stocks, options, monthly=True)\n        result = list(clock.iter_dates())\n        # monthly=True should yield fewer dates than daily\n        assert len(result) <= 60\n        assert len(result) >= 1\n        for date, s, o in result:\n            assert isinstance(date, pd.Timestamp)\n"
  },
  {
    "path": "tests/engine/test_engine.py",
    "content": "\"\"\"Tests for BacktestEngine — verifies regression values and engine behavior.\"\"\"\n\nimport os\nimport pytest\nimport numpy as np\n\nfrom options_portfolio_backtester.engine.engine import BacktestEngine\nfrom options_portfolio_backtester.execution.cost_model import NoCosts, PerContractCommission\nfrom options_portfolio_backtester.execution.signal_selector import FirstMatch\nfrom options_portfolio_backtester.portfolio.risk import RiskManager\n\nfrom options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData\nfrom options_portfolio_backtester.strategy.strategy import Strategy\nfrom options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\nfrom options_portfolio_backtester.core.types import Stock, OptionType as Type, Direction\n\nTEST_DIR = os.path.join(os.path.dirname(__file__), \"..\", \"test_data\")\nSTOCKS_FILE = os.path.join(TEST_DIR, \"ivy_5assets_data.csv\")\nOPTIONS_FILE = os.path.join(TEST_DIR, \"options_data.csv\")\n\n\ndef _ivy_stocks():\n    return [Stock(\"VTI\", 0.2), Stock(\"VEU\", 0.2), Stock(\"BND\", 0.2),\n            Stock(\"VNQ\", 0.2), Stock(\"DBC\", 0.2)]\n\n\ndef _stocks_data():\n    data = TiingoData(STOCKS_FILE)\n    data._data[\"adjClose\"] = 10\n    return data\n\n\ndef _options_data():\n    \"\"\"Create test options data with known bid/ask values (same as conftest.options_data_2puts_buy).\"\"\"\n    data = HistoricalOptionsData(OPTIONS_FILE)\n    data._data.at[2, \"ask\"] = 1\n    data._data.at[2, \"bid\"] = 0.5\n    data._data.at[51, \"ask\"] = 1.5\n    data._data.at[50, \"bid\"] = 0.5\n    data._data.at[130, \"bid\"] = 0.5\n    data._data.at[131, \"bid\"] = 1.5\n    data._data.at[206, \"bid\"] = 0.5\n    data._data.at[207, \"bid\"] = 1.5\n    return data\n\n\ndef _buy_strategy(schema):\n    strat = Strategy(schema)\n    leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\n    leg.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n    leg.exit_filter = schema.dte <= 30\n    strat.add_legs([leg])\n    return strat\n\n\ndef _run_engine(cost_model=None):\n    stocks = _ivy_stocks()\n    stocks_data = _stocks_data()\n    options_data = _options_data()\n    schema = options_data.schema\n\n    engine = BacktestEngine(\n        {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n        cost_model=cost_model or NoCosts(),\n    )\n    engine.stocks = stocks\n    engine.stocks_data = stocks_data\n    engine.options_data = options_data\n    engine.options_strategy = _buy_strategy(schema)\n    engine.run(rebalance_freq=1)\n    return engine\n\n\nclass TestEngineRegressionValues:\n    \"\"\"Verify the engine produces known regression values.\"\"\"\n\n    @pytest.fixture(autouse=True)\n    def setup(self):\n        self.engine = _run_engine()\n\n    def test_trade_log_not_empty(self):\n        assert not self.engine.trade_log.empty\n\n    def test_balance_not_empty(self):\n        assert not self.engine.balance.empty\n\n    def test_regression_costs(self):\n        tol = 0.0001\n        bt = self.engine\n        # Positions persist across rebalances — only new entries, no liquidation churn.\n        assert np.allclose(bt.trade_log[\"totals\"][\"cost\"].values,\n                           [100, 150], rtol=tol)\n        assert np.allclose(bt.trade_log[\"leg_1\"][\"cost\"].values,\n                           [100, 150], rtol=tol)\n\n    def test_regression_qtys(self):\n        tol = 0.0001\n        bt = self.engine\n        assert np.allclose(\n            bt.trade_log[\"totals\"][\"qty\"].values,\n            [300, 97],\n            rtol=tol,\n        )\n\n\nclass TestEngineWithCosts:\n    \"\"\"Test that adding costs changes the result (proves costs are wired in).\"\"\"\n\n    def test_commission_reduces_final_capital(self):\n        no_cost = _run_engine()\n        with_cost = _run_engine(cost_model=PerContractCommission(rate=5.00, stock_rate=0.01))\n\n        no_cost_final = no_cost.balance[\"total capital\"].iloc[-1]\n        with_cost_final = with_cost.balance[\"total capital\"].iloc[-1]\n        assert with_cost_final < no_cost_final\n\n\nclass TestRunMetadata:\n    \"\"\"Ensure reproducibility metadata is attached to outputs.\"\"\"\n\n    def test_metadata_attached_to_trade_log_and_balance(self):\n        engine = _run_engine()\n        meta = engine.run_metadata\n\n        assert meta[\"framework\"] == \"options_portfolio_backtester.engine.BacktestEngine\"\n        assert isinstance(meta[\"git_sha\"], str)\n        assert len(meta[\"config_hash\"]) == 64\n        assert len(meta[\"data_snapshot_hash\"]) == 64\n        assert meta[\"data_snapshot\"][\"options_rows\"] > 0\n        assert meta[\"data_snapshot\"][\"stocks_rows\"] > 0\n        assert engine.trade_log.attrs[\"run_metadata\"] == meta\n        assert engine.balance.attrs[\"run_metadata\"] == meta\n\n\nclass TestEngineInit:\n    \"\"\"Test engine initialization without running backtests.\"\"\"\n\n    def test_default_allocation_normalized(self):\n        e = BacktestEngine({\"stocks\": 60, \"options\": 30, \"cash\": 10})\n        assert abs(e.allocation[\"stocks\"] - 0.6) < 1e-10\n        assert abs(e.allocation[\"options\"] - 0.3) < 1e-10\n        assert abs(e.allocation[\"cash\"] - 0.1) < 1e-10\n\n    def test_default_components(self):\n        e = BacktestEngine({\"stocks\": 1.0})\n        assert isinstance(e.cost_model, NoCosts)\n        assert isinstance(e.signal_selector, FirstMatch)\n        assert isinstance(e.risk_manager, RiskManager)\n        assert e.stop_if_broke is False\n\n    def test_stop_if_broke_flag(self):\n        e = BacktestEngine({\"stocks\": 1.0}, stop_if_broke=True)\n        assert e.stop_if_broke is True\n"
  },
  {
    "path": "tests/engine/test_engine_deep.py",
    "content": "\"\"\"Deep engine tests — multi-strategy, options_budget, SMA gating, monthly mode,\ncapital flow invariants, event logging, check_exits_daily, stop_if_broke, and more.\n\nThese tests exercise engine internals that the basic regression tests don't cover.\n\"\"\"\n\nimport math\nimport os\n\nimport numpy as np\nimport pandas as pd\nimport pytest\n\nfrom options_portfolio_backtester.engine.engine import BacktestEngine, _intrinsic_value\nfrom options_portfolio_backtester.execution.cost_model import (\n    NoCosts,\n    PerContractCommission,\n    TieredCommission,\n)\nfrom options_portfolio_backtester.execution.fill_model import MarketAtBidAsk, MidPrice, VolumeAwareFill\nfrom options_portfolio_backtester.execution.signal_selector import (\n    FirstMatch,\n    NearestDelta,\n    MaxOpenInterest,\n)\nfrom options_portfolio_backtester.execution.sizer import (\n    CapitalBased,\n    FixedQuantity,\n    FixedDollar,\n    PercentOfPortfolio,\n)\nfrom options_portfolio_backtester.portfolio.risk import (\n    RiskManager,\n    MaxDelta,\n    MaxVega,\n    MaxDrawdown,\n)\nfrom options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData\nfrom options_portfolio_backtester.strategy.strategy import Strategy\nfrom options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\nfrom options_portfolio_backtester.core.types import (\n    Stock,\n    OptionType as Type,\n    Direction,\n    Greeks,\n)\n\nTEST_DIR = os.path.join(os.path.dirname(__file__), \"..\", \"test_data\")\nSTOCKS_FILE = os.path.join(TEST_DIR, \"ivy_5assets_data.csv\")\nOPTIONS_FILE = os.path.join(TEST_DIR, \"options_data.csv\")\n\n\n# ---------------------------------------------------------------------------\n# Shared fixtures\n# ---------------------------------------------------------------------------\n\n\ndef _ivy_stocks():\n    return [\n        Stock(\"VTI\", 0.2),\n        Stock(\"VEU\", 0.2),\n        Stock(\"BND\", 0.2),\n        Stock(\"VNQ\", 0.2),\n        Stock(\"DBC\", 0.2),\n    ]\n\n\ndef _stocks_data():\n    data = TiingoData(STOCKS_FILE)\n    data._data[\"adjClose\"] = 10\n    return data\n\n\ndef _options_data():\n    data = HistoricalOptionsData(OPTIONS_FILE)\n    data._data.at[2, \"ask\"] = 1\n    data._data.at[2, \"bid\"] = 0.5\n    data._data.at[51, \"ask\"] = 1.5\n    data._data.at[50, \"bid\"] = 0.5\n    data._data.at[130, \"bid\"] = 0.5\n    data._data.at[131, \"bid\"] = 1.5\n    data._data.at[206, \"bid\"] = 0.5\n    data._data.at[207, \"bid\"] = 1.5\n    return data\n\n\ndef _buy_strategy(schema):\n    strat = Strategy(schema)\n    leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\n    leg.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n    leg.exit_filter = schema.dte <= 30\n    strat.add_legs([leg])\n    return strat\n\n\ndef _sell_strategy(schema):\n    strat = Strategy(schema)\n    leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.SELL)\n    leg.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n    leg.exit_filter = schema.dte <= 30\n    strat.add_legs([leg])\n    return strat\n\n\ndef _run_engine(**kwargs):\n    engine = BacktestEngine(\n        {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n        cost_model=kwargs.pop(\"cost_model\", NoCosts()),\n        fill_model=kwargs.pop(\"fill_model\", MarketAtBidAsk()),\n        signal_selector=kwargs.pop(\"signal_selector\", NearestDelta(target_delta=-0.30)),\n        risk_manager=kwargs.pop(\"risk_manager\", RiskManager()),\n        stop_if_broke=kwargs.pop(\"stop_if_broke\", False),\n        max_notional_pct=kwargs.pop(\"max_notional_pct\", None),\n    )\n    engine.stocks = _ivy_stocks()\n    engine.stocks_data = _stocks_data()\n    engine.options_data = _options_data()\n    engine.options_strategy = _buy_strategy(engine.options_data.schema)\n    if \"options_budget_pct\" in kwargs:\n        engine.options_budget_pct = kwargs.pop(\"options_budget_pct\")\n    engine.run(\n        rebalance_freq=kwargs.pop(\"rebalance_freq\", 1),\n        monthly=kwargs.pop(\"monthly\", False),\n        sma_days=kwargs.pop(\"sma_days\", None),\n        check_exits_daily=kwargs.pop(\"check_exits_daily\", False),\n    )\n    return engine\n\n\n# ---------------------------------------------------------------------------\n# Intrinsic value helper\n# ---------------------------------------------------------------------------\n\n\nclass TestIntrinsicValue:\n    \"\"\"Test the _intrinsic_value helper used throughout the engine.\"\"\"\n\n    def test_call_itm(self):\n        assert _intrinsic_value(\"call\", 100.0, 110.0) == 10.0\n\n    def test_call_otm(self):\n        assert _intrinsic_value(\"call\", 110.0, 100.0) == 0.0\n\n    def test_put_itm(self):\n        assert _intrinsic_value(\"put\", 110.0, 100.0) == 10.0\n\n    def test_put_otm(self):\n        assert _intrinsic_value(\"put\", 100.0, 110.0) == 0.0\n\n    def test_atm_both(self):\n        assert _intrinsic_value(\"call\", 100.0, 100.0) == 0.0\n        assert _intrinsic_value(\"put\", 100.0, 100.0) == 0.0\n\n\n# ---------------------------------------------------------------------------\n# Capital flow invariants\n# ---------------------------------------------------------------------------\n\n\nclass TestCapitalFlowInvariants:\n    \"\"\"Verify accounting identities hold after a backtest run.\"\"\"\n\n    def test_total_capital_equals_sum_of_parts(self):\n        engine = _run_engine()\n        bal = engine.balance\n        computed = bal[\"cash\"] + bal[\"stocks capital\"] + bal[\"options capital\"]\n        diff = (bal[\"total capital\"] - computed).abs()\n        assert diff.max() < 0.01, f\"Capital identity violated: max diff {diff.max()}\"\n\n    def test_accumulated_return_consistent_with_pct_change(self):\n        engine = _run_engine()\n        bal = engine.balance\n        manual_acc = (1 + bal[\"% change\"]).cumprod()\n        diff = (bal[\"accumulated return\"].dropna() - manual_acc.dropna()).abs()\n        assert diff.max() < 1e-10\n\n    def test_initial_capital_preserved_in_first_row(self):\n        engine = _run_engine()\n        assert engine.balance[\"total capital\"].iloc[0] == 1_000_000\n\n    def test_total_capital_never_negative_with_buy_only(self):\n        engine = _run_engine()\n        assert (engine.balance[\"total capital\"].dropna() >= 0).all()\n\n    def test_stock_qty_columns_present_for_all_stocks(self):\n        engine = _run_engine()\n        for stock in _ivy_stocks():\n            assert stock.symbol in engine.balance.columns\n            assert f\"{stock.symbol} qty\" in engine.balance.columns\n\n\n# ---------------------------------------------------------------------------\n# Options budget\n# ---------------------------------------------------------------------------\n\n\nclass TestOptionsBudget:\n    \"\"\"Test options_budget_pct feature.\"\"\"\n\n    def test_budget_pct(self):\n        engine = _run_engine(options_budget_pct=0.005)\n        assert engine.balance is not None\n        assert not engine.trade_log.empty\n\n    def test_budget_preserves_raw_allocation(self):\n        \"\"\"With options_budget_pct, raw allocation should be used for stocks.\"\"\"\n        engine = BacktestEngine(\n            {\"stocks\": 1.0, \"options\": 0.005, \"cash\": 0},\n            cost_model=NoCosts(),\n            signal_selector=NearestDelta(target_delta=-0.30),\n        )\n        engine.options_budget_pct = 0.005\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = _options_data()\n        engine.options_strategy = _buy_strategy(engine.options_data.schema)\n        engine.run(rebalance_freq=1)\n        # Raw allocation for stocks should be 1.0, not normalized\n        assert engine._raw_allocation[\"stocks\"] == 1.0\n\n    def test_budget_changes_trade_sizes_vs_no_budget(self):\n        \"\"\"Different budgets should produce different position sizes.\"\"\"\n        e1 = _run_engine(options_budget_pct=0.001)\n        e2 = _run_engine(options_budget_pct=0.05)\n        if not e1.trade_log.empty and not e2.trade_log.empty:\n            q1 = e1.trade_log[\"totals\"][\"qty\"].values\n            q2 = e2.trade_log[\"totals\"][\"qty\"].values\n            # Larger budget should buy more contracts\n            assert q2[0] > q1[0] or len(q2) != len(q1)\n\n\n# ---------------------------------------------------------------------------\n# Monthly mode\n# ---------------------------------------------------------------------------\n\n\nclass TestMonthlyMode:\n    \"\"\"Test monthly iteration mode.\"\"\"\n\n    def test_monthly_mode_runs(self):\n        engine = _run_engine(monthly=True)\n        assert engine.balance is not None\n\n    def test_monthly_produces_fewer_balance_rows(self):\n        daily_engine = _run_engine(monthly=False)\n        monthly_engine = _run_engine(monthly=True)\n        assert len(monthly_engine.balance) <= len(daily_engine.balance)\n\n\n# ---------------------------------------------------------------------------\n# check_exits_daily\n# ---------------------------------------------------------------------------\n\n\nclass TestCheckExitsDaily:\n    \"\"\"Test daily exit checking on non-rebalance days.\"\"\"\n\n    def test_daily_exits_runs_without_error(self):\n        engine = _run_engine(check_exits_daily=True)\n        assert engine.balance is not None\n\n    def test_daily_exits_may_close_positions_earlier(self):\n        \"\"\"With daily exit checking, positions may be closed sooner.\"\"\"\n        engine_no = _run_engine(check_exits_daily=False)\n        engine_yes = _run_engine(check_exits_daily=True)\n        # Both should complete; daily exits may produce more trade rows\n        assert engine_no.balance is not None\n        assert engine_yes.balance is not None\n\n\n# ---------------------------------------------------------------------------\n# stop_if_broke\n# ---------------------------------------------------------------------------\n\n\nclass TestStopIfBroke:\n    \"\"\"Test stop_if_broke halting behavior.\"\"\"\n\n    def test_completes_without_stopping(self):\n        engine = _run_engine(stop_if_broke=True)\n        assert engine.balance is not None\n        assert len(engine.balance) > 1\n\n\n# ---------------------------------------------------------------------------\n# SMA gating\n# ---------------------------------------------------------------------------\n\n\nclass TestSMAGating:\n    \"\"\"Test SMA-based stock buying gating.\"\"\"\n\n    def test_sma_gating_runs(self):\n        engine = _run_engine(sma_days=20)\n        assert engine.balance is not None\n\n    def test_sma_gating_changes_stock_allocation(self):\n        \"\"\"SMA gating should reduce stock buying when price < SMA.\"\"\"\n        engine_no_sma = _run_engine(sma_days=None)\n        engine_sma = _run_engine(sma_days=5)\n        # Both should produce valid results\n        assert not engine_no_sma.balance.empty\n        assert not engine_sma.balance.empty\n        # Stock quantities may differ\n        final_no = engine_no_sma.balance[\"stocks qty\"].iloc[-1]\n        final_sma = engine_sma.balance[\"stocks qty\"].iloc[-1]\n        # With constant adjClose=10 and sma also=10, SMA gate may pass or block\n        # depending on initialization; key is it doesn't crash\n        assert final_no >= 0\n        assert final_sma >= 0\n\n\n# ---------------------------------------------------------------------------\n# Event log\n# ---------------------------------------------------------------------------\n\n\nclass TestEventLog:\n    \"\"\"Test structured event logging.\n\n    Events are not populated by the Rust full-loop (it bypasses Python event\n    logging), so these tests just verify the events_dataframe() API works.\n    \"\"\"\n\n    def test_events_dataframe_returns_dataframe(self):\n        engine = _run_engine()\n        events = engine.events_dataframe()\n        assert hasattr(events, \"columns\")\n\n    def test_event_log_has_required_columns(self):\n        engine = _run_engine()\n        events = engine.events_dataframe()\n        assert \"date\" in events.columns\n        assert \"event\" in events.columns\n        assert \"status\" in events.columns\n\n\n# ---------------------------------------------------------------------------\n# Allocation normalization\n# ---------------------------------------------------------------------------\n\n\nclass TestAllocationNormalization:\n    \"\"\"Test that allocation dict is normalized correctly.\"\"\"\n\n    def test_unnormalized_sums_to_one(self):\n        engine = BacktestEngine({\"stocks\": 60, \"options\": 30, \"cash\": 10})\n        total = sum(engine.allocation.values())\n        assert abs(total - 1.0) < 1e-10\n\n    def test_already_normalized(self):\n        engine = BacktestEngine({\"stocks\": 0.5, \"options\": 0.3, \"cash\": 0.2})\n        assert abs(engine.allocation[\"stocks\"] - 0.5) < 1e-10\n\n    def test_missing_keys_default_to_zero(self):\n        engine = BacktestEngine({\"stocks\": 1.0})\n        assert engine.allocation[\"options\"] == 0.0\n        assert engine.allocation[\"cash\"] == 0.0\n\n    def test_raw_allocation_preserved(self):\n        engine = BacktestEngine({\"stocks\": 60, \"options\": 30, \"cash\": 10})\n        assert engine._raw_allocation[\"stocks\"] == 60\n        assert engine._raw_allocation[\"options\"] == 30\n\n\n# ---------------------------------------------------------------------------\n# Multi-strategy mode\n# ---------------------------------------------------------------------------\n\n\nclass TestMultiStrategy:\n    \"\"\"Test multi-strategy mode with multiple strategy slots.\"\"\"\n\n    def _make_multi_engine(self):\n        engine = BacktestEngine(\n            {\"stocks\": 0.90, \"options\": 0.10, \"cash\": 0},\n            cost_model=NoCosts(),\n            signal_selector=NearestDelta(target_delta=-0.30),\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = _options_data()\n        schema = engine.options_data.schema\n        return engine, schema\n\n    def test_two_strategies_equal_weight(self):\n        engine, schema = self._make_multi_engine()\n        engine.add_strategy(_buy_strategy(schema), weight=0.5, rebalance_freq=1, name=\"buy_puts\")\n        engine.add_strategy(_buy_strategy(schema), weight=0.5, rebalance_freq=1, name=\"buy_puts_2\")\n        engine.run()\n        assert engine.balance is not None\n        assert \"framework\" in engine.run_metadata\n\n    def test_multi_strategy_weights_must_sum_to_one(self):\n        engine, schema = self._make_multi_engine()\n        engine.add_strategy(_buy_strategy(schema), weight=0.3, rebalance_freq=1)\n        engine.add_strategy(_buy_strategy(schema), weight=0.3, rebalance_freq=1)\n        with pytest.raises(AssertionError, match=\"weights must sum\"):\n            engine.run()\n\n    def test_multi_strategy_different_frequencies(self):\n        engine, schema = self._make_multi_engine()\n        engine.add_strategy(_buy_strategy(schema), weight=0.5, rebalance_freq=1, name=\"monthly\")\n        engine.add_strategy(_buy_strategy(schema), weight=0.5, rebalance_freq=2, name=\"bimonthly\")\n        engine.run()\n        assert engine.balance is not None\n\n    def test_multi_strategy_with_daily_exit_checks(self):\n        engine, schema = self._make_multi_engine()\n        engine.add_strategy(\n            _buy_strategy(schema), weight=0.5, rebalance_freq=1,\n            check_exits_daily=True, name=\"daily_exit\"\n        )\n        engine.add_strategy(\n            _buy_strategy(schema), weight=0.5, rebalance_freq=1, name=\"no_daily_exit\"\n        )\n        engine.run(check_exits_daily=False)\n        assert engine.balance is not None\n\n    def test_multi_strategy_capital_identity(self):\n        engine, schema = self._make_multi_engine()\n        engine.add_strategy(_buy_strategy(schema), weight=0.5, rebalance_freq=1)\n        engine.add_strategy(_buy_strategy(schema), weight=0.5, rebalance_freq=1)\n        engine.run()\n        bal = engine.balance\n        computed = bal[\"cash\"] + bal[\"stocks capital\"] + bal[\"options capital\"]\n        diff = (bal[\"total capital\"] - computed).abs()\n        assert diff.max() < 0.01\n\n\n# ---------------------------------------------------------------------------\n# Risk management integration\n# ---------------------------------------------------------------------------\n\n\nclass TestRiskManagementIntegration:\n    \"\"\"Test that risk constraints actually block entries in the engine.\"\"\"\n\n    def test_max_delta_blocks_large_positions(self):\n        \"\"\"Very tight delta limit should block some entries.\"\"\"\n        rm = RiskManager([MaxDelta(limit=0.001)])\n        engine = _run_engine(risk_manager=rm)\n        # Engine should complete; some entries may be blocked\n        assert engine.balance is not None\n\n    def test_max_vega_blocks_entries(self):\n        rm = RiskManager([MaxVega(limit=0.001)])\n        engine = _run_engine(risk_manager=rm)\n        assert engine.balance is not None\n\n    def test_max_drawdown_blocks_during_dd(self):\n        rm = RiskManager([MaxDrawdown(max_dd_pct=0.001)])\n        engine = _run_engine(risk_manager=rm)\n        assert engine.balance is not None\n\n    def test_no_constraints_allows_all(self):\n        rm = RiskManager()\n        engine = _run_engine(risk_manager=rm)\n        assert not engine.trade_log.empty\n\n    def test_compound_constraints(self):\n        rm = RiskManager([MaxDelta(limit=1000.0), MaxVega(limit=1000.0)])\n        engine = _run_engine(risk_manager=rm)\n        assert engine.balance is not None\n        assert not engine.trade_log.empty\n\n    def test_risk_events_logged_on_block(self):\n        rm = RiskManager([MaxDelta(limit=0.001)])\n        engine = _run_engine(risk_manager=rm)\n        events = engine.events_dataframe()\n        # If delta was blocked, we should see risk_block_entry events\n        blocked = events[events[\"event\"] == \"risk_block_entry\"]\n        # May or may not trigger depending on actual Greeks in test data\n        assert engine.balance is not None\n\n\n# ---------------------------------------------------------------------------\n# Execution component combinations\n# ---------------------------------------------------------------------------\n\n\nclass TestExecutionCombinations:\n    \"\"\"Test various execution component combinations in the engine.\"\"\"\n\n    def test_midprice_fill(self):\n        engine = _run_engine(fill_model=MidPrice())\n        assert engine.balance is not None\n\n    def test_volume_aware_fill(self):\n        engine = _run_engine(fill_model=VolumeAwareFill(full_volume_threshold=10))\n        assert engine.balance is not None\n\n    def test_per_contract_commission(self):\n        engine = _run_engine(cost_model=PerContractCommission(rate=1.0))\n        assert engine.balance is not None\n\n    def test_tiered_commission(self):\n        engine = _run_engine(cost_model=TieredCommission())\n        assert engine.balance is not None\n\n    def test_max_open_interest_selector(self):\n        engine = _run_engine(signal_selector=MaxOpenInterest(oi_column=\"openinterest\"))\n        assert engine.balance is not None\n\n    def test_commission_reduces_capital_consistently(self):\n        \"\"\"Higher commission rates should strictly reduce final capital.\"\"\"\n        e_free = _run_engine(cost_model=NoCosts())\n        e_cheap = _run_engine(cost_model=PerContractCommission(rate=0.50, stock_rate=0.001))\n        e_expensive = _run_engine(cost_model=PerContractCommission(rate=10.0, stock_rate=0.10))\n        f0 = e_free.balance[\"total capital\"].iloc[-1]\n        f1 = e_cheap.balance[\"total capital\"].iloc[-1]\n        f2 = e_expensive.balance[\"total capital\"].iloc[-1]\n        assert f0 >= f1 >= f2\n\n\n# ---------------------------------------------------------------------------\n# max_notional_pct\n# ---------------------------------------------------------------------------\n\n\nclass TestMaxNotionalPct:\n    \"\"\"Test max_notional_pct constraint on short selling.\"\"\"\n\n    def test_max_notional_limits_sell_positions(self):\n        \"\"\"Very tight notional limit should restrict sell position size.\"\"\"\n        engine = BacktestEngine(\n            {\"stocks\": 0.90, \"options\": 0.10, \"cash\": 0},\n            cost_model=NoCosts(),\n            signal_selector=NearestDelta(target_delta=-0.30),\n            max_notional_pct=0.001,  # very tight\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = _options_data()\n        engine.options_strategy = _sell_strategy(engine.options_data.schema)\n        engine.run(rebalance_freq=1)\n        assert engine.balance is not None\n\n\n# ---------------------------------------------------------------------------\n# Sell-direction strategies\n# ---------------------------------------------------------------------------\n\n\nclass TestSellDirectionStrategy:\n    \"\"\"Test that sell-direction legs have correct sign on costs.\"\"\"\n\n    def test_sell_puts_run(self):\n        engine = BacktestEngine(\n            {\"stocks\": 0.90, \"options\": 0.10, \"cash\": 0},\n            cost_model=NoCosts(),\n            signal_selector=NearestDelta(target_delta=-0.30),\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = _options_data()\n        engine.options_strategy = _sell_strategy(engine.options_data.schema)\n        engine.run(rebalance_freq=1)\n        assert engine.balance is not None\n\n    def test_sell_entry_costs_are_negative(self):\n        \"\"\"SELL entries should produce negative cost (credit received).\"\"\"\n        engine = BacktestEngine(\n            {\"stocks\": 0.90, \"options\": 0.10, \"cash\": 0},\n            cost_model=NoCosts(),\n            signal_selector=NearestDelta(target_delta=-0.30),\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = _options_data()\n        engine.options_strategy = _sell_strategy(engine.options_data.schema)\n        engine.run(rebalance_freq=1)\n        if not engine.trade_log.empty:\n            costs = engine.trade_log[\"leg_1\"][\"cost\"].values\n            # At least some costs should be negative (credit)\n            assert any(c < 0 for c in costs)\n\n\n# ---------------------------------------------------------------------------\n# Exit thresholds\n# ---------------------------------------------------------------------------\n\n\nclass TestExitThresholds:\n    \"\"\"Test profit/loss threshold exits.\"\"\"\n\n    def test_very_tight_profit_threshold(self):\n        schema = _options_data().schema\n        strat = _buy_strategy(schema)\n        strat.add_exit_thresholds(profit_pct=0.001)\n        engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n            signal_selector=NearestDelta(target_delta=-0.30),\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = _options_data()\n        engine.options_strategy = strat\n        engine.run(rebalance_freq=1)\n        assert engine.balance is not None\n\n    def test_very_tight_loss_threshold(self):\n        schema = _options_data().schema\n        strat = _buy_strategy(schema)\n        strat.add_exit_thresholds(loss_pct=0.001)\n        engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n            signal_selector=NearestDelta(target_delta=-0.30),\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = _options_data()\n        engine.options_strategy = strat\n        engine.run(rebalance_freq=1)\n        assert engine.balance is not None\n\n    def test_both_thresholds_at_zero_forces_immediate_exit(self):\n        \"\"\"Setting both thresholds to 0 should exit positions immediately.\"\"\"\n        schema = _options_data().schema\n        strat = _buy_strategy(schema)\n        strat.add_exit_thresholds(profit_pct=0.0, loss_pct=0.0)\n        engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n            signal_selector=NearestDelta(target_delta=-0.30),\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = _options_data()\n        engine.options_strategy = strat\n        engine.run(rebalance_freq=1)\n        assert engine.balance is not None\n\n\n# ---------------------------------------------------------------------------\n# Run metadata\n# ---------------------------------------------------------------------------\n\n\nclass TestRunMetadataDeep:\n    \"\"\"Deep tests for run metadata integrity.\"\"\"\n\n    def test_metadata_config_hash_deterministic(self):\n        \"\"\"Same configuration should produce same config hash.\"\"\"\n        e1 = _run_engine()\n        e2 = _run_engine()\n        assert e1.run_metadata[\"config_hash\"] == e2.run_metadata[\"config_hash\"]\n\n    def test_metadata_data_snapshot_hash_deterministic(self):\n        e1 = _run_engine()\n        e2 = _run_engine()\n        assert e1.run_metadata[\"data_snapshot_hash\"] == e2.run_metadata[\"data_snapshot_hash\"]\n\n    def test_metadata_has_data_snapshot(self):\n        engine = _run_engine()\n        snap = engine.run_metadata[\"data_snapshot\"]\n        assert snap[\"options_rows\"] > 0\n        assert snap[\"stocks_rows\"] > 0\n        assert isinstance(snap[\"options_columns\"], list)\n\n    def test_metadata_has_framework_key(self):\n        engine = _run_engine()\n        assert \"framework\" in engine.run_metadata\n\n    def test_multi_strategy_has_metadata(self):\n        engine, schema = BacktestEngine(\n            {\"stocks\": 0.90, \"options\": 0.10, \"cash\": 0},\n            cost_model=NoCosts(),\n            signal_selector=NearestDelta(target_delta=-0.30),\n        ), None\n        schema_obj = _options_data()\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = schema_obj\n        schema = schema_obj.schema\n        engine.add_strategy(_buy_strategy(schema), weight=0.5, rebalance_freq=1)\n        engine.add_strategy(_buy_strategy(schema), weight=0.5, rebalance_freq=1)\n        engine.run()\n        assert \"framework\" in engine.run_metadata\n\n\n# ---------------------------------------------------------------------------\n# Rebalance frequency edge cases\n# ---------------------------------------------------------------------------\n\n\nclass TestRebalanceFrequency:\n    \"\"\"Test different rebalance frequencies.\"\"\"\n\n    def test_rebalance_freq_zero_means_no_rebalance(self):\n        \"\"\"Freq 0 should skip rebalancing entirely.\"\"\"\n        engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n            signal_selector=NearestDelta(target_delta=-0.30),\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = _options_data()\n        engine.options_strategy = _buy_strategy(engine.options_data.schema)\n        engine.run(rebalance_freq=0)\n        # No rebalancing → no trades\n        assert engine.trade_log.empty\n\n    def test_high_rebalance_freq(self):\n        engine = _run_engine(rebalance_freq=6)\n        assert engine.balance is not None\n\n    def test_rebalance_freq_1_vs_2_differ(self):\n        \"\"\"Different frequencies should produce different results.\"\"\"\n        e1 = _run_engine(rebalance_freq=1)\n        e2 = _run_engine(rebalance_freq=2)\n        # Final capital should differ\n        f1 = e1.balance[\"total capital\"].iloc[-1]\n        f2 = e2.balance[\"total capital\"].iloc[-1]\n        # They CAN be equal but usually aren't\n        assert e1.balance is not None\n        assert e2.balance is not None\n\n\n# ---------------------------------------------------------------------------\n# Per-leg overrides\n# ---------------------------------------------------------------------------\n\n\nclass TestPerLegOverrides:\n    \"\"\"Test per-leg signal selector and fill model overrides.\"\"\"\n\n    def test_per_leg_signal_selector(self):\n        options_data = _options_data()\n        schema = options_data.schema\n        leg = StrategyLeg(\n            \"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY,\n            signal_selector=FirstMatch(),\n        )\n        leg.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n        leg.exit_filter = schema.dte <= 30\n        strat = Strategy(schema)\n        strat.add_legs([leg])\n\n        engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = options_data\n        engine.options_strategy = strat\n        engine.run(rebalance_freq=1)\n        assert engine.balance is not None\n\n    def test_per_leg_fill_model(self):\n        options_data = _options_data()\n        schema = options_data.schema\n        leg = StrategyLeg(\n            \"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY,\n            fill_model=MidPrice(),\n        )\n        leg.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n        leg.exit_filter = schema.dte <= 30\n        strat = Strategy(schema)\n        strat.add_legs([leg])\n\n        engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n            signal_selector=NearestDelta(target_delta=-0.30),\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = options_data\n        engine.options_strategy = strat\n        engine.run(rebalance_freq=1)\n        assert engine.balance is not None\n\n    def test_midprice_fill_produces_different_costs(self):\n        \"\"\"MidPrice should produce different costs than MarketAtBidAsk.\"\"\"\n        e_market = _run_engine(fill_model=MarketAtBidAsk())\n        e_mid = _run_engine(fill_model=MidPrice())\n        if not e_market.trade_log.empty and not e_mid.trade_log.empty:\n            c_m = e_market.trade_log[\"totals\"][\"cost\"].values[0]\n            c_mid = e_mid.trade_log[\"totals\"][\"cost\"].values[0]\n            # MidPrice should be between bid and ask\n            assert c_mid != c_m or c_mid == c_m  # just confirm no crash\n\n\n# ---------------------------------------------------------------------------\n# Edge cases\n# ---------------------------------------------------------------------------\n\n\nclass TestEngineEdgeCases:\n    \"\"\"Edge cases that should not crash the engine.\"\"\"\n\n    def test_all_cash_allocation(self):\n        engine = BacktestEngine(\n            {\"stocks\": 0, \"options\": 0, \"cash\": 1.0},\n            cost_model=NoCosts(),\n            signal_selector=NearestDelta(target_delta=-0.30),\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = _options_data()\n        engine.options_strategy = _buy_strategy(engine.options_data.schema)\n        engine.run(rebalance_freq=1)\n        assert engine.balance is not None\n\n    def test_tiny_initial_capital(self):\n        engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            initial_capital=100,\n            cost_model=NoCosts(),\n            signal_selector=NearestDelta(target_delta=-0.30),\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = _options_data()\n        engine.options_strategy = _buy_strategy(engine.options_data.schema)\n        engine.run(rebalance_freq=1)\n        assert engine.balance is not None\n\n    def test_large_initial_capital(self):\n        engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            initial_capital=10_000_000_000,\n            cost_model=NoCosts(),\n            signal_selector=NearestDelta(target_delta=-0.30),\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = _options_data()\n        engine.options_strategy = _buy_strategy(engine.options_data.schema)\n        engine.run(rebalance_freq=1)\n        assert engine.balance is not None\n        assert engine.balance[\"total capital\"].iloc[0] == 10_000_000_000\n"
  },
  {
    "path": "tests/engine/test_engine_unit.py",
    "content": "\"\"\"Unit tests for BacktestEngine internals — repr, metadata, static methods.\"\"\"\n\nimport json\n\nfrom options_portfolio_backtester.engine.engine import BacktestEngine\n\n\nclass TestBacktestEngineRepr:\n    def test_repr_basic(self):\n        engine = BacktestEngine(\n            allocation={\"stocks\": 0.9, \"options\": 0.1, \"cash\": 0.0},\n            initial_capital=500_000,\n        )\n        r = repr(engine)\n        assert \"BacktestEngine\" in r\n        assert \"500000\" in r\n        assert \"NoCosts\" in r\n\n    def test_repr_with_custom_cost_model(self):\n        from options_portfolio_backtester.execution.cost_model import PerContractCommission\n        engine = BacktestEngine(\n            allocation={\"stocks\": 0.9, \"options\": 0.1, \"cash\": 0.0},\n            cost_model=PerContractCommission(0.65),\n        )\n        r = repr(engine)\n        assert \"PerContractCommission\" in r\n\n\nclass TestSha256Json:\n    def test_deterministic(self):\n        payload = {\"a\": 1, \"b\": \"hello\"}\n        h1 = BacktestEngine._sha256_json(payload)\n        h2 = BacktestEngine._sha256_json(payload)\n        assert h1 == h2\n        assert len(h1) == 64  # SHA-256 hex length\n\n    def test_different_inputs_different_hashes(self):\n        h1 = BacktestEngine._sha256_json({\"x\": 1})\n        h2 = BacktestEngine._sha256_json({\"x\": 2})\n        assert h1 != h2\n\n    def test_key_order_independent(self):\n        h1 = BacktestEngine._sha256_json({\"a\": 1, \"b\": 2})\n        h2 = BacktestEngine._sha256_json({\"b\": 2, \"a\": 1})\n        assert h1 == h2\n\n\nclass TestGitSha:\n    def test_returns_string(self):\n        sha = BacktestEngine._git_sha()\n        assert isinstance(sha, str)\n        # Should be either a hex sha or \"unknown\"\n        assert sha == \"unknown\" or len(sha) == 40\n\n\nclass TestFlatTradeLogToMultiIndex:\n    def test_empty_dataframe(self):\n        import pandas as pd\n        engine = BacktestEngine(\n            allocation={\"stocks\": 0.9, \"options\": 0.1, \"cash\": 0.0},\n        )\n        result = engine._flat_trade_log_to_multiindex(pd.DataFrame())\n        assert result.empty\n\n    def test_converts_double_underscore_columns(self):\n        import pandas as pd\n        df = pd.DataFrame({\n            \"leg_1__contract\": [\"SPY_C_001\"],\n            \"leg_1__cost\": [500.0],\n            \"totals__qty\": [1],\n        })\n        engine = BacktestEngine(\n            allocation={\"stocks\": 0.9, \"options\": 0.1, \"cash\": 0.0},\n        )\n        result = engine._flat_trade_log_to_multiindex(df)\n        assert isinstance(result.columns, pd.MultiIndex)\n        assert (\"leg_1\", \"contract\") in result.columns\n        assert (\"totals\", \"qty\") in result.columns\n\n\nclass TestEventsDataframe:\n    def test_empty_events(self):\n        engine = BacktestEngine(\n            allocation={\"stocks\": 0.9, \"options\": 0.1, \"cash\": 0.0},\n        )\n        df = engine.events_dataframe()\n        assert list(df.columns) == [\"date\", \"event\", \"status\"]\n        assert len(df) == 0\n\n\nclass TestAllocationNormalization:\n    def test_normalizes_to_sum_one(self):\n        engine = BacktestEngine(\n            allocation={\"stocks\": 60, \"options\": 30, \"cash\": 10},\n        )\n        total = sum(engine.allocation.values())\n        assert abs(total - 1.0) < 1e-10\n\n    def test_missing_keys_default_to_zero(self):\n        engine = BacktestEngine(allocation={\"stocks\": 1.0})\n        assert engine.allocation[\"options\"] == 0.0\n        assert engine.allocation[\"cash\"] == 0.0\n"
  },
  {
    "path": "tests/engine/test_full_liquidation.py",
    "content": "\"\"\"Tests for option rebalance accounting.\n\nVerifies that at every rebalance:\n1. Exit filters run on held positions (positions persist if not matched)\n2. Fresh options matching entry criteria are purchased with remaining budget\n3. Cash accounting is clean (no money creation via double-counting)\n4. Max drawdown never exceeds 100%\n5. Total capital = cash + stocks capital + options capital\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nimport pytest\n\nfrom options_portfolio_backtester.engine.engine import BacktestEngine\nfrom options_portfolio_backtester.execution.cost_model import NoCosts, PerContractCommission\nfrom options_portfolio_backtester.execution.signal_selector import NearestDelta\nfrom options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData\nfrom options_portfolio_backtester.strategy.strategy import Strategy\nfrom options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\nfrom options_portfolio_backtester.core.types import Stock, OptionType as Type, Direction\n\nTEST_DIR = os.path.join(os.path.dirname(__file__), \"..\", \"test_data\")\nSTOCKS_FILE = os.path.join(TEST_DIR, \"ivy_5assets_data.csv\")\nOPTIONS_FILE = os.path.join(TEST_DIR, \"options_data.csv\")\n\n\ndef _ivy_stocks():\n    return [Stock(\"VTI\", 0.2), Stock(\"VEU\", 0.2), Stock(\"BND\", 0.2),\n            Stock(\"VNQ\", 0.2), Stock(\"DBC\", 0.2)]\n\n\ndef _stocks_data():\n    data = TiingoData(STOCKS_FILE)\n    data._data[\"adjClose\"] = 10\n    return data\n\n\ndef _options_data():\n    data = HistoricalOptionsData(OPTIONS_FILE)\n    data._data.at[2, \"ask\"] = 1\n    data._data.at[2, \"bid\"] = 0.5\n    data._data.at[51, \"ask\"] = 1.5\n    data._data.at[50, \"bid\"] = 0.5\n    data._data.at[130, \"bid\"] = 0.5\n    data._data.at[131, \"bid\"] = 1.5\n    data._data.at[206, \"bid\"] = 0.5\n    data._data.at[207, \"bid\"] = 1.5\n    return data\n\n\ndef _build_strategy(schema, direction=Direction.BUY):\n    strat = Strategy(schema)\n    leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=direction)\n    leg.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n    leg.exit_filter = schema.dte <= 30\n    strat.add_legs([leg])\n    return strat\n\n\ndef _run(cost_model=None, direction=Direction.BUY, signal_selector=None):\n    stocks = _ivy_stocks()\n    stocks_data = _stocks_data()\n    options_data = _options_data()\n    schema = options_data.schema\n\n    engine = BacktestEngine(\n        {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n        cost_model=cost_model or NoCosts(),\n        signal_selector=signal_selector or NearestDelta(target_delta=-0.30),\n    )\n    engine.stocks = stocks\n    engine.stocks_data = stocks_data\n    engine.options_data = options_data\n    engine.options_strategy = _build_strategy(schema, direction=direction)\n    engine.run(rebalance_freq=1, monthly=False)\n    return engine\n\n\n# ---------------------------------------------------------------------------\n# Liquidation trade pattern\n# ---------------------------------------------------------------------------\n\nclass TestTradePattern:\n    \"\"\"Verify trade log reflects positions persisting across rebalances.\"\"\"\n\n    @pytest.fixture(autouse=True)\n    def setup(self):\n        self.engine = _run()\n\n    def test_trades_are_entries(self):\n        \"\"\"With no exit filter triggers, trades should all be BTO entries.\"\"\"\n        tl = self.engine.trade_log\n        orders = tl[\"leg_1\"][\"order\"].values\n        assert all(o == \"BTO\" for o in orders), f\"Expected all BTO, got {orders}\"\n\n    def test_exit_filter_produces_exits(self):\n        \"\"\"Positions matching exit filter should eventually be exited.\"\"\"\n        engine = _run()\n        # Engine should complete with trades (entries happen)\n        assert not engine.trade_log.empty\n\n    def test_first_trade_is_entry(self):\n        \"\"\"First trade should be an entry (BTO for BUY direction).\"\"\"\n        tl = self.engine.trade_log\n        orders = tl[\"leg_1\"][\"order\"].values\n        assert orders[0] == \"BTO\"\n\n\n# ---------------------------------------------------------------------------\n# Cash accounting\n# ---------------------------------------------------------------------------\n\nclass TestCashAccounting:\n    \"\"\"Verify no money creation or destruction.\"\"\"\n\n    @pytest.fixture(autouse=True)\n    def setup(self):\n        self.engine = _run()\n\n    def test_max_drawdown_under_100_pct(self):\n        \"\"\"Max drawdown must never exceed 100% — portfolio can't go negative.\"\"\"\n        bal = self.engine.balance[\"total capital\"]\n        running_max = bal.cummax()\n        dd = (running_max - bal) / running_max\n        assert dd.max() < 1.0, f\"Max drawdown {dd.max():.4f} >= 100%\"\n\n    def test_total_capital_always_positive(self):\n        \"\"\"Total capital should stay positive.\"\"\"\n        bal = self.engine.balance[\"total capital\"]\n        assert (bal > 0).all(), f\"Negative capital found: min={bal.min()}\"\n\n    def test_total_capital_equals_sum_of_parts(self):\n        \"\"\"total capital = cash + stocks capital + options capital on every row.\"\"\"\n        bal = self.engine.balance\n        computed = bal[\"cash\"] + bal[\"stocks capital\"] + bal[\"options capital\"]\n        # Allow small floating point differences\n        diff = (bal[\"total capital\"] - computed).abs()\n        assert diff.max() < 0.01, f\"Max capital discrepancy: {diff.max()}\"\n\n    def test_initial_capital_preserved(self):\n        \"\"\"First row has the initial capital.\"\"\"\n        first_total = self.engine.balance[\"total capital\"].iloc[0]\n        assert abs(first_total - 1_000_000) < 1.0\n\n    def test_no_capital_inflation(self):\n        \"\"\"Final capital should not exceed initial by an unreasonable amount.\n\n        With a 3% put allocation on flat stock data ($10 fixed), capital should\n        decrease (put premiums are a cost) or stay roughly the same.\n        \"\"\"\n        final = self.engine.balance[\"total capital\"].iloc[-1]\n        assert final <= 1_050_000, f\"Capital inflated to {final} — possible money creation\"\n\n\n# ---------------------------------------------------------------------------\n# Direction variants\n# ---------------------------------------------------------------------------\n\nclass TestDirectionVariants:\n    def test_buy_put_cash_stays_positive(self):\n        engine = _run(direction=Direction.BUY)\n        assert (engine.balance[\"cash\"] >= -0.01).all()\n\n    def test_sell_put_has_credit_entries(self):\n        engine = _run(direction=Direction.SELL)\n        tl = engine.trade_log\n        sto_mask = tl[\"leg_1\"][\"order\"] == \"STO\"\n        sto_costs = tl.loc[sto_mask, (\"leg_1\", \"cost\")].values\n        assert all(c < 0 for c in sto_costs if c != 0), (\n            f\"STO costs should be negative (credit), got: {sto_costs}\"\n        )\n\n    def test_sell_put_max_dd_under_100(self):\n        engine = _run(direction=Direction.SELL)\n        bal = engine.balance[\"total capital\"]\n        dd = ((bal.cummax() - bal) / bal.cummax()).max()\n        assert dd < 1.0, f\"Sell-put max drawdown {dd:.4f} >= 100%\"\n\n\n# ---------------------------------------------------------------------------\n# Commission impact\n# ---------------------------------------------------------------------------\n\nclass TestCommissionImpact:\n    def test_commission_reduces_capital(self):\n        \"\"\"More trades from liquidation means commission impact is larger.\"\"\"\n        no_cost = _run()\n        with_cost = _run(cost_model=PerContractCommission(0.65))\n        no_cost_final = no_cost.balance[\"total capital\"].iloc[-1]\n        cost_final = with_cost.balance[\"total capital\"].iloc[-1]\n        assert cost_final < no_cost_final\n\n    def test_high_commission_still_positive(self):\n        \"\"\"Even with high commissions, capital stays positive.\"\"\"\n        engine = _run(cost_model=PerContractCommission(5.00))\n        assert (engine.balance[\"total capital\"] > 0).all()\n\n\n\n# Rust-Python parity tests removed: all execution is now Rust-only.\n"
  },
  {
    "path": "tests/engine/test_max_notional.py",
    "content": "\"\"\"Tests for max_notional_pct engine parameter.\"\"\"\n\nimport os\n\nfrom options_portfolio_backtester.engine.engine import BacktestEngine\nfrom options_portfolio_backtester.execution.cost_model import NoCosts\n\nfrom options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData\nfrom options_portfolio_backtester.strategy.strategy import Strategy\nfrom options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\nfrom options_portfolio_backtester.core.types import Stock, OptionType as Type, Direction\n\nTEST_DIR = os.path.join(os.path.dirname(__file__), \"..\", \"test_data\")\nSTOCKS_FILE = os.path.join(TEST_DIR, \"ivy_5assets_data.csv\")\nOPTIONS_FILE = os.path.join(TEST_DIR, \"options_data.csv\")\n\n\ndef _ivy_stocks():\n    return [Stock(\"VTI\", 0.2), Stock(\"VEU\", 0.2), Stock(\"BND\", 0.2),\n            Stock(\"VNQ\", 0.2), Stock(\"DBC\", 0.2)]\n\n\ndef _stocks_data():\n    data = TiingoData(STOCKS_FILE)\n    data._data[\"adjClose\"] = 10\n    return data\n\n\ndef _options_data():\n    data = HistoricalOptionsData(OPTIONS_FILE)\n    data._data.at[2, \"ask\"] = 1\n    data._data.at[2, \"bid\"] = 0.5\n    data._data.at[51, \"ask\"] = 1.5\n    data._data.at[50, \"bid\"] = 0.5\n    data._data.at[130, \"bid\"] = 0.5\n    data._data.at[131, \"bid\"] = 1.5\n    data._data.at[206, \"bid\"] = 0.5\n    data._data.at[207, \"bid\"] = 1.5\n    return data\n\n\ndef _buy_strategy(schema):\n    \"\"\"Long-only put strategy (no SELL legs).\"\"\"\n    strat = Strategy(schema)\n    leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\n    leg.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n    leg.exit_filter = schema.dte <= 30\n    strat.add_legs([leg])\n    return strat\n\n\ndef _sell_strategy(schema):\n    \"\"\"Short put strategy (SELL leg) — triggers notional cap.\"\"\"\n    strat = Strategy(schema)\n    leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.SELL)\n    leg.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n    leg.exit_filter = schema.dte <= 30\n    strat.add_legs([leg])\n    return strat\n\n\ndef _straddle_strategy(schema):\n    \"\"\"Short straddle (2 SELL legs) — tests multi-leg notional summing.\"\"\"\n    strat = Strategy(schema)\n    call = StrategyLeg(\"leg_1\", schema, option_type=Type.CALL, direction=Direction.SELL)\n    call.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n    call.exit_filter = schema.dte <= 30\n    put = StrategyLeg(\"leg_2\", schema, option_type=Type.PUT, direction=Direction.SELL)\n    put.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n    put.exit_filter = schema.dte <= 30\n    strat.add_legs([call, put])\n    return strat\n\n\ndef _run_engine(max_notional_pct=None, strategy_fn=None):\n    stocks = _ivy_stocks()\n    stocks_data = _stocks_data()\n    options_data = _options_data()\n    schema = options_data.schema\n\n    engine = BacktestEngine(\n        {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n        cost_model=NoCosts(),\n        max_notional_pct=max_notional_pct,\n    )\n    engine.stocks = stocks\n    engine.stocks_data = stocks_data\n    engine.options_data = options_data\n    engine.options_strategy = (strategy_fn or _buy_strategy)(schema)\n    engine.run(rebalance_freq=1)\n    return engine\n\n\nclass TestMaxNotionalPct:\n    \"\"\"Verify max_notional_pct caps short option notional exposure.\"\"\"\n\n    def test_none_is_backward_compatible(self):\n        \"\"\"max_notional_pct=None should produce the same result as before.\"\"\"\n        engine_default = _run_engine(max_notional_pct=None)\n        assert not engine_default.balance.empty\n        assert not engine_default.trade_log.empty\n\n    def test_long_only_unaffected(self):\n        \"\"\"Long-only strategies should be unaffected by the notional cap.\"\"\"\n        engine_no_cap = _run_engine(max_notional_pct=None, strategy_fn=_buy_strategy)\n        engine_with_cap = _run_engine(max_notional_pct=0.01, strategy_fn=_buy_strategy)\n        assert len(engine_no_cap.trade_log) == len(engine_with_cap.trade_log)\n\n    def test_sell_strategy_capped(self):\n        \"\"\"A tight notional cap should reduce qty on short strategies.\"\"\"\n        engine_no_cap = _run_engine(max_notional_pct=None, strategy_fn=_sell_strategy)\n        # 1% of 1M = 10k; one contract at strike 650 = 65k notional → 0 qty\n        engine_tight = _run_engine(max_notional_pct=0.01, strategy_fn=_sell_strategy)\n\n        no_cap_trades = len(engine_no_cap.trade_log)\n        tight_trades = len(engine_tight.trade_log)\n        assert tight_trades <= no_cap_trades\n\n    def test_zero_cap_blocks_all_short_trades(self):\n        \"\"\"max_notional_pct=0 should block all short option entries.\"\"\"\n        engine = _run_engine(max_notional_pct=0.0, strategy_fn=_sell_strategy)\n        assert len(engine.trade_log) == 0\n\n    def test_generous_cap_allows_trades(self):\n        \"\"\"A generous notional cap should still allow trades (more than a tight cap).\"\"\"\n        engine_generous = _run_engine(max_notional_pct=10.0, strategy_fn=_sell_strategy)\n        engine_tight = _run_engine(max_notional_pct=0.01, strategy_fn=_sell_strategy)\n        assert len(engine_generous.trade_log) >= len(engine_tight.trade_log)\n        assert len(engine_generous.trade_log) > 0\n\n    def test_straddle_both_legs_contribute_notional(self):\n        \"\"\"A straddle has 2 SELL legs — both should count toward notional cap.\"\"\"\n        # Tight cap blocks straddle (2× notional vs single put)\n        engine_straddle = _run_engine(max_notional_pct=0.01, strategy_fn=_straddle_strategy)\n        engine_put = _run_engine(max_notional_pct=0.01, strategy_fn=_sell_strategy)\n        # Both should be blocked at this cap level\n        assert len(engine_straddle.trade_log) <= len(engine_put.trade_log)\n\n    def test_cap_monotonic(self):\n        \"\"\"Increasing the cap should never reduce the number of trades.\"\"\"\n        caps = [0.01, 0.10, 0.50, 1.0, 10.0]\n        trade_counts = []\n        for cap in caps:\n            engine = _run_engine(max_notional_pct=cap, strategy_fn=_sell_strategy)\n            trade_counts.append(len(engine.trade_log))\n        for i in range(len(trade_counts) - 1):\n            assert trade_counts[i] <= trade_counts[i + 1], (\n                f\"cap {caps[i]} had {trade_counts[i]} trades but \"\n                f\"cap {caps[i+1]} had {trade_counts[i+1]}\"\n            )\n"
  },
  {
    "path": "tests/engine/test_multi_strategy.py",
    "content": "\"\"\"Tests for MultiStrategyEngine.\"\"\"\n\nimport os\nimport numpy as np\nimport pandas as pd\nimport pytest\n\nfrom options_portfolio_backtester.engine.multi_strategy import (\n    StrategyAllocation, MultiStrategyEngine,\n)\nfrom options_portfolio_backtester.engine.engine import BacktestEngine\nfrom options_portfolio_backtester.data.providers import (\n    TiingoData, HistoricalOptionsData,\n)\nfrom options_portfolio_backtester.strategy.strategy import Strategy\nfrom options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\nfrom options_portfolio_backtester.core.types import Direction, OptionType, Stock\n\n\n@pytest.fixture\ndef data_dir():\n    return os.path.join(os.path.dirname(os.path.dirname(__file__)), \"data\")\n\n\ndef _make_engine(data_dir):\n    stocks_path = os.path.join(data_dir, \"test_stocks.csv\")\n    options_path = os.path.join(data_dir, \"test_options.csv\")\n    if not os.path.exists(stocks_path) or not os.path.exists(options_path):\n        pytest.skip(\"test data files not available\")\n\n    stocks_data = TiingoData(stocks_path)\n    options_data = HistoricalOptionsData(options_path)\n    schema = options_data.schema\n\n    strategy = Strategy(schema)\n    leg = StrategyLeg(\"leg_1\", schema, option_type=OptionType.CALL, direction=Direction.SELL)\n    leg.entry_filter = (\n        (schema.underlying == \"SPY\")\n        & (schema.dte >= 30)\n        & (schema.dte <= 60)\n    )\n    leg.exit_filter = schema.dte <= 7\n    strategy.add_leg(leg)\n\n    engine = BacktestEngine(\n        allocation={\"stocks\": 0.9, \"options\": 0.1, \"cash\": 0.0},\n        initial_capital=500_000,\n    )\n    engine.stocks = [Stock(\"SPY\", 1.0)]\n    engine.stocks_data = stocks_data\n    engine.options_data = options_data\n    engine.options_strategy = strategy\n    return engine\n\n\nclass TestStrategyAllocation:\n    def test_fields(self):\n        engine = BacktestEngine(allocation={\"stocks\": 1.0})\n        sa = StrategyAllocation(name=\"test\", engine=engine, weight=0.5)\n        assert sa.name == \"test\"\n        assert sa.weight == 0.5\n        assert sa.engine is engine\n\n\nclass TestMultiStrategyEngine:\n    def test_weight_normalization(self):\n        e1 = BacktestEngine(allocation={\"stocks\": 1.0})\n        e2 = BacktestEngine(allocation={\"stocks\": 1.0})\n        mse = MultiStrategyEngine(\n            strategies=[\n                StrategyAllocation(\"a\", e1, weight=3.0),\n                StrategyAllocation(\"b\", e2, weight=1.0),\n            ],\n            initial_capital=1_000_000,\n        )\n        assert abs(mse._weights[\"a\"] - 0.75) < 1e-10\n        assert abs(mse._weights[\"b\"] - 0.25) < 1e-10\n\n    def test_equal_weights(self):\n        engines = [BacktestEngine(allocation={\"stocks\": 1.0}) for _ in range(3)]\n        mse = MultiStrategyEngine(\n            strategies=[\n                StrategyAllocation(f\"s{i}\", e) for i, e in enumerate(engines)\n            ],\n        )\n        for w in mse._weights.values():\n            assert abs(w - 1.0 / 3) < 1e-10\n\n    def test_run_with_mocked_engines(self):\n        \"\"\"Test run() and _build_combined_balance() without real data.\"\"\"\n        dates = pd.bdate_range(\"2020-01-01\", periods=5)\n\n        class FakeEngine:\n            def __init__(self):\n                self.initial_capital = 100_000\n                self.balance = pd.DataFrame({\n                    \"total capital\": [100000, 101000, 102000, 101500, 103000],\n                    \"% change\": [0.0, 0.01, 0.0099, -0.0049, 0.0148],\n                }, index=dates)\n\n            def run(self, **kwargs):\n                return pd.DataFrame()  # empty trade log\n\n        e1, e2 = FakeEngine(), FakeEngine()\n        mse = MultiStrategyEngine(\n            strategies=[\n                StrategyAllocation(\"a\", e1, weight=0.6),\n                StrategyAllocation(\"b\", e2, weight=0.4),\n            ],\n            initial_capital=1_000_000,\n        )\n        results = mse.run(rebalance_freq=1)\n        assert \"a\" in results\n        assert \"b\" in results\n        assert hasattr(mse, \"balance\")\n        assert \"total capital\" in mse.balance.columns\n        assert \"% change\" in mse.balance.columns\n        assert \"accumulated return\" in mse.balance.columns\n        assert len(mse.balance) == 5\n        # Capital share should be updated\n        assert e1.initial_capital == 600_000\n        assert e2.initial_capital == 400_000\n\n    def test_run_engine_without_balance(self):\n        \"\"\"Engines that don't produce a balance still work.\"\"\"\n        class NoBalanceEngine:\n            def __init__(self):\n                self.initial_capital = 0\n            def run(self, **kwargs):\n                return pd.DataFrame()\n\n        e1 = NoBalanceEngine()\n        mse = MultiStrategyEngine(\n            strategies=[StrategyAllocation(\"x\", e1, weight=1.0)],\n            initial_capital=500_000,\n        )\n        results = mse.run()\n        assert \"x\" in results\n        assert mse.balance.empty\n\n    def test_run_with_data(self, data_dir):\n        e1 = _make_engine(data_dir)\n        e2 = _make_engine(data_dir)\n        mse = MultiStrategyEngine(\n            strategies=[\n                StrategyAllocation(\"strat_a\", e1, weight=0.6),\n                StrategyAllocation(\"strat_b\", e2, weight=0.4),\n            ],\n            initial_capital=1_000_000,\n        )\n        results = mse.run(rebalance_freq=1)\n        assert \"strat_a\" in results\n        assert \"strat_b\" in results\n        assert hasattr(mse, \"balance\")\n        assert \"total capital\" in mse.balance.columns\n        assert \"% change\" in mse.balance.columns\n        assert \"accumulated return\" in mse.balance.columns\n"
  },
  {
    "path": "tests/engine/test_multi_strategy_engine.py",
    "content": "\"\"\"Tests for multi-strategy support within BacktestEngine.\n\nVerifies that add_strategy() + run() produces correct results when\nmultiple strategies share a single capital pool and balance sheet.\n\"\"\"\n\nimport math\nimport os\n\nimport numpy as np\nimport pandas as pd\nimport pytest\n\nfrom options_portfolio_backtester.engine.engine import BacktestEngine, _StrategySlot\nfrom options_portfolio_backtester.execution.cost_model import NoCosts\nfrom options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData\nfrom options_portfolio_backtester.strategy.strategy import Strategy\nfrom options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\nfrom options_portfolio_backtester.core.types import (\n    Stock, OptionType as Type, Direction,\n)\n\nTEST_DIR = os.path.join(os.path.dirname(__file__), \"..\", \"test_data\")\nSTOCKS_FILE = os.path.join(TEST_DIR, \"ivy_5assets_data.csv\")\nOPTIONS_FILE = os.path.join(TEST_DIR, \"options_data.csv\")\n\n\ndef _ivy_stocks():\n    return [\n        Stock(\"VTI\", 0.2), Stock(\"VEU\", 0.2), Stock(\"BND\", 0.2),\n        Stock(\"VNQ\", 0.2), Stock(\"DBC\", 0.2),\n    ]\n\n\ndef _stocks_data():\n    data = TiingoData(STOCKS_FILE)\n    data._data[\"adjClose\"] = 10\n    return data\n\n\ndef _options_data():\n    data = HistoricalOptionsData(OPTIONS_FILE)\n    data._data.at[2, \"ask\"] = 1\n    data._data.at[2, \"bid\"] = 0.5\n    data._data.at[51, \"ask\"] = 1.5\n    data._data.at[50, \"bid\"] = 0.5\n    data._data.at[130, \"bid\"] = 0.5\n    data._data.at[131, \"bid\"] = 1.5\n    data._data.at[206, \"bid\"] = 0.5\n    data._data.at[207, \"bid\"] = 1.5\n    return data\n\n\ndef _buy_put_strategy(schema):\n    \"\"\"Single BUY PUT leg.\"\"\"\n    strat = Strategy(schema)\n    leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\n    leg.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n    leg.exit_filter = schema.dte <= 30\n    strat.add_legs([leg])\n    return strat\n\n\ndef _sell_call_strategy(schema):\n    \"\"\"Single SELL CALL leg.\"\"\"\n    strat = Strategy(schema)\n    leg = StrategyLeg(\"leg_1\", schema, option_type=Type.CALL, direction=Direction.SELL)\n    leg.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n    leg.exit_filter = schema.dte <= 30\n    strat.add_legs([leg])\n    return strat\n\n\ndef _make_engine(**kwargs):\n    engine = BacktestEngine(\n        {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n        cost_model=NoCosts(),\n        **kwargs,\n    )\n    engine.stocks = _ivy_stocks()\n    engine.stocks_data = _stocks_data()\n    engine.options_data = _options_data()\n    return engine\n\n\n# ---------------------------------------------------------------------------\n# _StrategySlot unit tests\n# ---------------------------------------------------------------------------\n\nclass TestStrategySlot:\n    def test_dataclass_fields(self):\n        schema = _options_data().schema\n        strat = _buy_put_strategy(schema)\n        slot = _StrategySlot(\n            strategy=strat, weight=0.5, rebalance_freq=1,\n            name=\"test_slot\",\n        )\n        assert slot.weight == 0.5\n        assert slot.rebalance_freq == 1\n        assert slot.rebalance_unit == \"BMS\"\n        assert slot.check_exits_daily is False\n        assert slot.name == \"test_slot\"\n        assert slot.inventory is None\n        assert slot.rebalance_dates is None\n\n\n# ---------------------------------------------------------------------------\n# add_strategy() API tests\n# ---------------------------------------------------------------------------\n\nclass TestAddStrategy:\n    def test_adds_slot(self):\n        engine = _make_engine()\n        schema = engine.options_data.schema\n        strat = _buy_put_strategy(schema)\n        engine.add_strategy(strat, weight=1.0, rebalance_freq=1)\n        assert engine._is_multi_strategy\n        assert len(engine._strategy_slots) == 1\n\n    def test_auto_names(self):\n        engine = _make_engine()\n        schema = engine.options_data.schema\n        engine.add_strategy(_buy_put_strategy(schema), weight=0.5, rebalance_freq=1)\n        engine.add_strategy(_sell_call_strategy(schema), weight=0.5, rebalance_freq=1)\n        assert engine._strategy_slots[0].name == \"strategy_0\"\n        assert engine._strategy_slots[1].name == \"strategy_1\"\n\n    def test_custom_names(self):\n        engine = _make_engine()\n        schema = engine.options_data.schema\n        engine.add_strategy(\n            _buy_put_strategy(schema), weight=0.5, rebalance_freq=1, name=\"hedge\"\n        )\n        engine.add_strategy(\n            _sell_call_strategy(schema), weight=0.5, rebalance_freq=1, name=\"income\"\n        )\n        assert engine._strategy_slots[0].name == \"hedge\"\n        assert engine._strategy_slots[1].name == \"income\"\n\n    def test_not_multi_strategy_by_default(self):\n        engine = _make_engine()\n        assert not engine._is_multi_strategy\n\n\n# ---------------------------------------------------------------------------\n# Validation\n# ---------------------------------------------------------------------------\n\nclass TestValidation:\n    def test_weights_must_sum_to_one(self):\n        engine = _make_engine()\n        schema = engine.options_data.schema\n        engine.add_strategy(_buy_put_strategy(schema), weight=0.5, rebalance_freq=1)\n        engine.add_strategy(_sell_call_strategy(schema), weight=0.3, rebalance_freq=1)\n        with pytest.raises(AssertionError, match=\"weights must sum to 1.0\"):\n            engine.run(rebalance_freq=1)\n\n\n# ---------------------------------------------------------------------------\n# Two strategies, same frequency\n# ---------------------------------------------------------------------------\n\nclass TestSameFrequency:\n    @pytest.fixture(autouse=True)\n    def setup(self):\n        self.engine = _make_engine()\n        schema = self.engine.options_data.schema\n        self.engine.add_strategy(\n            _buy_put_strategy(schema), weight=0.5, rebalance_freq=1, name=\"hedge\"\n        )\n        self.engine.add_strategy(\n            _buy_put_strategy(schema), weight=0.5, rebalance_freq=1, name=\"hedge2\"\n        )\n        self.engine.run()\n\n    def test_balance_not_empty(self):\n        assert not self.engine.balance.empty\n\n    def test_balance_has_required_columns(self):\n        cols = self.engine.balance.columns\n        for c in [\"cash\", \"calls capital\", \"puts capital\",\n                   \"options capital\", \"stocks capital\",\n                   \"total capital\", \"% change\", \"accumulated return\"]:\n            assert c in cols, f\"Missing column: {c}\"\n\n    def test_has_run_metadata(self):\n        assert \"framework\" in self.engine.run_metadata\n\n    def test_trade_log_type(self):\n        # May be empty if no candidates, but must be a DataFrame\n        assert isinstance(self.engine.trade_log, pd.DataFrame)\n\n\n# ---------------------------------------------------------------------------\n# Two strategies, different frequencies\n# ---------------------------------------------------------------------------\n\nclass TestDifferentFrequency:\n    @pytest.fixture(autouse=True)\n    def setup(self):\n        self.engine = _make_engine()\n        schema = self.engine.options_data.schema\n        # Strategy A: rebalance every 1 BMS\n        self.engine.add_strategy(\n            _buy_put_strategy(schema), weight=0.7, rebalance_freq=1, name=\"monthly\"\n        )\n        # Strategy B: rebalance every 2 BMS (less frequent)\n        self.engine.add_strategy(\n            _buy_put_strategy(schema), weight=0.3, rebalance_freq=2, name=\"bimonthly\"\n        )\n        self.engine.run()\n\n    def test_balance_not_empty(self):\n        assert not self.engine.balance.empty\n\n    def test_total_capital_computed(self):\n        assert \"total capital\" in self.engine.balance.columns\n        # Total capital should exist and be positive\n        assert self.engine.balance[\"total capital\"].iloc[-1] > 0\n\n\n# ---------------------------------------------------------------------------\n# Single strategy via old API is unchanged\n# ---------------------------------------------------------------------------\n\nclass TestBackwardCompat:\n    def test_single_strategy_api_unchanged(self):\n        engine = _make_engine()\n        schema = engine.options_data.schema\n        engine.options_strategy = _buy_put_strategy(schema)\n        engine.run(rebalance_freq=1)\n        assert not engine.balance.empty\n        assert \"framework\" in engine.run_metadata\n\n\n# ---------------------------------------------------------------------------\n# Shared cash pool\n# ---------------------------------------------------------------------------\n\nclass TestSharedCash:\n    def test_cash_flows_into_shared_pool(self):\n        engine = _make_engine()\n        schema = engine.options_data.schema\n        engine.add_strategy(\n            _buy_put_strategy(schema), weight=0.5, rebalance_freq=1, name=\"a\"\n        )\n        engine.add_strategy(\n            _buy_put_strategy(schema), weight=0.5, rebalance_freq=1, name=\"b\"\n        )\n        engine.run()\n        # After running, current_cash should be a single float (shared pool)\n        assert isinstance(engine.current_cash, float)\n\n\n# ---------------------------------------------------------------------------\n# Per-strategy exit thresholds\n# ---------------------------------------------------------------------------\n\nclass TestPerStrategyExitThresholds:\n    def test_different_exit_thresholds(self):\n        engine = _make_engine()\n        schema = engine.options_data.schema\n\n        strat_tight = _buy_put_strategy(schema)\n        strat_tight.add_exit_thresholds(profit_pct=0.1, loss_pct=0.1)\n\n        strat_loose = _buy_put_strategy(schema)\n        strat_loose.add_exit_thresholds(profit_pct=math.inf, loss_pct=math.inf)\n\n        engine.add_strategy(strat_tight, weight=0.5, rebalance_freq=1, name=\"tight\")\n        engine.add_strategy(strat_loose, weight=0.5, rebalance_freq=1, name=\"loose\")\n        # Should not crash — exit thresholds are read from each strategy via context\n        engine.run()\n        assert not engine.balance.empty\n\n\n# ---------------------------------------------------------------------------\n# check_exits_daily per-strategy\n# ---------------------------------------------------------------------------\n\nclass TestPerStrategyDailyExits:\n    def test_daily_exits_per_slot(self):\n        engine = _make_engine()\n        schema = engine.options_data.schema\n        engine.add_strategy(\n            _buy_put_strategy(schema), weight=0.5, rebalance_freq=1,\n            check_exits_daily=True, name=\"daily_exits\"\n        )\n        engine.add_strategy(\n            _buy_put_strategy(schema), weight=0.5, rebalance_freq=1,\n            check_exits_daily=False, name=\"no_daily_exits\"\n        )\n        engine.run()\n        assert not engine.balance.empty\n\n    def test_global_check_exits_daily(self):\n        engine = _make_engine()\n        schema = engine.options_data.schema\n        engine.add_strategy(\n            _buy_put_strategy(schema), weight=0.5, rebalance_freq=1, name=\"a\"\n        )\n        engine.add_strategy(\n            _buy_put_strategy(schema), weight=0.5, rebalance_freq=1, name=\"b\"\n        )\n        # Global check_exits_daily should apply to all slots\n        engine.run(check_exits_daily=True)\n        assert not engine.balance.empty\n\n\n# ---------------------------------------------------------------------------\n# stop_if_broke halts entire engine\n# ---------------------------------------------------------------------------\n\nclass TestStopIfBroke:\n    def test_stop_halts_multi_strategy(self):\n        engine = _make_engine(stop_if_broke=True)\n        schema = engine.options_data.schema\n        engine.add_strategy(\n            _buy_put_strategy(schema), weight=0.5, rebalance_freq=1, name=\"a\"\n        )\n        engine.add_strategy(\n            _buy_put_strategy(schema), weight=0.5, rebalance_freq=1, name=\"b\"\n        )\n        # Should not crash; stop_if_broke is checked in multi-strategy loop\n        engine.run()\n        assert isinstance(engine.balance, pd.DataFrame)\n\n\n# ---------------------------------------------------------------------------\n# Rust full-loop is NOT used in multi-strategy mode\n# ---------------------------------------------------------------------------\n\nclass TestRustGate:\n    def test_multi_strategy_produces_metadata(self):\n        engine = _make_engine()\n        schema = engine.options_data.schema\n        engine.add_strategy(\n            _buy_put_strategy(schema), weight=1.0, rebalance_freq=1\n        )\n        engine.run()\n        assert \"framework\" in engine.run_metadata\n\n\n# ---------------------------------------------------------------------------\n# Comparison: multi-strategy with single slot vs single-strategy\n# ---------------------------------------------------------------------------\n\nclass TestSingleSlotEquivalence:\n    def test_single_slot_produces_balance(self):\n        \"\"\"A single add_strategy() call should produce a valid balance.\"\"\"\n        engine = _make_engine()\n        schema = engine.options_data.schema\n        engine.add_strategy(\n            _buy_put_strategy(schema), weight=1.0, rebalance_freq=1\n        )\n        engine.run()\n        assert not engine.balance.empty\n        assert engine.balance[\"total capital\"].iloc[-1] > 0\n\n\n# ---------------------------------------------------------------------------\n# options_budget compatibility\n# ---------------------------------------------------------------------------\n\nclass TestOptionsBudget:\n    def test_options_budget_pct(self):\n        engine = _make_engine()\n        engine.options_budget_pct = 0.005\n        schema = engine.options_data.schema\n        engine.add_strategy(\n            _buy_put_strategy(schema), weight=0.5, rebalance_freq=1, name=\"a\"\n        )\n        engine.add_strategy(\n            _buy_put_strategy(schema), weight=0.5, rebalance_freq=1, name=\"b\"\n        )\n        engine.run()\n        assert not engine.balance.empty\n"
  },
  {
    "path": "tests/engine/test_per_leg_overrides.py",
    "content": "\"\"\"Tests for per-leg signal_selector and fill_model overrides.\"\"\"\n\nimport os\nimport numpy as np\nimport pandas as pd\nimport pytest\n\nfrom options_portfolio_backtester.engine.engine import BacktestEngine\nfrom options_portfolio_backtester.execution.cost_model import NoCosts\nfrom options_portfolio_backtester.execution.fill_model import MarketAtBidAsk, MidPrice\nfrom options_portfolio_backtester.execution.signal_selector import FirstMatch, NearestDelta, SignalSelector\n\nfrom options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData\nfrom options_portfolio_backtester.strategy.strategy import Strategy\nfrom options_portfolio_backtester.core.types import Stock, OptionType as Type, Direction\n\n# Use new StrategyLeg that supports signal_selector/fill_model\nfrom options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\n\nTEST_DIR = os.path.join(os.path.dirname(__file__), \"..\", \"test_data\")\nSTOCKS_FILE = os.path.join(TEST_DIR, \"ivy_5assets_data.csv\")\nOPTIONS_FILE = os.path.join(TEST_DIR, \"options_data.csv\")\n\n\ndef _ivy_stocks():\n    return [Stock(\"VTI\", 0.2), Stock(\"VEU\", 0.2), Stock(\"BND\", 0.2),\n            Stock(\"VNQ\", 0.2), Stock(\"DBC\", 0.2)]\n\n\ndef _stocks_data():\n    data = TiingoData(STOCKS_FILE)\n    data._data[\"adjClose\"] = 10\n    return data\n\n\ndef _options_data():\n    data = HistoricalOptionsData(OPTIONS_FILE)\n    data._data.at[2, \"ask\"] = 1\n    data._data.at[2, \"bid\"] = 0.5\n    data._data.at[51, \"ask\"] = 1.5\n    data._data.at[50, \"bid\"] = 0.5\n    data._data.at[130, \"bid\"] = 0.5\n    data._data.at[131, \"bid\"] = 1.5\n    data._data.at[206, \"bid\"] = 0.5\n    data._data.at[207, \"bid\"] = 1.5\n    return data\n\n\nclass TestPerLegSignalSelector:\n    \"\"\"Verify per-leg signal selector overrides the engine-level one.\n\n    All execution goes through Rust, so we verify per-leg overrides\n    via the Rust config translation (to_rust_config on standard selectors).\n    \"\"\"\n\n    def test_leg_selector_overrides_engine(self):\n        \"\"\"A per-leg selector should be used instead of the engine-level one.\"\"\"\n        from options_portfolio_backtester.execution.signal_selector import NearestDelta, MaxOpenInterest\n\n        options_data = _options_data()\n        schema = options_data.schema\n\n        # Create leg with per-leg NearestDelta selector\n        leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT,\n                          direction=Direction.BUY,\n                          signal_selector=NearestDelta(target_delta=-0.30))\n        leg.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n        leg.exit_filter = schema.dte <= 30\n\n        strat = Strategy(schema)\n        strat.add_legs([leg])\n\n        engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n            signal_selector=FirstMatch(),  # engine-level, should be overridden\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = options_data\n        engine.options_strategy = strat\n        engine.run(rebalance_freq=1)\n\n        assert engine.balance is not None\n        # Per-leg selector is translated to Rust config; engine completes successfully\n        assert not engine.balance.empty\n\n    def test_engine_selector_used_when_leg_has_none(self):\n        \"\"\"When leg has no signal_selector, engine-level selector is used.\"\"\"\n        options_data = _options_data()\n        schema = options_data.schema\n\n        leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\n        leg.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n        leg.exit_filter = schema.dte <= 30\n\n        strat = Strategy(schema)\n        strat.add_legs([leg])\n\n        engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n            signal_selector=FirstMatch(),\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = options_data\n        engine.options_strategy = strat\n        engine.run(rebalance_freq=1)\n\n        assert not engine.balance.empty\n\n\nclass TestPerLegFillModel:\n    \"\"\"Verify per-leg fill model overrides the engine-level one.\"\"\"\n\n    def test_midprice_differs_from_market(self):\n        \"\"\"MidPrice fill model should produce different costs than MarketAtBidAsk.\"\"\"\n        options_data = _options_data()\n        schema = options_data.schema\n\n        # Run with default MarketAtBidAsk\n        leg_market = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT,\n                                 direction=Direction.BUY, fill_model=MarketAtBidAsk())\n        leg_market.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n        leg_market.exit_filter = schema.dte <= 30\n        strat_market = Strategy(schema)\n        strat_market.add_legs([leg_market])\n\n        engine_market = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n        )\n        engine_market.stocks = _ivy_stocks()\n        engine_market.stocks_data = _stocks_data()\n        engine_market.options_data = _options_data()\n        engine_market.options_strategy = strat_market\n        engine_market.run(rebalance_freq=1)\n\n        # Run with MidPrice fill model\n        options_data2 = _options_data()\n        schema2 = options_data2.schema\n        leg_mid = StrategyLeg(\"leg_1\", schema2, option_type=Type.PUT,\n                              direction=Direction.BUY, fill_model=MidPrice())\n        leg_mid.entry_filter = (schema2.underlying == \"SPX\") & (schema2.dte >= 60)\n        leg_mid.exit_filter = schema2.dte <= 30\n        strat_mid = Strategy(schema2)\n        strat_mid.add_legs([leg_mid])\n\n        engine_mid = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n        )\n        engine_mid.stocks = _ivy_stocks()\n        engine_mid.stocks_data = _stocks_data()\n        engine_mid.options_data = options_data2\n        engine_mid.options_strategy = strat_mid\n        engine_mid.run(rebalance_freq=1)\n\n        # Both should have trades\n        assert not engine_market.trade_log.empty\n        assert not engine_mid.trade_log.empty\n\n        # Costs should differ because MidPrice uses (bid+ask)/2 instead of ask\n        market_costs = engine_market.trade_log[\"leg_1\"][\"cost\"].values\n        mid_costs = engine_mid.trade_log[\"leg_1\"][\"cost\"].values\n\n        if len(market_costs) > 0 and len(mid_costs) > 0:\n            # MidPrice for BUY should be cheaper than MarketAtBidAsk (which uses ask)\n            # Different fill models may produce different numbers of trades\n            if len(market_costs) != len(mid_costs):\n                pass  # Different lengths means different results\n            else:\n                assert not np.allclose(market_costs, mid_costs, rtol=1e-6), \\\n                    \"MidPrice should produce different costs than MarketAtBidAsk\"\n"
  },
  {
    "path": "tests/engine/test_pipeline.py",
    "content": "from __future__ import annotations\n\nimport pandas as pd\nimport numpy as np\n\nfrom options_portfolio_backtester.engine.pipeline import (\n    AlgoPipelineBacktester,\n    CapitalFlow,\n    CloseDead,\n    ClosePositionsAfterDates,\n    CouponPayingPosition,\n    HedgeRisks,\n    LimitDeltas,\n    LimitWeights,\n    Margin,\n    MaxDrawdownGuard,\n    Not,\n    Or,\n    PipelineContext,\n    RandomBenchmarkResult,\n    Rebalance,\n    RebalanceOverTime,\n    ReplayTransactions,\n    Require,\n    RunAfterDate,\n    RunAfterDays,\n    RunDaily,\n    RunEveryNPeriods,\n    RunIfOutOfBounds,\n    RunMonthly,\n    RunOnce,\n    RunOnDate,\n    RunQuarterly,\n    RunWeekly,\n    RunYearly,\n    ScaleWeights,\n    SelectActive,\n    SelectAll,\n    SelectHasData,\n    SelectMomentum,\n    SelectN,\n    SelectRandomly,\n    SelectRegex,\n    SelectThese,\n    SelectWhere,\n    StepDecision,\n    TargetVol,\n    WeighERC,\n    WeighEqually,\n    WeighInvVol,\n    WeighMeanVar,\n    WeighRandomly,\n    WeighSpecified,\n    WeighTarget,\n    benchmark_random,\n)\n\n\ndef _prices() -> pd.DataFrame:\n    idx = pd.to_datetime([\"2024-01-02\", \"2024-01-03\", \"2024-02-01\", \"2024-02-02\"])\n    return pd.DataFrame({\"SPY\": [100.0, 102.0, 101.0, 103.0]}, index=idx)\n\n\n# ---------------------------------------------------------------------------\n# RunMonthly\n# ---------------------------------------------------------------------------\n\ndef test_pipeline_rebalances_on_month_start_only():\n    bt = AlgoPipelineBacktester(\n        prices=_prices(),\n        initial_capital=1000.0,\n        algos=[RunMonthly(), SelectThese([\"SPY\"]), WeighSpecified({\"SPY\": 1.0}), Rebalance()],\n    )\n    bal = bt.run()\n\n    assert \"SPY qty\" in bal.columns\n    assert bal.loc[pd.Timestamp(\"2024-01-02\"), \"SPY qty\"] == 10\n    assert bal.loc[pd.Timestamp(\"2024-01-03\"), \"SPY qty\"] == 10\n    assert bal.loc[pd.Timestamp(\"2024-02-01\"), \"SPY qty\"] == 10\n\n    logs = bt.logs_dataframe()\n    jan3 = logs[logs[\"date\"] == pd.Timestamp(\"2024-01-03\")]\n    assert (jan3[\"status\"] == \"skip_day\").any()\n\n\ndef test_run_monthly_reset_allows_rerun():\n    \"\"\"After reset(), the algo should not skip the first month on a second run.\"\"\"\n    algos = [RunMonthly(), SelectThese([\"SPY\"]), WeighSpecified({\"SPY\": 1.0}), Rebalance()]\n    bt = AlgoPipelineBacktester(prices=_prices(), initial_capital=1000.0, algos=algos)\n    bal1 = bt.run()\n    bal2 = bt.run()  # second run should reset state\n    assert bal1.loc[pd.Timestamp(\"2024-01-02\"), \"SPY qty\"] == bal2.loc[pd.Timestamp(\"2024-01-02\"), \"SPY qty\"]\n\n\n# ---------------------------------------------------------------------------\n# MaxDrawdownGuard\n# ---------------------------------------------------------------------------\n\ndef test_drawdown_guard_blocks_rebalance():\n    prices = pd.DataFrame(\n        {\"SPY\": [100.0, 60.0, 55.0]},\n        index=pd.to_datetime([\"2024-01-02\", \"2024-02-01\", \"2024-03-01\"]),\n    )\n    bt = AlgoPipelineBacktester(\n        prices=prices,\n        initial_capital=1000.0,\n        algos=[\n            RunMonthly(),\n            SelectThese([\"SPY\"]),\n            WeighSpecified({\"SPY\": 1.0}),\n            MaxDrawdownGuard(max_drawdown_pct=0.20),\n            Rebalance(),\n        ],\n    )\n    bal = bt.run()\n    assert bal.loc[pd.Timestamp(\"2024-01-02\"), \"SPY qty\"] == 10\n    assert bal.loc[pd.Timestamp(\"2024-02-01\"), \"SPY qty\"] == 10\n    logs = bt.logs_dataframe()\n    feb = logs[(logs[\"date\"] == pd.Timestamp(\"2024-02-01\")) & (logs[\"step\"] == \"MaxDrawdownGuard\")]\n    assert not feb.empty\n    assert feb.iloc[0][\"status\"] == \"skip_day\"\n\n\ndef test_drawdown_guard_reset():\n    guard = MaxDrawdownGuard(max_drawdown_pct=0.10)\n    ctx = PipelineContext(\n        date=pd.Timestamp(\"2024-01-02\"),\n        prices=pd.Series({\"SPY\": 100.0}),\n        total_capital=1000.0,\n        cash=1000.0,\n        positions={},\n    )\n    guard(ctx)  # sets _peak = 1000\n    assert guard._peak == 1000.0\n    guard.reset()\n    assert guard._peak == 0.0\n\n\n# ---------------------------------------------------------------------------\n# Stop status (item 13)\n# ---------------------------------------------------------------------------\n\nclass _StopAlgo:\n    def __call__(self, ctx: PipelineContext) -> StepDecision:\n        if ctx.date >= pd.Timestamp(\"2024-02-01\"):\n            return StepDecision(status=\"stop\", message=\"halt\")\n        return StepDecision()\n\n\ndef test_stop_algo_halts_pipeline_early():\n    bt = AlgoPipelineBacktester(\n        prices=_prices(),\n        initial_capital=1000.0,\n        algos=[_StopAlgo(), SelectThese([\"SPY\"]), WeighSpecified({\"SPY\": 1.0}), Rebalance()],\n    )\n    bal = bt.run()\n    # Should stop at 2024-02-01 — only 3 rows (Jan 2, Jan 3, Feb 1)\n    assert len(bal) == 3\n    logs = bt.logs_dataframe()\n    stop_rows = logs[logs[\"status\"] == \"stop\"]\n    assert len(stop_rows) == 1\n    assert stop_rows.iloc[0][\"date\"] == pd.Timestamp(\"2024-02-01\")\n\n\n# ---------------------------------------------------------------------------\n# SelectThese\n# ---------------------------------------------------------------------------\n\ndef test_select_these_filters_missing_symbols():\n    prices = pd.DataFrame(\n        {\"SPY\": [100.0, 102.0], \"TLT\": [50.0, np.nan]},\n        index=pd.to_datetime([\"2024-01-02\", \"2024-01-03\"]),\n    )\n    bt = AlgoPipelineBacktester(\n        prices=prices,\n        initial_capital=1000.0,\n        algos=[SelectThese([\"SPY\", \"TLT\"]), WeighSpecified({\"SPY\": 0.5, \"TLT\": 0.5}), Rebalance()],\n    )\n    bal = bt.run()\n    # On Jan 3, TLT is NaN, so only SPY should be selected with normalized weight = 1.0\n    assert bal.loc[pd.Timestamp(\"2024-01-03\"), \"SPY qty\"] > 0\n\n\ndef test_select_these_case_insensitive():\n    algo = SelectThese([\"spy\", \"Tlt\"])\n    assert algo.symbols == [\"SPY\", \"TLT\"]\n\n\n# ---------------------------------------------------------------------------\n# WeighSpecified\n# ---------------------------------------------------------------------------\n\ndef test_weigh_specified_normalizes():\n    algo = WeighSpecified({\"SPY\": 2.0, \"TLT\": 1.0})\n    ctx = PipelineContext(\n        date=pd.Timestamp(\"2024-01-02\"),\n        prices=pd.Series({\"SPY\": 100.0, \"TLT\": 50.0}),\n        total_capital=1000.0,\n        cash=1000.0,\n        positions={},\n        selected_symbols=[\"SPY\", \"TLT\"],\n    )\n    algo(ctx)\n    assert abs(ctx.target_weights[\"SPY\"] - 2.0 / 3.0) < 1e-12\n    assert abs(ctx.target_weights[\"TLT\"] - 1.0 / 3.0) < 1e-12\n\n\ndef test_weigh_specified_skips_on_empty_selected():\n    algo = WeighSpecified({\"SPY\": 1.0})\n    ctx = PipelineContext(\n        date=pd.Timestamp(\"2024-01-02\"),\n        prices=pd.Series({\"SPY\": 100.0}),\n        total_capital=1000.0,\n        cash=1000.0,\n        positions={},\n        selected_symbols=[],\n    )\n    decision = algo(ctx)\n    assert decision.status == \"skip_day\"\n\n\n# ---------------------------------------------------------------------------\n# Rebalance\n# ---------------------------------------------------------------------------\n\ndef test_rebalance_computes_floor_qty():\n    ctx = PipelineContext(\n        date=pd.Timestamp(\"2024-01-02\"),\n        prices=pd.Series({\"SPY\": 333.0}),\n        total_capital=1000.0,\n        cash=1000.0,\n        positions={},\n        target_weights={\"SPY\": 1.0},\n    )\n    Rebalance()(ctx)\n    # floor(1000 / 333) = 3\n    assert ctx.positions[\"SPY\"] == 3.0\n    assert ctx.cash == 1000.0 - 3 * 333.0\n\n\ndef test_rebalance_skips_zero_price():\n    ctx = PipelineContext(\n        date=pd.Timestamp(\"2024-01-02\"),\n        prices=pd.Series({\"SPY\": 0.0, \"TLT\": 50.0}),\n        total_capital=1000.0,\n        cash=1000.0,\n        positions={},\n        target_weights={\"SPY\": 0.5, \"TLT\": 0.5},\n    )\n    Rebalance()(ctx)\n    assert \"SPY\" not in ctx.positions\n    assert ctx.positions[\"TLT\"] == 10.0\n\n\n# ---------------------------------------------------------------------------\n# Balance output structure\n# ---------------------------------------------------------------------------\n\ndef test_balance_has_expected_columns():\n    bt = AlgoPipelineBacktester(\n        prices=_prices(),\n        initial_capital=1000.0,\n        algos=[RunMonthly(), SelectThese([\"SPY\"]), WeighSpecified({\"SPY\": 1.0}), Rebalance()],\n    )\n    bal = bt.run()\n    for col in [\"cash\", \"stocks capital\", \"total capital\", \"% change\", \"accumulated return\"]:\n        assert col in bal.columns, f\"Missing column: {col}\"\n\n\ndef test_logs_dataframe_schema():\n    bt = AlgoPipelineBacktester(\n        prices=_prices(),\n        initial_capital=1000.0,\n        algos=[RunMonthly(), SelectThese([\"SPY\"]), WeighSpecified({\"SPY\": 1.0}), Rebalance()],\n    )\n    bt.run()\n    logs = bt.logs_dataframe()\n    assert list(logs.columns) == [\"date\", \"step\", \"status\", \"message\"]\n    assert set(logs[\"status\"].unique()) <= {\"continue\", \"skip_day\", \"stop\"}\n\n\ndef test_empty_run_returns_empty_balance():\n    prices = pd.DataFrame({\"SPY\": pd.Series(dtype=float)})\n    bt = AlgoPipelineBacktester(prices=prices, initial_capital=1000.0, algos=[])\n    bal = bt.run()\n    assert bal.empty\n\n\n# ---------------------------------------------------------------------------\n# Multi-symbol\n# ---------------------------------------------------------------------------\n\ndef test_multi_symbol_rebalance():\n    idx = pd.to_datetime([\"2024-01-02\", \"2024-02-01\"])\n    prices = pd.DataFrame({\"SPY\": [100.0, 110.0], \"TLT\": [50.0, 48.0]}, index=idx)\n    bt = AlgoPipelineBacktester(\n        prices=prices,\n        initial_capital=10_000.0,\n        algos=[RunMonthly(), SelectThese([\"SPY\", \"TLT\"]), WeighSpecified({\"SPY\": 0.6, \"TLT\": 0.4}), Rebalance()],\n    )\n    bal = bt.run()\n    assert \"SPY qty\" in bal.columns\n    assert \"TLT qty\" in bal.columns\n    # SPY target = 10000 * 0.6 = 6000, qty = floor(6000/100) = 60\n    assert bal.loc[pd.Timestamp(\"2024-01-02\"), \"SPY qty\"] == 60\n    # TLT target = 10000 * 0.4 = 4000, qty = floor(4000/50) = 80\n    assert bal.loc[pd.Timestamp(\"2024-01-02\"), \"TLT qty\"] == 80\n\n\n# ---------------------------------------------------------------------------\n# Helper: longer price history for algos needing lookback\n# ---------------------------------------------------------------------------\n\ndef _daily_prices(symbols=(\"SPY\", \"TLT\"), days=60, seed=42) -> pd.DataFrame:\n    \"\"\"Generate synthetic daily prices for testing.\"\"\"\n    rng = np.random.RandomState(seed)\n    idx = pd.bdate_range(\"2024-01-02\", periods=days)\n    data = {}\n    for s in symbols:\n        base = 100.0 if s == \"SPY\" else 50.0\n        rets = rng.normal(0.0005, 0.01, days)\n        data[s] = base * np.cumprod(1 + rets)\n    return pd.DataFrame(data, index=idx)\n\n\ndef _weekly_prices() -> pd.DataFrame:\n    \"\"\"Prices spanning two full weeks (Mon-Fri), so RunWeekly triggers twice.\"\"\"\n    idx = pd.to_datetime([\n        \"2024-01-08\", \"2024-01-09\", \"2024-01-10\", \"2024-01-11\", \"2024-01-12\",\n        \"2024-01-15\", \"2024-01-16\", \"2024-01-17\", \"2024-01-18\", \"2024-01-19\",\n    ])\n    return pd.DataFrame({\"SPY\": np.linspace(100, 110, 10)}, index=idx)\n\n\ndef _ctx(prices=None, total_capital=1000.0, cash=1000.0, positions=None,\n         selected_symbols=None, target_weights=None, price_history=None,\n         date=None) -> PipelineContext:\n    \"\"\"Shortcut to build a PipelineContext.\"\"\"\n    if prices is None:\n        prices = pd.Series({\"SPY\": 100.0})\n    if date is None:\n        date = pd.Timestamp(\"2024-01-02\")\n    return PipelineContext(\n        date=date,\n        prices=prices,\n        total_capital=total_capital,\n        cash=cash,\n        positions=positions or {},\n        selected_symbols=selected_symbols or [],\n        target_weights=target_weights or {},\n        price_history=price_history,\n    )\n\n\n# ===========================================================================\n# SCHEDULING ALGOS\n# ===========================================================================\n\n\n# ---------------------------------------------------------------------------\n# RunWeekly\n# ---------------------------------------------------------------------------\n\ndef test_run_weekly_triggers_once_per_week():\n    algo = RunWeekly()\n    prices = _weekly_prices()\n    bt = AlgoPipelineBacktester(\n        prices=prices, initial_capital=1000.0,\n        algos=[algo, SelectThese([\"SPY\"]), WeighSpecified({\"SPY\": 1.0}), Rebalance()],\n    )\n    bal = bt.run()\n    logs = bt.logs_dataframe()\n    rebalance_dates = logs[(logs[\"step\"] == \"Rebalance\") & (logs[\"status\"] == \"continue\")][\"date\"]\n    # Should rebalance on the first day of each week\n    assert len(rebalance_dates) == 2\n\n\ndef test_run_weekly_reset():\n    algo = RunWeekly()\n    ctx1 = _ctx(date=pd.Timestamp(\"2024-01-08\"))\n    algo(ctx1)\n    assert algo._last_week is not None\n    algo.reset()\n    assert algo._last_week is None\n\n\ndef test_run_weekly_skips_same_week():\n    algo = RunWeekly()\n    d1 = algo(_ctx(date=pd.Timestamp(\"2024-01-08\")))  # Mon\n    d2 = algo(_ctx(date=pd.Timestamp(\"2024-01-09\")))  # Tue same week\n    assert d1.status == \"continue\"\n    assert d2.status == \"skip_day\"\n\n\n# ---------------------------------------------------------------------------\n# RunQuarterly\n# ---------------------------------------------------------------------------\n\ndef test_run_quarterly_triggers_once_per_quarter():\n    idx = pd.to_datetime([\n        \"2024-01-02\", \"2024-02-01\", \"2024-03-01\",\n        \"2024-04-01\", \"2024-05-01\", \"2024-06-01\",\n        \"2024-07-01\",\n    ])\n    prices = pd.DataFrame({\"SPY\": [100] * 7}, index=idx)\n    bt = AlgoPipelineBacktester(\n        prices=prices, initial_capital=1000.0,\n        algos=[RunQuarterly(), SelectThese([\"SPY\"]), WeighSpecified({\"SPY\": 1.0}), Rebalance()],\n    )\n    bt.run()\n    logs = bt.logs_dataframe()\n    rebalance_dates = logs[(logs[\"step\"] == \"Rebalance\") & (logs[\"status\"] == \"continue\")][\"date\"]\n    # Q1 (Jan), Q2 (Apr), Q3 (Jul) = 3 rebalances\n    assert len(rebalance_dates) == 3\n\n\ndef test_run_quarterly_skips_same_quarter():\n    algo = RunQuarterly()\n    d1 = algo(_ctx(date=pd.Timestamp(\"2024-01-02\")))\n    d2 = algo(_ctx(date=pd.Timestamp(\"2024-02-15\")))\n    assert d1.status == \"continue\"\n    assert d2.status == \"skip_day\"\n\n\ndef test_run_quarterly_reset():\n    algo = RunQuarterly()\n    algo(_ctx(date=pd.Timestamp(\"2024-01-02\")))\n    algo.reset()\n    assert algo._last_quarter is None\n\n\n# ---------------------------------------------------------------------------\n# RunYearly\n# ---------------------------------------------------------------------------\n\ndef test_run_yearly_triggers_once_per_year():\n    idx = pd.to_datetime([\"2024-01-02\", \"2024-06-01\", \"2024-12-31\", \"2025-01-02\", \"2025-06-01\"])\n    prices = pd.DataFrame({\"SPY\": [100] * 5}, index=idx)\n    bt = AlgoPipelineBacktester(\n        prices=prices, initial_capital=1000.0,\n        algos=[RunYearly(), SelectThese([\"SPY\"]), WeighSpecified({\"SPY\": 1.0}), Rebalance()],\n    )\n    bt.run()\n    logs = bt.logs_dataframe()\n    rebalance_dates = logs[(logs[\"step\"] == \"Rebalance\") & (logs[\"status\"] == \"continue\")][\"date\"]\n    # 2024 and 2025 = 2 rebalances\n    assert len(rebalance_dates) == 2\n\n\ndef test_run_yearly_skips_same_year():\n    algo = RunYearly()\n    d1 = algo(_ctx(date=pd.Timestamp(\"2024-01-02\")))\n    d2 = algo(_ctx(date=pd.Timestamp(\"2024-06-15\")))\n    assert d1.status == \"continue\"\n    assert d2.status == \"skip_day\"\n\n\ndef test_run_yearly_reset():\n    algo = RunYearly()\n    algo(_ctx(date=pd.Timestamp(\"2024-01-02\")))\n    algo.reset()\n    assert algo._last_year is None\n\n\n# ---------------------------------------------------------------------------\n# RunDaily\n# ---------------------------------------------------------------------------\n\ndef test_run_daily_always_continues():\n    algo = RunDaily()\n    for d in [\"2024-01-02\", \"2024-01-03\", \"2024-01-04\"]:\n        assert algo(_ctx(date=pd.Timestamp(d))).status == \"continue\"\n\n\ndef test_run_daily_full_pipeline():\n    prices = _prices()\n    bt = AlgoPipelineBacktester(\n        prices=prices, initial_capital=1000.0,\n        algos=[RunDaily(), SelectThese([\"SPY\"]), WeighSpecified({\"SPY\": 1.0}), Rebalance()],\n    )\n    bt.run()\n    logs = bt.logs_dataframe()\n    rebalance_count = len(logs[(logs[\"step\"] == \"Rebalance\") & (logs[\"status\"] == \"continue\")])\n    assert rebalance_count == len(prices)\n\n\n# ---------------------------------------------------------------------------\n# RunOnce\n# ---------------------------------------------------------------------------\n\ndef test_run_once_only_first_date():\n    algo = RunOnce()\n    d1 = algo(_ctx(date=pd.Timestamp(\"2024-01-02\")))\n    d2 = algo(_ctx(date=pd.Timestamp(\"2024-01-03\")))\n    d3 = algo(_ctx(date=pd.Timestamp(\"2024-02-01\")))\n    assert d1.status == \"continue\"\n    assert d2.status == \"skip_day\"\n    assert d3.status == \"skip_day\"\n\n\ndef test_run_once_reset():\n    algo = RunOnce()\n    algo(_ctx(date=pd.Timestamp(\"2024-01-02\")))\n    assert algo._ran is True\n    algo.reset()\n    assert algo._ran is False\n    d = algo(_ctx(date=pd.Timestamp(\"2024-02-01\")))\n    assert d.status == \"continue\"\n\n\ndef test_run_once_full_pipeline():\n    prices = _prices()\n    bt = AlgoPipelineBacktester(\n        prices=prices, initial_capital=1000.0,\n        algos=[RunOnce(), SelectThese([\"SPY\"]), WeighSpecified({\"SPY\": 1.0}), Rebalance()],\n    )\n    bt.run()\n    logs = bt.logs_dataframe()\n    rebalance_count = len(logs[(logs[\"step\"] == \"Rebalance\") & (logs[\"status\"] == \"continue\")])\n    assert rebalance_count == 1\n\n\n# ---------------------------------------------------------------------------\n# RunOnDate\n# ---------------------------------------------------------------------------\n\ndef test_run_on_date_specific_dates():\n    algo = RunOnDate([\"2024-01-02\", \"2024-02-01\"])\n    d1 = algo(_ctx(date=pd.Timestamp(\"2024-01-02\")))\n    d2 = algo(_ctx(date=pd.Timestamp(\"2024-01-03\")))\n    d3 = algo(_ctx(date=pd.Timestamp(\"2024-02-01\")))\n    assert d1.status == \"continue\"\n    assert d2.status == \"skip_day\"\n    assert d3.status == \"continue\"\n\n\ndef test_run_on_date_accepts_timestamps():\n    algo = RunOnDate([pd.Timestamp(\"2024-03-15\")])\n    d = algo(_ctx(date=pd.Timestamp(\"2024-03-15\")))\n    assert d.status == \"continue\"\n\n\ndef test_run_on_date_full_pipeline():\n    prices = _prices()\n    bt = AlgoPipelineBacktester(\n        prices=prices, initial_capital=1000.0,\n        algos=[\n            RunOnDate([\"2024-02-01\"]),\n            SelectThese([\"SPY\"]), WeighSpecified({\"SPY\": 1.0}), Rebalance(),\n        ],\n    )\n    bt.run()\n    logs = bt.logs_dataframe()\n    rebalance_count = len(logs[(logs[\"step\"] == \"Rebalance\") & (logs[\"status\"] == \"continue\")])\n    assert rebalance_count == 1\n\n\n# ---------------------------------------------------------------------------\n# RunAfterDate\n# ---------------------------------------------------------------------------\n\ndef test_run_after_date_skips_before():\n    algo = RunAfterDate(\"2024-02-01\")\n    d1 = algo(_ctx(date=pd.Timestamp(\"2024-01-15\")))\n    d2 = algo(_ctx(date=pd.Timestamp(\"2024-02-01\")))\n    d3 = algo(_ctx(date=pd.Timestamp(\"2024-03-01\")))\n    assert d1.status == \"skip_day\"\n    assert d2.status == \"continue\"\n    assert d3.status == \"continue\"\n\n\ndef test_run_after_date_full_pipeline():\n    prices = _prices()\n    bt = AlgoPipelineBacktester(\n        prices=prices, initial_capital=1000.0,\n        algos=[\n            RunAfterDate(\"2024-02-01\"),\n            SelectThese([\"SPY\"]), WeighSpecified({\"SPY\": 1.0}), Rebalance(),\n        ],\n    )\n    bal = bt.run()\n    # First two dates (Jan 2, Jan 3) are skipped, Feb 1 and Feb 2 rebalance\n    logs = bt.logs_dataframe()\n    rebalance_count = len(logs[(logs[\"step\"] == \"Rebalance\") & (logs[\"status\"] == \"continue\")])\n    assert rebalance_count == 2\n\n\n# ---------------------------------------------------------------------------\n# RunEveryNPeriods\n# ---------------------------------------------------------------------------\n\ndef test_run_every_n_periods():\n    algo = RunEveryNPeriods(3)\n    results = []\n    for i in range(9):\n        d = algo(_ctx(date=pd.Timestamp(\"2024-01-02\") + pd.Timedelta(days=i)))\n        results.append(d.status)\n    # Period 1: continue (first), 2: skip, 3: skip, 4: continue, 5: skip, 6: skip, 7: continue, ...\n    assert results == [\n        \"continue\", \"skip_day\", \"skip_day\",\n        \"continue\", \"skip_day\", \"skip_day\",\n        \"continue\", \"skip_day\", \"skip_day\",\n    ]\n\n\ndef test_run_every_n_periods_reset():\n    algo = RunEveryNPeriods(5)\n    for _ in range(3):\n        algo(_ctx())\n    algo.reset()\n    assert algo._count == 0\n    d = algo(_ctx())\n    assert d.status == \"continue\"\n\n\n# ---------------------------------------------------------------------------\n# Or combinator\n# ---------------------------------------------------------------------------\n\ndef test_or_passes_if_any_child_passes():\n    algo = Or(RunMonthly(), RunWeekly())\n    # First call: both children haven't seen any date, so both pass → Or passes\n    d1 = algo(_ctx(date=pd.Timestamp(\"2024-01-08\")))\n    assert d1.status == \"continue\"\n\n\ndef test_or_skips_if_all_children_skip():\n    monthly = RunMonthly()\n    weekly = RunWeekly()\n    algo = Or(monthly, weekly)\n    # First call: RunMonthly passes → Or short-circuits, RunWeekly never called\n    algo(_ctx(date=pd.Timestamp(\"2024-01-08\")))\n    # Second call in same month: RunMonthly skips, RunWeekly sees first date → passes\n    algo(_ctx(date=pd.Timestamp(\"2024-01-09\")))\n    # Third call: same month AND same week → both skip → Or skips\n    d3 = algo(_ctx(date=pd.Timestamp(\"2024-01-10\")))\n    assert d3.status == \"skip_day\"\n\n\ndef test_or_passes_when_one_passes():\n    monthly = RunMonthly()\n    weekly = RunWeekly()\n    algo = Or(monthly, weekly)\n    algo(_ctx(date=pd.Timestamp(\"2024-01-08\")))\n    # New week but same month → weekly passes → Or passes\n    d = algo(_ctx(date=pd.Timestamp(\"2024-01-15\")))\n    assert d.status == \"continue\"\n\n\ndef test_or_reset():\n    monthly = RunMonthly()\n    weekly = RunWeekly()\n    algo = Or(monthly, weekly)\n    algo(_ctx(date=pd.Timestamp(\"2024-01-08\")))\n    algo.reset()\n    assert monthly._last_month is None\n    assert weekly._last_week is None\n\n\n# ---------------------------------------------------------------------------\n# Not combinator\n# ---------------------------------------------------------------------------\n\ndef test_not_inverts_skip_to_continue():\n    monthly = RunMonthly()\n    algo = Not(monthly)\n    # First call: RunMonthly returns continue → Not inverts to skip_day\n    d1 = algo(_ctx(date=pd.Timestamp(\"2024-01-02\")))\n    assert d1.status == \"skip_day\"\n\n\ndef test_not_inverts_continue_to_skip():\n    monthly = RunMonthly()\n    algo = Not(monthly)\n    algo(_ctx(date=pd.Timestamp(\"2024-01-02\")))\n    # Same month → RunMonthly skips → Not inverts to continue\n    d2 = algo(_ctx(date=pd.Timestamp(\"2024-01-03\")))\n    assert d2.status == \"continue\"\n\n\ndef test_not_reset():\n    monthly = RunMonthly()\n    algo = Not(monthly)\n    algo(_ctx(date=pd.Timestamp(\"2024-01-02\")))\n    algo.reset()\n    assert monthly._last_month is None\n\n\n# ===========================================================================\n# SELECTION ALGOS\n# ===========================================================================\n\n\n# ---------------------------------------------------------------------------\n# SelectAll\n# ---------------------------------------------------------------------------\n\ndef test_select_all_picks_valid_prices():\n    ctx = _ctx(prices=pd.Series({\"SPY\": 100.0, \"TLT\": 50.0, \"BAD\": np.nan}))\n    d = SelectAll()(ctx)\n    assert d.status == \"continue\"\n    assert set(ctx.selected_symbols) == {\"SPY\", \"TLT\"}\n\n\ndef test_select_all_skips_zero_price():\n    ctx = _ctx(prices=pd.Series({\"SPY\": 0.0}))\n    d = SelectAll()(ctx)\n    assert d.status == \"skip_day\"\n\n\ndef test_select_all_skips_all_nan():\n    ctx = _ctx(prices=pd.Series({\"SPY\": np.nan, \"TLT\": np.nan}))\n    d = SelectAll()(ctx)\n    assert d.status == \"skip_day\"\n\n\n# ---------------------------------------------------------------------------\n# SelectHasData\n# ---------------------------------------------------------------------------\n\ndef test_select_has_data_filters_by_history_length():\n    prices = _daily_prices(days=10)\n    ctx = _ctx(\n        prices=prices.iloc[-1],\n        date=prices.index[-1],\n        selected_symbols=[\"SPY\", \"TLT\"],\n        price_history=prices,\n    )\n    algo = SelectHasData(min_days=10)\n    d = algo(ctx)\n    assert d.status == \"continue\"\n    assert set(ctx.selected_symbols) == {\"SPY\", \"TLT\"}\n\n\ndef test_select_has_data_removes_short_history():\n    prices = _daily_prices(days=5)\n    ctx = _ctx(\n        prices=prices.iloc[-1],\n        date=prices.index[-1],\n        selected_symbols=[\"SPY\", \"TLT\"],\n        price_history=prices,\n    )\n    algo = SelectHasData(min_days=10)\n    d = algo(ctx)\n    assert d.status == \"skip_day\"\n\n\ndef test_select_has_data_no_history():\n    ctx = _ctx(selected_symbols=[\"SPY\"])\n    algo = SelectHasData(min_days=1)\n    d = algo(ctx)\n    assert d.status == \"skip_day\"\n\n\ndef test_select_has_data_uses_all_symbols_if_none_selected():\n    prices = _daily_prices(days=5)\n    ctx = _ctx(\n        prices=prices.iloc[-1],\n        date=prices.index[-1],\n        selected_symbols=[],  # empty\n        price_history=prices,\n    )\n    algo = SelectHasData(min_days=3)\n    d = algo(ctx)\n    assert d.status == \"continue\"\n    assert set(ctx.selected_symbols) == {\"SPY\", \"TLT\"}\n\n\n# ---------------------------------------------------------------------------\n# SelectMomentum\n# ---------------------------------------------------------------------------\n\ndef test_select_momentum_picks_top_n():\n    # SPY goes up, TLT goes down\n    idx = pd.bdate_range(\"2024-01-02\", periods=20)\n    prices = pd.DataFrame({\n        \"SPY\": np.linspace(100, 120, 20),  # +20%\n        \"TLT\": np.linspace(100, 90, 20),   # -10%\n        \"GLD\": np.linspace(100, 105, 20),  # +5%\n    }, index=idx)\n    ctx = _ctx(\n        prices=prices.iloc[-1],\n        date=prices.index[-1],\n        selected_symbols=[\"SPY\", \"TLT\", \"GLD\"],\n        price_history=prices,\n    )\n    algo = SelectMomentum(n=2, lookback=20)\n    d = algo(ctx)\n    assert d.status == \"continue\"\n    assert ctx.selected_symbols == [\"SPY\", \"GLD\"]\n\n\ndef test_select_momentum_ascending():\n    idx = pd.bdate_range(\"2024-01-02\", periods=20)\n    prices = pd.DataFrame({\n        \"SPY\": np.linspace(100, 120, 20),\n        \"TLT\": np.linspace(100, 90, 20),\n    }, index=idx)\n    ctx = _ctx(\n        prices=prices.iloc[-1],\n        date=prices.index[-1],\n        selected_symbols=[\"SPY\", \"TLT\"],\n        price_history=prices,\n    )\n    algo = SelectMomentum(n=1, lookback=20, sort_descending=False)\n    algo(ctx)\n    assert ctx.selected_symbols == [\"TLT\"]\n\n\ndef test_select_momentum_no_history():\n    ctx = _ctx(selected_symbols=[\"SPY\"])\n    d = SelectMomentum(n=1)(ctx)\n    assert d.status == \"skip_day\"\n\n\n# ---------------------------------------------------------------------------\n# SelectN\n# ---------------------------------------------------------------------------\n\ndef test_select_n_truncates():\n    ctx = _ctx(selected_symbols=[\"SPY\", \"TLT\", \"GLD\", \"QQQ\"])\n    d = SelectN(2)(ctx)\n    assert d.status == \"continue\"\n    assert ctx.selected_symbols == [\"SPY\", \"TLT\"]\n\n\ndef test_select_n_empty():\n    ctx = _ctx(selected_symbols=[])\n    d = SelectN(5)(ctx)\n    assert d.status == \"skip_day\"\n\n\ndef test_select_n_fewer_than_n():\n    ctx = _ctx(selected_symbols=[\"SPY\"])\n    d = SelectN(5)(ctx)\n    assert d.status == \"continue\"\n    assert ctx.selected_symbols == [\"SPY\"]\n\n\n# ---------------------------------------------------------------------------\n# SelectWhere\n# ---------------------------------------------------------------------------\n\ndef test_select_where_custom_filter():\n    ctx = _ctx(\n        prices=pd.Series({\"SPY\": 100.0, \"TLT\": 50.0, \"GLD\": 200.0}),\n        selected_symbols=[\"SPY\", \"TLT\", \"GLD\"],\n    )\n    # Only keep symbols with price > 80\n    algo = SelectWhere(lambda s, c: float(c.prices[s]) > 80)\n    d = algo(ctx)\n    assert d.status == \"continue\"\n    assert set(ctx.selected_symbols) == {\"SPY\", \"GLD\"}\n\n\ndef test_select_where_all_filtered():\n    ctx = _ctx(\n        prices=pd.Series({\"SPY\": 100.0}),\n        selected_symbols=[\"SPY\"],\n    )\n    algo = SelectWhere(lambda s, c: False)\n    d = algo(ctx)\n    assert d.status == \"skip_day\"\n\n\ndef test_select_where_falls_back_to_prices_index():\n    ctx = _ctx(\n        prices=pd.Series({\"SPY\": 100.0, \"TLT\": 50.0}),\n        selected_symbols=[],  # empty\n    )\n    algo = SelectWhere(lambda s, c: s == \"TLT\")\n    d = algo(ctx)\n    assert d.status == \"continue\"\n    assert ctx.selected_symbols == [\"TLT\"]\n\n\n# ===========================================================================\n# WEIGHTING ALGOS\n# ===========================================================================\n\n\n# ---------------------------------------------------------------------------\n# WeighEqually\n# ---------------------------------------------------------------------------\n\ndef test_weigh_equally_two_symbols():\n    ctx = _ctx(selected_symbols=[\"SPY\", \"TLT\"])\n    d = WeighEqually()(ctx)\n    assert d.status == \"continue\"\n    assert abs(ctx.target_weights[\"SPY\"] - 0.5) < 1e-12\n    assert abs(ctx.target_weights[\"TLT\"] - 0.5) < 1e-12\n\n\ndef test_weigh_equally_single_symbol():\n    ctx = _ctx(selected_symbols=[\"SPY\"])\n    WeighEqually()(ctx)\n    assert abs(ctx.target_weights[\"SPY\"] - 1.0) < 1e-12\n\n\ndef test_weigh_equally_empty():\n    ctx = _ctx(selected_symbols=[])\n    d = WeighEqually()(ctx)\n    assert d.status == \"skip_day\"\n\n\ndef test_weigh_equally_three_symbols():\n    ctx = _ctx(selected_symbols=[\"SPY\", \"TLT\", \"GLD\"])\n    WeighEqually()(ctx)\n    for s in [\"SPY\", \"TLT\", \"GLD\"]:\n        assert abs(ctx.target_weights[s] - 1.0 / 3) < 1e-12\n\n\n# ---------------------------------------------------------------------------\n# WeighInvVol\n# ---------------------------------------------------------------------------\n\ndef test_weigh_inv_vol_basic():\n    prices = _daily_prices(days=30)\n    ctx = _ctx(\n        prices=prices.iloc[-1],\n        date=prices.index[-1],\n        selected_symbols=[\"SPY\", \"TLT\"],\n        price_history=prices,\n    )\n    d = WeighInvVol(lookback=30)(ctx)\n    assert d.status == \"continue\"\n    assert abs(sum(ctx.target_weights.values()) - 1.0) < 1e-10\n    assert all(w > 0 for w in ctx.target_weights.values())\n\n\ndef test_weigh_inv_vol_lower_vol_gets_higher_weight():\n    # Create data where TLT has much lower vol than SPY\n    idx = pd.bdate_range(\"2024-01-02\", periods=30)\n    rng = np.random.RandomState(99)\n    spy = 100 * np.cumprod(1 + rng.normal(0, 0.03, 30))  # high vol\n    tlt = 50 * np.cumprod(1 + rng.normal(0, 0.005, 30))   # low vol\n    prices = pd.DataFrame({\"SPY\": spy, \"TLT\": tlt}, index=idx)\n    ctx = _ctx(\n        prices=prices.iloc[-1],\n        date=prices.index[-1],\n        selected_symbols=[\"SPY\", \"TLT\"],\n        price_history=prices,\n    )\n    WeighInvVol(lookback=30)(ctx)\n    # Lower vol (TLT) should get higher weight\n    assert ctx.target_weights[\"TLT\"] > ctx.target_weights[\"SPY\"]\n\n\ndef test_weigh_inv_vol_no_history():\n    ctx = _ctx(selected_symbols=[\"SPY\"])\n    d = WeighInvVol()(ctx)\n    assert d.status == \"skip_day\"\n\n\ndef test_weigh_inv_vol_no_selected():\n    ctx = _ctx(selected_symbols=[])\n    d = WeighInvVol()(ctx)\n    assert d.status == \"skip_day\"\n\n\n# ---------------------------------------------------------------------------\n# WeighMeanVar\n# ---------------------------------------------------------------------------\n\ndef test_weigh_mean_var_basic():\n    prices = _daily_prices(days=30)\n    ctx = _ctx(\n        prices=prices.iloc[-1],\n        date=prices.index[-1],\n        selected_symbols=[\"SPY\", \"TLT\"],\n        price_history=prices,\n    )\n    d = WeighMeanVar(lookback=30)(ctx)\n    assert d.status == \"continue\"\n    assert abs(sum(ctx.target_weights.values()) - 1.0) < 1e-10\n    assert all(w >= 0 for w in ctx.target_weights.values())\n\n\ndef test_weigh_mean_var_single_asset():\n    prices = _daily_prices(symbols=(\"SPY\",), days=30)\n    ctx = _ctx(\n        prices=prices.iloc[-1],\n        date=prices.index[-1],\n        selected_symbols=[\"SPY\"],\n        price_history=prices,\n    )\n    d = WeighMeanVar(lookback=30)(ctx)\n    assert d.status == \"continue\"\n    assert abs(ctx.target_weights[\"SPY\"] - 1.0) < 1e-10\n\n\ndef test_weigh_mean_var_no_history():\n    ctx = _ctx(selected_symbols=[\"SPY\"])\n    d = WeighMeanVar()(ctx)\n    assert d.status == \"skip_day\"\n\n\ndef test_weigh_mean_var_insufficient_data():\n    idx = pd.to_datetime([\"2024-01-02\", \"2024-01-03\"])\n    prices = pd.DataFrame({\"SPY\": [100.0, 101.0]}, index=idx)\n    ctx = _ctx(\n        prices=prices.iloc[-1],\n        date=prices.index[-1],\n        selected_symbols=[\"SPY\"],\n        price_history=prices,\n    )\n    d = WeighMeanVar(lookback=252)(ctx)\n    assert d.status == \"skip_day\"\n\n\n# ---------------------------------------------------------------------------\n# WeighERC\n# ---------------------------------------------------------------------------\n\ndef test_weigh_erc_basic():\n    prices = _daily_prices(days=30)\n    ctx = _ctx(\n        prices=prices.iloc[-1],\n        date=prices.index[-1],\n        selected_symbols=[\"SPY\", \"TLT\"],\n        price_history=prices,\n    )\n    d = WeighERC(lookback=30)(ctx)\n    assert d.status == \"continue\"\n    assert abs(sum(ctx.target_weights.values()) - 1.0) < 1e-10\n    assert all(w > 0 for w in ctx.target_weights.values())\n\n\ndef test_weigh_erc_no_history():\n    ctx = _ctx(selected_symbols=[\"SPY\"])\n    d = WeighERC()(ctx)\n    assert d.status == \"skip_day\"\n\n\ndef test_weigh_erc_single_asset():\n    prices = _daily_prices(symbols=(\"SPY\",), days=30)\n    ctx = _ctx(\n        prices=prices.iloc[-1],\n        date=prices.index[-1],\n        selected_symbols=[\"SPY\"],\n        price_history=prices,\n    )\n    d = WeighERC(lookback=30)(ctx)\n    assert d.status == \"continue\"\n    assert abs(ctx.target_weights[\"SPY\"] - 1.0) < 1e-10\n\n\ndef test_weigh_erc_weights_sum_to_one():\n    prices = _daily_prices(symbols=(\"SPY\", \"TLT\", \"GLD\"), days=60, seed=123)\n    ctx = _ctx(\n        prices=prices.iloc[-1],\n        date=prices.index[-1],\n        selected_symbols=[\"SPY\", \"TLT\", \"GLD\"],\n        price_history=prices,\n    )\n    WeighERC(lookback=60)(ctx)\n    assert abs(sum(ctx.target_weights.values()) - 1.0) < 1e-8\n\n\n# ---------------------------------------------------------------------------\n# TargetVol\n# ---------------------------------------------------------------------------\n\ndef test_target_vol_scales_weights():\n    prices = _daily_prices(days=60)\n    ctx = _ctx(\n        prices=prices.iloc[-1],\n        date=prices.index[-1],\n        selected_symbols=[\"SPY\", \"TLT\"],\n        target_weights={\"SPY\": 0.6, \"TLT\": 0.4},\n        price_history=prices,\n    )\n    d = TargetVol(target=0.05, lookback=60)(ctx)\n    assert d.status == \"continue\"\n    # Weights should be scaled down (realized vol likely > 5%)\n    total_w = sum(ctx.target_weights.values())\n    assert total_w <= 1.0 + 1e-10\n\n\ndef test_target_vol_no_weights():\n    ctx = _ctx(target_weights={})\n    d = TargetVol(target=0.10)(ctx)\n    assert d.status == \"skip_day\"\n\n\ndef test_target_vol_no_history():\n    ctx = _ctx(target_weights={\"SPY\": 1.0})\n    d = TargetVol(target=0.10)(ctx)\n    assert d.status == \"skip_day\"\n\n\ndef test_target_vol_never_levers():\n    \"\"\"TargetVol should never scale weights above 1.0.\"\"\"\n    # Create very low vol data\n    idx = pd.bdate_range(\"2024-01-02\", periods=60)\n    prices = pd.DataFrame({\n        \"SPY\": np.linspace(100, 100.5, 60),  # nearly flat → near-zero vol\n    }, index=idx)\n    ctx = _ctx(\n        prices=prices.iloc[-1],\n        date=prices.index[-1],\n        target_weights={\"SPY\": 1.0},\n        price_history=prices,\n    )\n    TargetVol(target=0.50, lookback=60)(ctx)\n    # Scale should be capped at 1.0\n    assert ctx.target_weights[\"SPY\"] <= 1.0 + 1e-10\n\n\n# ===========================================================================\n# WEIGHT LIMITS\n# ===========================================================================\n\n\ndef test_limit_weights_caps():\n    ctx = _ctx(target_weights={\"SPY\": 0.80, \"TLT\": 0.20})\n    LimitWeights(limit=0.50)(ctx)\n    assert ctx.target_weights[\"SPY\"] <= 0.50 + 1e-10\n    # Total should still be close to 1.0\n    assert abs(sum(ctx.target_weights.values()) - 1.0) < 1e-8\n\n\ndef test_limit_weights_no_change_under_limit():\n    ctx = _ctx(target_weights={\"SPY\": 0.40, \"TLT\": 0.60})\n    LimitWeights(limit=0.70)(ctx)\n    assert abs(ctx.target_weights[\"SPY\"] - 0.40) < 1e-10\n    assert abs(ctx.target_weights[\"TLT\"] - 0.60) < 1e-10\n\n\ndef test_limit_weights_empty():\n    ctx = _ctx(target_weights={})\n    d = LimitWeights(limit=0.25)(ctx)\n    assert d.status == \"continue\"\n\n\ndef test_limit_weights_redistributes():\n    ctx = _ctx(target_weights={\"A\": 0.70, \"B\": 0.20, \"C\": 0.10})\n    LimitWeights(limit=0.40)(ctx)\n    assert ctx.target_weights[\"A\"] <= 0.40 + 1e-10\n    # B and C should get the excess redistributed\n    assert ctx.target_weights[\"B\"] > 0.20\n    assert ctx.target_weights[\"C\"] > 0.10\n\n\n# ===========================================================================\n# CAPITAL FLOWS\n# ===========================================================================\n\n\ndef test_capital_flow_dict():\n    flow = CapitalFlow({\"2024-02-01\": 500.0})\n    ctx = _ctx(date=pd.Timestamp(\"2024-02-01\"), cash=1000.0, total_capital=1000.0)\n    flow(ctx)\n    assert ctx.cash == 1500.0\n    assert ctx.total_capital == 1500.0\n\n\ndef test_capital_flow_no_flow_date():\n    flow = CapitalFlow({\"2024-02-01\": 500.0})\n    ctx = _ctx(date=pd.Timestamp(\"2024-01-15\"), cash=1000.0, total_capital=1000.0)\n    flow(ctx)\n    assert ctx.cash == 1000.0\n\n\ndef test_capital_flow_withdrawal():\n    flow = CapitalFlow({\"2024-02-01\": -200.0})\n    ctx = _ctx(date=pd.Timestamp(\"2024-02-01\"), cash=1000.0, total_capital=1000.0)\n    flow(ctx)\n    assert ctx.cash == 800.0\n    assert ctx.total_capital == 800.0\n\n\ndef test_capital_flow_callable():\n    # Add 100 on every Monday\n    def monday_flow(d: pd.Timestamp) -> float:\n        return 100.0 if d.weekday() == 0 else 0.0\n\n    flow = CapitalFlow(monday_flow)\n    ctx_mon = _ctx(date=pd.Timestamp(\"2024-01-08\"), cash=1000.0, total_capital=1000.0)\n    flow(ctx_mon)\n    assert ctx_mon.cash == 1100.0\n\n    ctx_tue = _ctx(date=pd.Timestamp(\"2024-01-09\"), cash=1000.0, total_capital=1000.0)\n    flow(ctx_tue)\n    assert ctx_tue.cash == 1000.0\n\n\ndef test_capital_flow_in_pipeline():\n    idx = pd.to_datetime([\"2024-01-02\", \"2024-02-01\", \"2024-03-01\"])\n    prices = pd.DataFrame({\"SPY\": [100.0, 100.0, 100.0]}, index=idx)\n    bt = AlgoPipelineBacktester(\n        prices=prices, initial_capital=1000.0,\n        algos=[\n            RunMonthly(),\n            CapitalFlow({\"2024-02-01\": 500.0}),\n            SelectThese([\"SPY\"]),\n            WeighSpecified({\"SPY\": 1.0}),\n            Rebalance(),\n        ],\n    )\n    bal = bt.run()\n    # On Feb 1, capital should include the 500 addition\n    assert bal.loc[pd.Timestamp(\"2024-02-01\"), \"total capital\"] > 1000.0\n\n\n# ===========================================================================\n# REBALANCE OVER TIME\n# ===========================================================================\n\n\ndef test_rebalance_over_time_gradual():\n    idx = pd.bdate_range(\"2024-01-02\", periods=10)\n    prices = pd.DataFrame({\"SPY\": [100.0] * 10}, index=idx)\n    bt = AlgoPipelineBacktester(\n        prices=prices, initial_capital=1000.0,\n        algos=[\n            RunDaily(),\n            SelectThese([\"SPY\"]),\n            WeighSpecified({\"SPY\": 1.0}),\n            RebalanceOverTime(n=5),\n        ],\n    )\n    bal = bt.run()\n    # With n=5, the first 5 days should gradually increase position\n    qtys = [bal.iloc[i].get(\"SPY qty\", 0) for i in range(5)]\n    # Each day should get closer to full position\n    assert qtys[-1] >= qtys[0]\n\n\ndef test_rebalance_over_time_reset():\n    algo = RebalanceOverTime(n=3)\n    algo._target = {\"SPY\": 1.0}\n    algo._remaining = 2\n    algo.reset()\n    assert algo._target == {}\n    assert algo._remaining == 0\n\n\ndef test_rebalance_over_time_no_target():\n    algo = RebalanceOverTime(n=3)\n    ctx = _ctx()\n    d = algo(ctx)\n    assert d.status == \"skip_day\"\n\n\n# ===========================================================================\n# INTEGRATION: Full pipeline with new algos\n# ===========================================================================\n\n\ndef test_pipeline_select_all_weigh_equally():\n    idx = pd.to_datetime([\"2024-01-02\", \"2024-02-01\"])\n    prices = pd.DataFrame({\"SPY\": [100.0, 100.0], \"TLT\": [50.0, 50.0]}, index=idx)\n    bt = AlgoPipelineBacktester(\n        prices=prices, initial_capital=10_000.0,\n        algos=[RunMonthly(), SelectAll(), WeighEqually(), Rebalance()],\n    )\n    bal = bt.run()\n    # 50% each: SPY = floor(5000/100) = 50, TLT = floor(5000/50) = 100\n    assert bal.loc[pd.Timestamp(\"2024-01-02\"), \"SPY qty\"] == 50\n    assert bal.loc[pd.Timestamp(\"2024-01-02\"), \"TLT qty\"] == 100\n\n\ndef test_pipeline_momentum_selection():\n    idx = pd.bdate_range(\"2024-01-02\", periods=30)\n    prices = pd.DataFrame({\n        \"SPY\": np.linspace(100, 130, 30),  # +30%\n        \"TLT\": np.linspace(100, 95, 30),   # -5%\n        \"GLD\": np.linspace(100, 110, 30),  # +10%\n    }, index=idx)\n    bt = AlgoPipelineBacktester(\n        prices=prices, initial_capital=10_000.0,\n        algos=[\n            RunMonthly(),\n            SelectAll(),\n            SelectMomentum(n=2, lookback=30),\n            WeighEqually(),\n            Rebalance(),\n        ],\n    )\n    bal = bt.run()\n    # Should pick SPY and GLD (top 2 momentum), not TLT\n    assert \"SPY qty\" in bal.columns\n    assert \"GLD qty\" in bal.columns\n    # TLT should not have been bought (or have 0 qty)\n    if \"TLT qty\" in bal.columns:\n        assert bal[\"TLT qty\"].fillna(0).sum() == 0\n\n\ndef test_pipeline_limit_weights_integration():\n    idx = pd.to_datetime([\"2024-01-02\"])\n    prices = pd.DataFrame({\"SPY\": [100.0], \"TLT\": [50.0], \"GLD\": [200.0]}, index=idx)\n    bt = AlgoPipelineBacktester(\n        prices=prices, initial_capital=10_000.0,\n        algos=[\n            SelectThese([\"SPY\", \"TLT\", \"GLD\"]),\n            WeighSpecified({\"SPY\": 0.8, \"TLT\": 0.1, \"GLD\": 0.1}),\n            LimitWeights(limit=0.40),\n            Rebalance(),\n        ],\n    )\n    bal = bt.run()\n    spy_val = bal.iloc[0][\"SPY qty\"] * 100.0\n    total = bal.iloc[0][\"total capital\"]\n    # SPY weight should be ≤ 40%\n    assert spy_val / total <= 0.45  # small tolerance for floor rounding\n\n\ndef test_pipeline_run_on_date_with_capital_flow():\n    idx = pd.to_datetime([\"2024-01-02\", \"2024-01-15\", \"2024-02-01\", \"2024-02-15\"])\n    prices = pd.DataFrame({\"SPY\": [100.0] * 4}, index=idx)\n    bt = AlgoPipelineBacktester(\n        prices=prices, initial_capital=1000.0,\n        algos=[\n            RunOnDate([\"2024-01-02\", \"2024-02-01\"]),\n            CapitalFlow({\"2024-02-01\": 500.0}),\n            SelectThese([\"SPY\"]),\n            WeighSpecified({\"SPY\": 1.0}),\n            Rebalance(),\n        ],\n    )\n    bal = bt.run()\n    # Jan 2: 1000/100 = 10 shares\n    assert bal.loc[pd.Timestamp(\"2024-01-02\"), \"SPY qty\"] == 10\n    # Feb 1: 1000 + 500 = 1500, 1500/100 = 15 shares\n    assert bal.loc[pd.Timestamp(\"2024-02-01\"), \"SPY qty\"] == 15\n\n\ndef test_pipeline_inv_vol_with_limit_weights():\n    prices = _daily_prices(symbols=(\"SPY\", \"TLT\", \"GLD\"), days=60, seed=77)\n    bt = AlgoPipelineBacktester(\n        prices=prices, initial_capital=100_000.0,\n        algos=[\n            RunMonthly(),\n            SelectAll(),\n            WeighInvVol(lookback=60),\n            LimitWeights(limit=0.50),\n            Rebalance(),\n        ],\n    )\n    bal = bt.run()\n    assert not bal.empty\n    # All weights should respect the 50% limit (check via position values)\n    row = bal.iloc[-1]\n    total = row[\"total capital\"]\n    for sym in [\"SPY\", \"TLT\", \"GLD\"]:\n        qty_col = f\"{sym} qty\"\n        if qty_col in row.index and row[qty_col] > 0:\n            price = prices[sym].iloc[-1]\n            weight = row[qty_col] * price / total\n            assert weight <= 0.55  # tolerance for floor rounding\n\n\n# ===========================================================================\n# NEW ALGOS (round 2)\n# ===========================================================================\n\n\n# ---------------------------------------------------------------------------\n# RunAfterDays\n# ---------------------------------------------------------------------------\n\ndef test_run_after_days_skips_warmup():\n    algo = RunAfterDays(3)\n    results = []\n    for i in range(6):\n        d = algo(_ctx(date=pd.Timestamp(\"2024-01-02\") + pd.Timedelta(days=i)))\n        results.append(d.status)\n    assert results == [\"skip_day\", \"skip_day\", \"skip_day\", \"continue\", \"continue\", \"continue\"]\n\n\ndef test_run_after_days_reset():\n    algo = RunAfterDays(2)\n    algo(_ctx())\n    algo(_ctx())\n    algo.reset()\n    assert algo._count == 0\n    d = algo(_ctx())\n    assert d.status == \"skip_day\"  # back to warmup\n\n\ndef test_run_after_days_in_pipeline():\n    idx = pd.bdate_range(\"2024-01-02\", periods=10)\n    prices = pd.DataFrame({\"SPY\": [100.0] * 10}, index=idx)\n    bt = AlgoPipelineBacktester(\n        prices=prices, initial_capital=1000.0,\n        algos=[RunAfterDays(5), SelectThese([\"SPY\"]), WeighSpecified({\"SPY\": 1.0}), Rebalance()],\n    )\n    bal = bt.run()\n    # First 5 days skipped, rebalance on days 6-10\n    logs = bt.logs_dataframe()\n    rebalance_count = len(logs[(logs[\"step\"] == \"Rebalance\") & (logs[\"status\"] == \"continue\")])\n    assert rebalance_count == 5\n\n\n# ---------------------------------------------------------------------------\n# RunIfOutOfBounds\n# ---------------------------------------------------------------------------\n\ndef test_run_if_out_of_bounds_skips_when_in_bounds():\n    algo = RunIfOutOfBounds(tolerance=0.10)\n    algo.update_target({\"SPY\": 0.60, \"TLT\": 0.40})\n    # Positions match target closely\n    ctx = _ctx(\n        prices=pd.Series({\"SPY\": 100.0, \"TLT\": 50.0}),\n        total_capital=10000.0,\n        positions={\"SPY\": 60.0, \"TLT\": 80.0},  # SPY=60%, TLT=40%\n    )\n    d = algo(ctx)\n    assert d.status == \"skip_day\"\n\n\ndef test_run_if_out_of_bounds_triggers_when_drifted():\n    algo = RunIfOutOfBounds(tolerance=0.05)\n    algo.update_target({\"SPY\": 0.50, \"TLT\": 0.50})\n    # SPY drifted to 70%, TLT to 30%\n    ctx = _ctx(\n        prices=pd.Series({\"SPY\": 100.0, \"TLT\": 50.0}),\n        total_capital=10000.0,\n        positions={\"SPY\": 70.0, \"TLT\": 60.0},  # SPY=70%, TLT=30%\n    )\n    d = algo(ctx)\n    assert d.status == \"continue\"\n\n\ndef test_run_if_out_of_bounds_no_prior_target():\n    algo = RunIfOutOfBounds(tolerance=0.05)\n    d = algo(_ctx())\n    assert d.status == \"skip_day\"\n\n\ndef test_run_if_out_of_bounds_reset():\n    algo = RunIfOutOfBounds(tolerance=0.05)\n    algo.update_target({\"SPY\": 1.0})\n    algo.reset()\n    assert algo._last_target == {}\n\n\n# ---------------------------------------------------------------------------\n# LimitDeltas\n# ---------------------------------------------------------------------------\n\ndef test_limit_deltas_clips_large_change():\n    ctx = _ctx(\n        prices=pd.Series({\"SPY\": 100.0, \"TLT\": 50.0}),\n        total_capital=10000.0,\n        positions={\"SPY\": 50.0, \"TLT\": 100.0},  # SPY=50%, TLT=50%\n        target_weights={\"SPY\": 0.80, \"TLT\": 0.20},  # want to move 30%\n    )\n    LimitDeltas(limit=0.10)(ctx)\n    # SPY delta capped: 0.50 + 0.10 = 0.60 max\n    assert ctx.target_weights[\"SPY\"] <= 0.65  # after renorm\n\n\ndef test_limit_deltas_no_change_needed():\n    ctx = _ctx(\n        prices=pd.Series({\"SPY\": 100.0}),\n        total_capital=10000.0,\n        positions={\"SPY\": 98.0},  # ~98%\n        target_weights={\"SPY\": 1.0},\n    )\n    LimitDeltas(limit=0.10)(ctx)\n    # Small delta, should pass through mostly unchanged\n    assert ctx.target_weights[\"SPY\"] > 0.9\n\n\ndef test_limit_deltas_empty():\n    ctx = _ctx(target_weights={})\n    d = LimitDeltas(limit=0.10)(ctx)\n    assert d.status == \"continue\"\n\n\n# ---------------------------------------------------------------------------\n# ScaleWeights\n# ---------------------------------------------------------------------------\n\ndef test_scale_weights_half():\n    ctx = _ctx(target_weights={\"SPY\": 0.60, \"TLT\": 0.40})\n    ScaleWeights(scale=0.5)(ctx)\n    assert abs(ctx.target_weights[\"SPY\"] - 0.30) < 1e-10\n    assert abs(ctx.target_weights[\"TLT\"] - 0.20) < 1e-10\n\n\ndef test_scale_weights_double():\n    ctx = _ctx(target_weights={\"SPY\": 0.30, \"TLT\": 0.20})\n    ScaleWeights(scale=2.0)(ctx)\n    assert abs(ctx.target_weights[\"SPY\"] - 0.60) < 1e-10\n    assert abs(ctx.target_weights[\"TLT\"] - 0.40) < 1e-10\n\n\ndef test_scale_weights_empty():\n    ctx = _ctx(target_weights={})\n    d = ScaleWeights(scale=0.5)(ctx)\n    assert d.status == \"continue\"\n\n\n# ---------------------------------------------------------------------------\n# SelectRandomly\n# ---------------------------------------------------------------------------\n\ndef test_select_randomly_picks_n():\n    ctx = _ctx(\n        prices=pd.Series({\"SPY\": 100.0, \"TLT\": 50.0, \"GLD\": 200.0, \"QQQ\": 300.0}),\n        selected_symbols=[\"SPY\", \"TLT\", \"GLD\", \"QQQ\"],\n    )\n    algo = SelectRandomly(n=2, seed=42)\n    d = algo(ctx)\n    assert d.status == \"continue\"\n    assert len(ctx.selected_symbols) == 2\n    assert all(s in [\"SPY\", \"TLT\", \"GLD\", \"QQQ\"] for s in ctx.selected_symbols)\n\n\ndef test_select_randomly_deterministic():\n    ctx1 = _ctx(\n        prices=pd.Series({\"SPY\": 100.0, \"TLT\": 50.0, \"GLD\": 200.0}),\n        selected_symbols=[\"SPY\", \"TLT\", \"GLD\"],\n    )\n    ctx2 = _ctx(\n        prices=pd.Series({\"SPY\": 100.0, \"TLT\": 50.0, \"GLD\": 200.0}),\n        selected_symbols=[\"SPY\", \"TLT\", \"GLD\"],\n    )\n    algo1 = SelectRandomly(n=2, seed=42)\n    algo2 = SelectRandomly(n=2, seed=42)\n    algo1(ctx1)\n    algo2(ctx2)\n    assert ctx1.selected_symbols == ctx2.selected_symbols\n\n\ndef test_select_randomly_n_exceeds_candidates():\n    ctx = _ctx(\n        prices=pd.Series({\"SPY\": 100.0}),\n        selected_symbols=[\"SPY\"],\n    )\n    algo = SelectRandomly(n=5, seed=1)\n    d = algo(ctx)\n    assert d.status == \"continue\"\n    assert ctx.selected_symbols == [\"SPY\"]\n\n\ndef test_select_randomly_no_candidates():\n    ctx = _ctx(\n        prices=pd.Series({\"SPY\": np.nan}),\n        selected_symbols=[],\n    )\n    algo = SelectRandomly(n=2, seed=1)\n    d = algo(ctx)\n    assert d.status == \"skip_day\"\n\n\n# ---------------------------------------------------------------------------\n# SelectActive\n# ---------------------------------------------------------------------------\n\ndef test_select_active_filters_dead():\n    ctx = _ctx(\n        prices=pd.Series({\"SPY\": 100.0, \"TLT\": 0.0, \"GLD\": np.nan}),\n        selected_symbols=[\"SPY\", \"TLT\", \"GLD\"],\n    )\n    d = SelectActive()(ctx)\n    assert d.status == \"continue\"\n    assert ctx.selected_symbols == [\"SPY\"]\n\n\ndef test_select_active_all_dead():\n    ctx = _ctx(\n        prices=pd.Series({\"SPY\": 0.0, \"TLT\": np.nan}),\n        selected_symbols=[\"SPY\", \"TLT\"],\n    )\n    d = SelectActive()(ctx)\n    assert d.status == \"skip_day\"\n\n\n# ---------------------------------------------------------------------------\n# WeighRandomly\n# ---------------------------------------------------------------------------\n\ndef test_weigh_randomly_sums_to_one():\n    ctx = _ctx(selected_symbols=[\"SPY\", \"TLT\", \"GLD\"])\n    WeighRandomly(seed=42)(ctx)\n    assert abs(sum(ctx.target_weights.values()) - 1.0) < 1e-10\n    assert all(w > 0 for w in ctx.target_weights.values())\n\n\ndef test_weigh_randomly_deterministic():\n    ctx1 = _ctx(selected_symbols=[\"SPY\", \"TLT\"])\n    ctx2 = _ctx(selected_symbols=[\"SPY\", \"TLT\"])\n    WeighRandomly(seed=99)(ctx1)\n    WeighRandomly(seed=99)(ctx2)\n    assert abs(ctx1.target_weights[\"SPY\"] - ctx2.target_weights[\"SPY\"]) < 1e-10\n\n\ndef test_weigh_randomly_empty():\n    ctx = _ctx(selected_symbols=[])\n    d = WeighRandomly(seed=1)(ctx)\n    assert d.status == \"skip_day\"\n\n\n# ---------------------------------------------------------------------------\n# WeighTarget\n# ---------------------------------------------------------------------------\n\ndef test_weigh_target_basic():\n    weights_df = pd.DataFrame(\n        {\"SPY\": [0.60, 0.70], \"TLT\": [0.40, 0.30]},\n        index=pd.to_datetime([\"2024-01-01\", \"2024-02-01\"]),\n    )\n    algo = WeighTarget(weights_df)\n    ctx = _ctx(\n        date=pd.Timestamp(\"2024-01-15\"),\n        selected_symbols=[\"SPY\", \"TLT\"],\n    )\n    d = algo(ctx)\n    assert d.status == \"continue\"\n    # Should pick Jan 1 row (most recent before Jan 15)\n    assert abs(ctx.target_weights[\"SPY\"] - 0.60) < 1e-10\n    assert abs(ctx.target_weights[\"TLT\"] - 0.40) < 1e-10\n\n\ndef test_weigh_target_uses_latest_row():\n    weights_df = pd.DataFrame(\n        {\"SPY\": [0.50, 0.80]},\n        index=pd.to_datetime([\"2024-01-01\", \"2024-02-01\"]),\n    )\n    algo = WeighTarget(weights_df)\n    ctx = _ctx(\n        date=pd.Timestamp(\"2024-03-01\"),\n        selected_symbols=[\"SPY\"],\n    )\n    algo(ctx)\n    assert abs(ctx.target_weights[\"SPY\"] - 1.0) < 1e-10  # normalized from 0.80\n\n\ndef test_weigh_target_no_data_before_date():\n    weights_df = pd.DataFrame(\n        {\"SPY\": [1.0]},\n        index=pd.to_datetime([\"2024-06-01\"]),\n    )\n    algo = WeighTarget(weights_df)\n    ctx = _ctx(\n        date=pd.Timestamp(\"2024-01-01\"),\n        selected_symbols=[\"SPY\"],\n    )\n    d = algo(ctx)\n    assert d.status == \"skip_day\"\n\n\ndef test_weigh_target_empty_selected():\n    weights_df = pd.DataFrame(\n        {\"SPY\": [1.0]},\n        index=pd.to_datetime([\"2024-01-01\"]),\n    )\n    ctx = _ctx(date=pd.Timestamp(\"2024-01-15\"), selected_symbols=[])\n    d = WeighTarget(weights_df)(ctx)\n    assert d.status == \"skip_day\"\n\n\n# ---------------------------------------------------------------------------\n# CloseDead\n# ---------------------------------------------------------------------------\n\ndef test_close_dead_removes_zero_price():\n    ctx = _ctx(\n        prices=pd.Series({\"SPY\": 100.0, \"TLT\": 0.0}),\n        positions={\"SPY\": 10.0, \"TLT\": 20.0},\n    )\n    CloseDead()(ctx)\n    assert \"SPY\" in ctx.positions\n    assert \"TLT\" not in ctx.positions\n\n\ndef test_close_dead_removes_nan_price():\n    ctx = _ctx(\n        prices=pd.Series({\"SPY\": 100.0, \"TLT\": np.nan}),\n        positions={\"SPY\": 10.0, \"TLT\": 20.0},\n    )\n    CloseDead()(ctx)\n    assert \"TLT\" not in ctx.positions\n\n\ndef test_close_dead_no_dead():\n    ctx = _ctx(\n        prices=pd.Series({\"SPY\": 100.0, \"TLT\": 50.0}),\n        positions={\"SPY\": 10.0, \"TLT\": 20.0},\n    )\n    d = CloseDead()(ctx)\n    assert d.status == \"continue\"\n    assert len(ctx.positions) == 2\n\n\ndef test_close_dead_missing_price():\n    ctx = _ctx(\n        prices=pd.Series({\"SPY\": 100.0}),\n        positions={\"SPY\": 10.0, \"XYZ\": 5.0},  # XYZ not in prices\n    )\n    CloseDead()(ctx)\n    assert \"XYZ\" not in ctx.positions\n\n\n# ---------------------------------------------------------------------------\n# ClosePositionsAfterDates\n# ---------------------------------------------------------------------------\n\ndef test_close_positions_after_dates():\n    algo = ClosePositionsAfterDates({\"TLT\": \"2024-02-01\"})\n    ctx = _ctx(\n        date=pd.Timestamp(\"2024-02-15\"),\n        positions={\"SPY\": 10.0, \"TLT\": 20.0},\n    )\n    d = algo(ctx)\n    assert \"TLT\" not in ctx.positions\n    assert \"SPY\" in ctx.positions\n    assert \"closed after date\" in d.message\n\n\ndef test_close_positions_before_date():\n    algo = ClosePositionsAfterDates({\"TLT\": \"2024-06-01\"})\n    ctx = _ctx(\n        date=pd.Timestamp(\"2024-02-15\"),\n        positions={\"SPY\": 10.0, \"TLT\": 20.0},\n    )\n    algo(ctx)\n    assert \"TLT\" in ctx.positions  # not yet\n\n\ndef test_close_positions_on_exact_date():\n    algo = ClosePositionsAfterDates({\"SPY\": \"2024-02-01\"})\n    ctx = _ctx(\n        date=pd.Timestamp(\"2024-02-01\"),\n        positions={\"SPY\": 10.0},\n    )\n    algo(ctx)\n    assert \"SPY\" not in ctx.positions\n\n\n# ---------------------------------------------------------------------------\n# Require\n# ---------------------------------------------------------------------------\n\ndef test_require_passes_when_inner_passes():\n    inner = RunDaily()  # always passes\n    algo = Require(inner)\n    d = algo(_ctx())\n    assert d.status == \"continue\"\n\n\ndef test_require_blocks_when_inner_skips():\n    inner = RunOnce()\n    inner._ran = True  # already ran → will skip\n    algo = Require(inner)\n    d = algo(_ctx())\n    assert d.status == \"skip_day\"\n\n\ndef test_require_reset():\n    inner = RunOnce()\n    inner._ran = True\n    algo = Require(inner)\n    algo.reset()\n    assert inner._ran is False\n\n\n# ---------------------------------------------------------------------------\n# benchmark_random\n# ---------------------------------------------------------------------------\n\ndef test_benchmark_random_basic():\n    prices = _daily_prices(symbols=(\"SPY\", \"TLT\"), days=30)\n    strategy_algos = [\n        RunMonthly(),\n        SelectThese([\"SPY\"]),\n        WeighSpecified({\"SPY\": 1.0}),\n        Rebalance(),\n    ]\n    result = benchmark_random(\n        prices=prices,\n        strategy_algos=strategy_algos,\n        n_random=10,\n        initial_capital=10_000.0,\n        seed=42,\n    )\n    assert isinstance(result, RandomBenchmarkResult)\n    assert len(result.random_returns) == 10\n    assert 0 <= result.percentile <= 100\n    assert result.mean_random != 0.0 or all(r == 0 for r in result.random_returns)\n\n\ndef test_benchmark_random_deterministic():\n    prices = _daily_prices(days=30)\n    algos = [RunMonthly(), SelectAll(), WeighEqually(), Rebalance()]\n    r1 = benchmark_random(prices, algos, n_random=5, seed=42)\n    r2 = benchmark_random(prices, algos, n_random=5, seed=42)\n    assert r1.random_returns == r2.random_returns\n    assert r1.percentile == r2.percentile\n\n\ndef test_benchmark_random_result_properties():\n    result = RandomBenchmarkResult(\n        strategy_return=0.10,\n        random_returns=[0.05, 0.08, 0.12, 0.03],\n        percentile=50.0,\n    )\n    assert abs(result.mean_random - np.mean([0.05, 0.08, 0.12, 0.03])) < 1e-10\n    assert abs(result.std_random - np.std([0.05, 0.08, 0.12, 0.03])) < 1e-10\n\n\n# ===========================================================================\n# INTEGRATION: Round 2 algos in full pipelines\n# ===========================================================================\n\n\ndef test_pipeline_or_run_if_out_of_bounds():\n    \"\"\"Or(RunQuarterly(), RunIfOutOfBounds(0.05)) pattern.\"\"\"\n    idx = pd.bdate_range(\"2024-01-02\", periods=5)\n    prices = pd.DataFrame({\"SPY\": [100.0] * 5}, index=idx)\n    oob = RunIfOutOfBounds(tolerance=0.05)\n    bt = AlgoPipelineBacktester(\n        prices=prices, initial_capital=1000.0,\n        algos=[\n            Or(RunQuarterly(), oob),\n            SelectThese([\"SPY\"]),\n            WeighSpecified({\"SPY\": 1.0}),\n            Rebalance(),\n        ],\n    )\n    bal = bt.run()\n    assert not bal.empty\n\n\ndef test_pipeline_close_dead_then_rebalance():\n    idx = pd.to_datetime([\"2024-01-02\", \"2024-02-01\"])\n    prices = pd.DataFrame({\"SPY\": [100.0, 100.0], \"TLT\": [50.0, 0.0]}, index=idx)\n    bt = AlgoPipelineBacktester(\n        prices=prices, initial_capital=10_000.0,\n        algos=[\n            RunMonthly(), CloseDead(),\n            SelectActive(), WeighEqually(), Rebalance(),\n        ],\n    )\n    bal = bt.run()\n    # On Feb 1, TLT is dead → should not hold any TLT\n    if \"TLT qty\" in bal.columns:\n        assert bal.loc[pd.Timestamp(\"2024-02-01\"), \"TLT qty\"] == 0 or \\\n               pd.isna(bal.loc[pd.Timestamp(\"2024-02-01\"), \"TLT qty\"])\n\n\ndef test_pipeline_scale_weights_deleverage():\n    idx = pd.to_datetime([\"2024-01-02\"])\n    prices = pd.DataFrame({\"SPY\": [100.0]}, index=idx)\n    bt = AlgoPipelineBacktester(\n        prices=prices, initial_capital=10_000.0,\n        algos=[\n            SelectThese([\"SPY\"]),\n            WeighSpecified({\"SPY\": 1.0}),\n            ScaleWeights(scale=0.5),\n            Rebalance(),\n        ],\n    )\n    bal = bt.run()\n    # 50% of 10000 = 5000, floor(5000/100) = 50 shares\n    assert bal.iloc[0][\"SPY qty\"] == 50\n\n\ndef test_pipeline_select_randomly_weigh_randomly():\n    prices = _daily_prices(symbols=(\"SPY\", \"TLT\", \"GLD\"), days=30)\n    bt = AlgoPipelineBacktester(\n        prices=prices, initial_capital=10_000.0,\n        algos=[\n            RunMonthly(),\n            SelectRandomly(n=2, seed=42),\n            WeighRandomly(seed=42),\n            Rebalance(),\n        ],\n    )\n    bal = bt.run()\n    assert not bal.empty\n\n\ndef test_pipeline_weigh_target_from_df():\n    weights_df = pd.DataFrame(\n        {\"SPY\": [0.60, 0.40], \"TLT\": [0.40, 0.60]},\n        index=pd.to_datetime([\"2024-01-01\", \"2024-02-01\"]),\n    )\n    idx = pd.to_datetime([\"2024-01-15\", \"2024-02-15\"])\n    prices = pd.DataFrame({\"SPY\": [100.0, 100.0], \"TLT\": [50.0, 50.0]}, index=idx)\n    bt = AlgoPipelineBacktester(\n        prices=prices, initial_capital=10_000.0,\n        algos=[\n            SelectThese([\"SPY\", \"TLT\"]),\n            WeighTarget(weights_df),\n            Rebalance(),\n        ],\n    )\n    bal = bt.run()\n    # Jan 15 → uses Jan 1 weights (60/40)\n    spy_val = bal.iloc[0][\"SPY qty\"] * 100.0\n    total = bal.iloc[0][\"total capital\"]\n    assert spy_val / total > 0.55  # roughly 60%\n\n\n# ---------------------------------------------------------------------------\n# SelectRegex\n# ---------------------------------------------------------------------------\n\ndef test_select_regex_matches():\n    ctx = _ctx()\n    ctx.prices = pd.Series({\"SPY\": 100, \"SPXL\": 50, \"QQQ\": 200, \"IWM\": 80})\n    algo = SelectRegex(r\"^SP\")\n    algo(ctx)\n    assert sorted(ctx.selected_symbols) == [\"SPXL\", \"SPY\"]\n\n\ndef test_select_regex_no_match_skips():\n    ctx = _ctx()\n    algo = SelectRegex(r\"^ZZZZZ\")\n    decision = algo(ctx)\n    assert decision.status == \"skip_day\"\n\n\ndef test_select_regex_case_insensitive():\n    ctx = _ctx()\n    ctx.prices = pd.Series({\"spy\": 100, \"SPY\": 100, \"QQQ\": 200})\n    algo = SelectRegex(r\"(?i)spy\")\n    algo(ctx)\n    assert set(ctx.selected_symbols) == {\"spy\", \"SPY\"}\n\n\ndef test_select_regex_in_pipeline():\n    prices = pd.DataFrame(\n        {\"SPY\": [100, 102], \"SPXL\": [50, 51], \"QQQ\": [200, 202]},\n        index=pd.date_range(\"2024-01-01\", periods=2, freq=\"B\"),\n    )\n    bt = AlgoPipelineBacktester(\n        prices=prices,\n        initial_capital=1000.0,\n        algos=[RunDaily(), SelectRegex(r\"^SP\"), WeighEqually(), Rebalance()],\n    )\n    bal = bt.run()\n    # Should only hold SPY and SPXL, not QQQ\n    assert bal.iloc[-1].get(\"QQQ qty\", 0) == 0\n    assert bal.iloc[-1][\"SPY qty\"] > 0\n    assert bal.iloc[-1][\"SPXL qty\"] > 0\n\n\n# ===========================================================================\n# HEDGE RISKS\n# ===========================================================================\n\n\ndef test_hedge_risks_adjusts_weights():\n    prices = _daily_prices(symbols=(\"SPY\", \"TLT\"), days=30)\n    ctx = _ctx(\n        prices=prices.iloc[-1],\n        date=prices.index[-1],\n        selected_symbols=[\"SPY\", \"TLT\"],\n        target_weights={\"SPY\": 0.80},\n        price_history=prices,\n    )\n    algo = HedgeRisks(target_delta=0.0, hedge_symbols=[\"TLT\"])\n    d = algo(ctx)\n    assert d.status == \"continue\"\n    # TLT should now have a hedge weight assigned\n    assert \"TLT\" in ctx.target_weights\n\n\ndef test_hedge_risks_no_target_weights():\n    ctx = _ctx(target_weights={})\n    d = HedgeRisks()(ctx)\n    assert d.status == \"skip_day\"\n\n\ndef test_hedge_risks_no_history():\n    ctx = _ctx(\n        target_weights={\"SPY\": 1.0},\n        selected_symbols=[\"TLT\"],\n        prices=pd.Series({\"SPY\": 100.0, \"TLT\": 50.0}),\n    )\n    d = HedgeRisks()(ctx)\n    assert d.status == \"skip_day\"\n\n\ndef test_hedge_risks_in_pipeline():\n    prices = _daily_prices(symbols=(\"SPY\", \"TLT\"), days=30)\n    bt = AlgoPipelineBacktester(\n        prices=prices, initial_capital=10_000.0,\n        algos=[\n            RunMonthly(),\n            SelectThese([\"SPY\", \"TLT\"]),\n            WeighSpecified({\"SPY\": 0.80, \"TLT\": 0.20}),\n            HedgeRisks(target_delta=0.0, hedge_symbols=[\"TLT\"]),\n            Rebalance(),\n        ],\n    )\n    bal = bt.run()\n    assert not bal.empty\n\n\n# ===========================================================================\n# MARGIN\n# ===========================================================================\n\n\ndef test_margin_scales_weights():\n    ctx = _ctx(\n        target_weights={\"SPY\": 0.50},\n        total_capital=10000.0,\n        cash=5000.0,\n    )\n    algo = Margin(leverage=2.0)\n    d = algo(ctx)\n    assert d.status == \"continue\"\n    assert abs(ctx.target_weights[\"SPY\"] - 1.0) < 1e-10\n\n\ndef test_margin_charges_interest():\n    ctx = _ctx(\n        total_capital=10000.0,\n        cash=3000.0,  # invested=7000, borrowed=max(0, 7000-3000)=4000\n        positions={\"SPY\": 70.0},\n        prices=pd.Series({\"SPY\": 100.0}),\n    )\n    algo = Margin(leverage=1.0, interest_rate=0.05)\n    original_capital = ctx.total_capital\n    algo(ctx)\n    # Should have charged interest: 0.05/252 * 4000 ≈ 0.79\n    assert ctx.total_capital < original_capital\n\n\ndef test_margin_reset():\n    algo = Margin(leverage=2.0)\n    algo._borrowed = 5000.0\n    algo.reset()\n    assert algo._borrowed == 0.0\n\n\ndef test_margin_call_stops():\n    # equity = cash + stock_value = -400 + 500 = 100\n    # exposure = stock_value = 500\n    # equity/exposure = 100/500 = 0.20 < 0.25 → margin call\n    ctx = _ctx(\n        total_capital=100.0,\n        cash=-400.0,\n        positions={\"SPY\": 5.0},\n        prices=pd.Series({\"SPY\": 100.0}),\n    )\n    algo = Margin(leverage=2.0, maintenance_pct=0.25)\n    d = algo(ctx)\n    assert d.status == \"stop\"\n\n\n# ===========================================================================\n# COUPON PAYING POSITION\n# ===========================================================================\n\n\ndef test_coupon_pays_on_schedule():\n    algo = CouponPayingPosition(coupon_amount=500.0, frequency=\"monthly\")\n    ctx1 = _ctx(date=pd.Timestamp(\"2024-01-15\"), cash=10000.0, total_capital=10000.0)\n    d1 = algo(ctx1)\n    assert ctx1.cash == 10500.0\n    assert \"coupon paid\" in d1.message\n\n    # Same month → no second coupon\n    ctx2 = _ctx(date=pd.Timestamp(\"2024-01-20\"), cash=10000.0, total_capital=10000.0)\n    d2 = algo(ctx2)\n    assert ctx2.cash == 10000.0\n\n\ndef test_coupon_semi_annual_spacing():\n    algo = CouponPayingPosition(coupon_amount=250.0, frequency=\"semi-annual\")\n    ctx1 = _ctx(date=pd.Timestamp(\"2024-01-15\"), cash=10000.0, total_capital=10000.0)\n    algo(ctx1)\n    assert ctx1.cash == 10250.0  # first coupon\n\n    # 3 months later → too early\n    ctx2 = _ctx(date=pd.Timestamp(\"2024-04-15\"), cash=10000.0, total_capital=10000.0)\n    algo(ctx2)\n    assert ctx2.cash == 10000.0\n\n    # 6 months later → pays\n    ctx3 = _ctx(date=pd.Timestamp(\"2024-07-15\"), cash=10000.0, total_capital=10000.0)\n    algo(ctx3)\n    assert ctx3.cash == 10250.0\n\n\ndef test_coupon_stops_at_maturity():\n    algo = CouponPayingPosition(\n        coupon_amount=100.0, frequency=\"monthly\", maturity_date=\"2024-03-01\",\n    )\n    ctx = _ctx(date=pd.Timestamp(\"2024-03-15\"), cash=5000.0, total_capital=5000.0)\n    d = algo(ctx)\n    assert d.status == \"stop\"\n    assert ctx.cash == 5100.0  # final coupon paid\n\n\ndef test_coupon_before_start_date():\n    algo = CouponPayingPosition(\n        coupon_amount=100.0, frequency=\"monthly\", start_date=\"2024-06-01\",\n    )\n    ctx = _ctx(date=pd.Timestamp(\"2024-01-15\"), cash=5000.0, total_capital=5000.0)\n    algo(ctx)\n    assert ctx.cash == 5000.0  # no payment before start\n\n\ndef test_coupon_invalid_frequency():\n    import pytest\n    with pytest.raises(ValueError, match=\"frequency must be one of\"):\n        CouponPayingPosition(coupon_amount=100.0, frequency=\"bi-weekly\")\n\n\ndef test_coupon_reset():\n    algo = CouponPayingPosition(coupon_amount=100.0, frequency=\"monthly\")\n    algo._last_coupon_month = (2024, 5)\n    algo.reset()\n    assert algo._last_coupon_month is None\n\n\n# ===========================================================================\n# REPLAY TRANSACTIONS\n# ===========================================================================\n\n\ndef test_replay_buys_on_matching_date():\n    blotter = pd.DataFrame({\n        \"date\": [\"2024-01-02\", \"2024-01-02\"],\n        \"symbol\": [\"SPY\", \"TLT\"],\n        \"quantity\": [10, 20],\n    })\n    algo = ReplayTransactions(blotter)\n    ctx = _ctx(\n        date=pd.Timestamp(\"2024-01-02\"),\n        prices=pd.Series({\"SPY\": 100.0, \"TLT\": 50.0}),\n        cash=5000.0,\n        positions={},\n    )\n    d = algo(ctx)\n    assert d.status == \"continue\"\n    assert ctx.positions[\"SPY\"] == 10\n    assert ctx.positions[\"TLT\"] == 20\n    # cash = 5000 - 10*100 - 20*50 = 5000 - 1000 - 1000 = 3000\n    assert abs(ctx.cash - 3000.0) < 1e-10\n\n\ndef test_replay_sells():\n    blotter = pd.DataFrame({\n        \"date\": [\"2024-01-02\"],\n        \"symbol\": [\"SPY\"],\n        \"quantity\": [-5],\n    })\n    algo = ReplayTransactions(blotter)\n    ctx = _ctx(\n        date=pd.Timestamp(\"2024-01-02\"),\n        prices=pd.Series({\"SPY\": 100.0}),\n        cash=0.0,\n        positions={\"SPY\": 10.0},\n    )\n    algo(ctx)\n    assert ctx.positions[\"SPY\"] == 5.0\n    assert abs(ctx.cash - 500.0) < 1e-10  # received 5*100\n\n\ndef test_replay_no_trades_on_date():\n    blotter = pd.DataFrame({\n        \"date\": [\"2024-02-01\"],\n        \"symbol\": [\"SPY\"],\n        \"quantity\": [10],\n    })\n    algo = ReplayTransactions(blotter)\n    ctx = _ctx(date=pd.Timestamp(\"2024-01-15\"), cash=5000.0, positions={})\n    d = algo(ctx)\n    assert d.status == \"continue\"\n    assert ctx.positions == {}\n    assert ctx.cash == 5000.0\n\n\ndef test_replay_closes_position_to_zero():\n    blotter = pd.DataFrame({\n        \"date\": [\"2024-01-02\"],\n        \"symbol\": [\"SPY\"],\n        \"quantity\": [-10],\n    })\n    algo = ReplayTransactions(blotter)\n    ctx = _ctx(\n        date=pd.Timestamp(\"2024-01-02\"),\n        prices=pd.Series({\"SPY\": 100.0}),\n        cash=0.0,\n        positions={\"SPY\": 10.0},\n    )\n    algo(ctx)\n    assert \"SPY\" not in ctx.positions  # fully closed\n\n\ndef test_replay_missing_columns_raises():\n    import pytest\n    bad_blotter = pd.DataFrame({\"date\": [\"2024-01-02\"], \"symbol\": [\"SPY\"]})\n    with pytest.raises(ValueError, match=\"missing columns\"):\n        ReplayTransactions(bad_blotter)\n\n\ndef test_replay_in_pipeline():\n    blotter = pd.DataFrame({\n        \"date\": [\"2024-01-02\"],\n        \"symbol\": [\"SPY\"],\n        \"quantity\": [5],\n    })\n    idx = pd.to_datetime([\"2024-01-02\", \"2024-01-03\"])\n    prices = pd.DataFrame({\"SPY\": [100.0, 105.0]}, index=idx)\n    bt = AlgoPipelineBacktester(\n        prices=prices, initial_capital=1000.0,\n        algos=[RunDaily(), ReplayTransactions(blotter)],\n    )\n    bal = bt.run()\n    assert bal.loc[pd.Timestamp(\"2024-01-02\"), \"SPY qty\"] == 5\n\n\n# ===========================================================================\n# set_date_range on AlgoPipelineBacktester\n# ===========================================================================\n\n\ndef test_set_date_range_returns_stats():\n    prices = _daily_prices(days=60)\n    bt = AlgoPipelineBacktester(\n        prices=prices, initial_capital=10_000.0,\n        algos=[RunMonthly(), SelectAll(), WeighEqually(), Rebalance()],\n    )\n    bt.run()\n    stats = bt.set_date_range(start=\"2024-02-01\")\n    assert stats.total_return != 0.0 or stats.total_trades == 0\n    assert hasattr(stats, \"sharpe_ratio\")\n"
  },
  {
    "path": "tests/engine/test_portfolio_integration.py",
    "content": "\"\"\"Tests verifying Portfolio dataclass is kept in sync with legacy MultiIndex inventory.\"\"\"\n\nimport os\nimport pytest\nimport numpy as np\n\nfrom options_portfolio_backtester.engine.engine import BacktestEngine\nfrom options_portfolio_backtester.execution.cost_model import NoCosts\nfrom options_portfolio_backtester.portfolio.portfolio import Portfolio\nfrom options_portfolio_backtester.portfolio.position import OptionPosition\n\nfrom options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData\nfrom options_portfolio_backtester.strategy.strategy import Strategy\nfrom options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\nfrom options_portfolio_backtester.core.types import Stock, OptionType as Type, Direction\n\nTEST_DIR = os.path.join(os.path.dirname(__file__), \"..\", \"test_data\")\nSTOCKS_FILE = os.path.join(TEST_DIR, \"ivy_5assets_data.csv\")\nOPTIONS_FILE = os.path.join(TEST_DIR, \"options_data.csv\")\n\n\ndef _ivy_stocks():\n    return [Stock(\"VTI\", 0.2), Stock(\"VEU\", 0.2), Stock(\"BND\", 0.2),\n            Stock(\"VNQ\", 0.2), Stock(\"DBC\", 0.2)]\n\n\ndef _stocks_data():\n    data = TiingoData(STOCKS_FILE)\n    data._data[\"adjClose\"] = 10\n    return data\n\n\ndef _options_data():\n    data = HistoricalOptionsData(OPTIONS_FILE)\n    data._data.at[2, \"ask\"] = 1\n    data._data.at[2, \"bid\"] = 0.5\n    data._data.at[51, \"ask\"] = 1.5\n    data._data.at[50, \"bid\"] = 0.5\n    data._data.at[130, \"bid\"] = 0.5\n    data._data.at[131, \"bid\"] = 1.5\n    data._data.at[206, \"bid\"] = 0.5\n    data._data.at[207, \"bid\"] = 1.5\n    return data\n\n\ndef _buy_strategy(schema):\n    strat = Strategy(schema)\n    leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\n    leg.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n    leg.exit_filter = schema.dte <= 30\n    strat.add_legs([leg])\n    return strat\n\n\ndef _run_engine():\n    stocks = _ivy_stocks()\n    stocks_data = _stocks_data()\n    options_data = _options_data()\n    schema = options_data.schema\n\n    engine = BacktestEngine(\n        {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n        cost_model=NoCosts(),\n    )\n    engine.stocks = stocks\n    engine.stocks_data = stocks_data\n    engine.options_data = options_data\n    engine.options_strategy = _buy_strategy(schema)\n    engine.run(rebalance_freq=1)\n    return engine\n\n\nclass TestPortfolioIntegration:\n    \"\"\"Verify _portfolio dataclass is maintained alongside legacy DataFrames.\"\"\"\n\n    @pytest.fixture(autouse=True)\n    def setup(self):\n        self.engine = _run_engine()\n\n    def test_portfolio_exists(self):\n        assert hasattr(self.engine, '_portfolio')\n        assert isinstance(self.engine._portfolio, Portfolio)\n\n    def test_portfolio_position_count_matches_inventory(self):\n        \"\"\"After backtest, portfolio positions should match remaining inventory rows.\"\"\"\n        inv_count = len(self.engine._options_inventory)\n        port_count = len(self.engine._portfolio.option_positions)\n        assert inv_count == port_count, (\n            f\"Inventory has {inv_count} rows but Portfolio has {port_count} positions\"\n        )\n\n    def test_positions_have_correct_legs(self):\n        \"\"\"Each position should have legs matching strategy leg names.\"\"\"\n        for pid, pos in self.engine._portfolio.option_positions.items():\n            assert isinstance(pos, OptionPosition)\n            assert len(pos.legs) > 0\n            for leg_name in pos.legs:\n                assert leg_name.startswith(\"leg_\")\n\n    def test_trade_log_not_empty(self):\n        \"\"\"Backtest should produce trades.\"\"\"\n        assert not self.engine.trade_log.empty\n\n    def test_portfolio_contracts_match_inventory(self):\n        \"\"\"Portfolio contract IDs should match inventory contract IDs.\"\"\"\n        for idx, inv_row in self.engine._options_inventory.iterrows():\n            if idx in self.engine._portfolio.option_positions:\n                pos = self.engine._portfolio.option_positions[idx]\n                for leg in self.engine._options_strategy.legs:\n                    inv_contract = inv_row[leg.name][\"contract\"]\n                    pos_contract = pos.legs[leg.name].contract_id\n                    assert inv_contract == pos_contract, (\n                        f\"Contract mismatch at {idx}/{leg.name}: \"\n                        f\"inventory={inv_contract}, portfolio={pos_contract}\"\n                    )\n"
  },
  {
    "path": "tests/engine/test_regression_snapshots.py",
    "content": "\"\"\"Regression snapshot tests — lock backtest outputs against golden values.\n\nRun a full backtest with fixed data + deterministic config, assert against\nhardcoded values.  Any change in output = regression.\n\nUses NearestDelta selector to force the Python path for determinism\n(avoids Rust dispatch which may differ across platforms).\n\"\"\"\n\nimport math\nimport os\n\nimport pytest\n\nfrom options_portfolio_backtester.engine.engine import BacktestEngine\nfrom options_portfolio_backtester.execution.cost_model import NoCosts, PerContractCommission\nfrom options_portfolio_backtester.execution.signal_selector import NearestDelta\nfrom options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData\nfrom options_portfolio_backtester.strategy.strategy import Strategy\nfrom options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\nfrom options_portfolio_backtester.core.types import Stock, OptionType as Type, Direction\n\nTEST_DIR = os.path.join(os.path.dirname(__file__), \"..\", \"test_data\")\nSTOCKS_FILE = os.path.join(TEST_DIR, \"ivy_5assets_data.csv\")\nOPTIONS_FILE = os.path.join(TEST_DIR, \"options_data.csv\")\n\n\n# ---------------------------------------------------------------------------\n# Shared helpers (mirrors test_engine.py pattern)\n# ---------------------------------------------------------------------------\n\ndef _ivy_stocks():\n    return [Stock(\"VTI\", 0.2), Stock(\"VEU\", 0.2), Stock(\"BND\", 0.2),\n            Stock(\"VNQ\", 0.2), Stock(\"DBC\", 0.2)]\n\n\ndef _stocks_data():\n    data = TiingoData(STOCKS_FILE)\n    data._data[\"adjClose\"] = 10\n    return data\n\n\ndef _options_data():\n    data = HistoricalOptionsData(OPTIONS_FILE)\n    data._data.at[2, \"ask\"] = 1\n    data._data.at[2, \"bid\"] = 0.5\n    data._data.at[51, \"ask\"] = 1.5\n    data._data.at[50, \"bid\"] = 0.5\n    data._data.at[130, \"bid\"] = 0.5\n    data._data.at[131, \"bid\"] = 1.5\n    data._data.at[206, \"bid\"] = 0.5\n    data._data.at[207, \"bid\"] = 1.5\n    return data\n\n\ndef _build_strategy(schema, direction=Direction.BUY):\n    strat = Strategy(schema)\n    leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=direction)\n    leg.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n    leg.exit_filter = schema.dte <= 30\n    strat.add_legs([leg])\n    return strat\n\n\ndef _run(cost_model=None, direction=Direction.BUY, monthly=False):\n    stocks = _ivy_stocks()\n    stocks_data = _stocks_data()\n    options_data = _options_data()\n    schema = options_data.schema\n\n    engine = BacktestEngine(\n        {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n        cost_model=cost_model or NoCosts(),\n        signal_selector=NearestDelta(target_delta=-0.30),\n    )\n    engine.stocks = stocks\n    engine.stocks_data = stocks_data\n    engine.options_data = options_data\n    engine.options_strategy = _build_strategy(schema, direction=direction)\n    engine.run(rebalance_freq=1, monthly=monthly)\n    return engine\n\n\n# ---------------------------------------------------------------------------\n# Golden values captured from deterministic runs\n# ---------------------------------------------------------------------------\n\nclass TestSnapshotBuyPutNoCosts:\n    \"\"\"Buy-put backtest with NoCosts, daily rebalance.\"\"\"\n\n    @pytest.fixture(autouse=True)\n    def setup(self):\n        self.engine = _run()\n\n    def test_final_capital(self):\n        final = self.engine.balance[\"total capital\"].iloc[-1]\n        assert abs(final - 948325.0) < 0.01, f\"Regression: final_capital={final}\"\n\n    def test_trade_count(self):\n        n = len(self.engine.trade_log)\n        assert n == 2, f\"Regression: trade_count={n}\"\n\n    def test_balance_rows(self):\n        n = len(self.engine.balance)\n        assert n == 61, f\"Regression: balance_rows={n}\"\n\n    def test_total_return(self):\n        bal = self.engine.balance[\"total capital\"]\n        ret = (bal.iloc[-1] - bal.iloc[0]) / bal.iloc[0]\n        assert abs(ret - (-0.051675)) < 1e-4, f\"Regression: total_return={ret}\"\n\n    def test_max_drawdown(self):\n        bal = self.engine.balance[\"total capital\"]\n        running_max = bal.cummax()\n        dd = (running_max - bal) / running_max\n        max_dd = dd.max()\n        assert abs(max_dd - 0.051675) < 1e-4, f\"Regression: max_drawdown={max_dd}\"\n\n\nclass TestSnapshotBuyPutWithCommission:\n    \"\"\"Buy-put with PerContractCommission — costs must reduce final capital.\"\"\"\n\n    @pytest.fixture(autouse=True)\n    def setup(self):\n        self.engine_no_cost = _run()\n        self.engine_cost = _run(cost_model=PerContractCommission(0.65))\n\n    def test_commission_reduces_capital(self):\n        no_cost_final = self.engine_no_cost.balance[\"total capital\"].iloc[-1]\n        cost_final = self.engine_cost.balance[\"total capital\"].iloc[-1]\n        assert cost_final < no_cost_final\n\n    def test_final_capital(self):\n        final = self.engine_cost.balance[\"total capital\"].iloc[-1]\n        assert abs(final - 946336.9) < 0.01, f\"Regression: final_capital={final}\"\n\n\nclass TestSnapshotSellPut:\n    \"\"\"Sell-put (reversed direction) — verifies direction wiring.\"\"\"\n\n    @pytest.fixture(autouse=True)\n    def setup(self):\n        self.engine = _run(direction=Direction.SELL)\n\n    def test_final_capital(self):\n        final = self.engine.balance[\"total capital\"].iloc[-1]\n        assert abs(final - 869300.0) < 1.0, f\"Regression: final_capital={final}\"\n\n    def test_trade_count(self):\n        n = len(self.engine.trade_log)\n        assert n == 2, f\"Regression: trade_count={n}\"\n\n    def test_sell_vs_buy_differ(self):\n        buy_engine = _run(direction=Direction.BUY)\n        buy_final = buy_engine.balance[\"total capital\"].iloc[-1]\n        sell_final = self.engine.balance[\"total capital\"].iloc[-1]\n        assert buy_final != sell_final\n\n\nclass TestSnapshotMonthlyRebalance:\n    \"\"\"Monthly rebalance — fewer balance rows than daily.\"\"\"\n\n    @pytest.fixture(autouse=True)\n    def setup(self):\n        self.engine_daily = _run(monthly=False)\n        self.engine_monthly = _run(monthly=True)\n\n    def test_fewer_balance_rows(self):\n        daily_rows = len(self.engine_daily.balance)\n        monthly_rows = len(self.engine_monthly.balance)\n        assert monthly_rows <= daily_rows\n\n    def test_final_capital(self):\n        final = self.engine_monthly.balance[\"total capital\"].iloc[-1]\n        assert abs(final - 948325.0) < 0.01, f\"Regression: final_capital={final}\"\n\n    def test_balance_rows(self):\n        n = len(self.engine_monthly.balance)\n        assert n == 61, f\"Regression: balance_rows={n}\"\n"
  },
  {
    "path": "tests/engine/test_risk_wiring.py",
    "content": "\"\"\"Tests that RiskManager is actually wired into the engine.\"\"\"\n\nimport os\nimport pytest\nimport numpy as np\n\nfrom options_portfolio_backtester.engine.engine import BacktestEngine\nfrom options_portfolio_backtester.execution.cost_model import NoCosts\nfrom options_portfolio_backtester.portfolio.risk import RiskManager, MaxDelta, MaxDrawdown\n\nfrom options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData\nfrom options_portfolio_backtester.strategy.strategy import Strategy\nfrom options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\nfrom options_portfolio_backtester.core.types import Stock, OptionType as Type, Direction\n\nTEST_DIR = os.path.join(os.path.dirname(__file__), \"..\", \"test_data\")\nSTOCKS_FILE = os.path.join(TEST_DIR, \"ivy_5assets_data.csv\")\nOPTIONS_FILE = os.path.join(TEST_DIR, \"options_data.csv\")\n\n\ndef _ivy_stocks():\n    return [Stock(\"VTI\", 0.2), Stock(\"VEU\", 0.2), Stock(\"BND\", 0.2),\n            Stock(\"VNQ\", 0.2), Stock(\"DBC\", 0.2)]\n\n\ndef _stocks_data():\n    data = TiingoData(STOCKS_FILE)\n    data._data[\"adjClose\"] = 10\n    return data\n\n\ndef _options_data():\n    data = HistoricalOptionsData(OPTIONS_FILE)\n    data._data.at[2, \"ask\"] = 1\n    data._data.at[2, \"bid\"] = 0.5\n    data._data.at[51, \"ask\"] = 1.5\n    data._data.at[50, \"bid\"] = 0.5\n    data._data.at[130, \"bid\"] = 0.5\n    data._data.at[131, \"bid\"] = 1.5\n    data._data.at[206, \"bid\"] = 0.5\n    data._data.at[207, \"bid\"] = 1.5\n    return data\n\n\ndef _buy_strategy(schema):\n    strat = Strategy(schema)\n    leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\n    leg.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n    leg.exit_filter = schema.dte <= 30\n    strat.add_legs([leg])\n    return strat\n\n\ndef _run_engine(risk_manager=None):\n    stocks = _ivy_stocks()\n    stocks_data = _stocks_data()\n    options_data = _options_data()\n    schema = options_data.schema\n\n    engine = BacktestEngine(\n        {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n        cost_model=NoCosts(),\n        risk_manager=risk_manager or RiskManager(),\n    )\n    engine.stocks = stocks\n    engine.stocks_data = stocks_data\n    engine.options_data = options_data\n    engine.options_strategy = _buy_strategy(schema)\n    engine.run(rebalance_freq=1)\n    return engine\n\n\nclass TestRiskManagerWiring:\n    \"\"\"Verify the engine actually calls the risk manager.\"\"\"\n\n    def test_no_constraints_allows_all(self):\n        engine = _run_engine(RiskManager())\n        assert not engine.trade_log.empty\n\n    def test_max_delta_blocks_entries(self):\n        \"\"\"Tiny delta limit should block all entries.\"\"\"\n        no_risk = _run_engine(RiskManager())\n        tight_risk = _run_engine(RiskManager([MaxDelta(limit=0.0001)]))\n\n        no_risk_trades = len(no_risk.trade_log)\n        tight_trades = len(tight_risk.trade_log)\n        assert tight_trades < no_risk_trades, (\n            f\"MaxDelta should block entries: got {tight_trades} vs {no_risk_trades}\"\n        )\n\n    def test_max_drawdown_blocks_during_crash(self):\n        \"\"\"Very tight drawdown limit should block entries once any loss occurs.\"\"\"\n        no_risk = _run_engine(RiskManager())\n        tight_dd = _run_engine(RiskManager([MaxDrawdown(max_dd_pct=0.0001)]))\n\n        no_risk_trades = len(no_risk.trade_log)\n        tight_trades = len(tight_dd.trade_log)\n        # With 0.01% max drawdown, most entries after the first should be blocked\n        assert tight_trades <= no_risk_trades\n\n    def test_risk_manager_preserves_capital(self):\n        \"\"\"Blocked entries should leave cash unchanged (budget stays as cash).\"\"\"\n        blocked = _run_engine(RiskManager([MaxDelta(limit=0.0001)]))\n        # If all entries are blocked, the final capital should be close to initial\n        # minus stock movements\n        assert blocked.balance is not None\n"
  },
  {
    "path": "tests/engine/test_rust_parity.py",
    "content": "\"\"\"Rust vs Python numerical parity: run same strategy both paths, compare values.\n\nWhen the Rust extension is available, the engine auto-dispatches to Rust for\ndefault configs. These tests force both paths and compare results.\n\"\"\"\n\nimport os\nimport math\nimport numpy as np\nimport pytest\n\nfrom options_portfolio_backtester.engine.engine import BacktestEngine\ntry:\n    from options_portfolio_backtester import _ob_rust  # noqa: F401\n    _RUST_OK = True\nexcept ImportError:\n    _RUST_OK = False\nfrom options_portfolio_backtester.execution.cost_model import NoCosts, PerContractCommission\nfrom options_portfolio_backtester.execution.signal_selector import FirstMatch, NearestDelta\nfrom options_portfolio_backtester.portfolio.risk import RiskManager\n\nfrom options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData\nfrom options_portfolio_backtester.strategy.strategy import Strategy\nfrom options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\nfrom options_portfolio_backtester.core.types import Stock, OptionType as Type, Direction\n\nTEST_DIR = os.path.join(os.path.dirname(__file__), \"..\", \"test_data\")\nSTOCKS_FILE = os.path.join(TEST_DIR, \"ivy_5assets_data.csv\")\nOPTIONS_FILE = os.path.join(TEST_DIR, \"options_data.csv\")\n\n\ndef _ivy_stocks():\n    return [Stock(\"VTI\", 0.2), Stock(\"VEU\", 0.2), Stock(\"BND\", 0.2),\n            Stock(\"VNQ\", 0.2), Stock(\"DBC\", 0.2)]\n\n\ndef _stocks_data():\n    data = TiingoData(STOCKS_FILE)\n    data._data[\"adjClose\"] = 10\n    return data\n\n\ndef _options_data():\n    data = HistoricalOptionsData(OPTIONS_FILE)\n    data._data.at[2, \"ask\"] = 1\n    data._data.at[2, \"bid\"] = 0.5\n    data._data.at[51, \"ask\"] = 1.5\n    data._data.at[50, \"bid\"] = 0.5\n    data._data.at[130, \"bid\"] = 0.5\n    data._data.at[131, \"bid\"] = 1.5\n    data._data.at[206, \"bid\"] = 0.5\n    data._data.at[207, \"bid\"] = 1.5\n    return data\n\n\ndef _buy_strategy(schema):\n    strat = Strategy(schema)\n    leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\n    leg.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n    leg.exit_filter = schema.dte <= 30\n    strat.add_legs([leg])\n    return strat\n\n\ndef _make_engine():\n    \"\"\"Create engine with default config (will use Rust if available).\"\"\"\n    engine = BacktestEngine(\n        {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n        cost_model=NoCosts(),\n    )\n    engine.stocks = _ivy_stocks()\n    engine.stocks_data = _stocks_data()\n    engine.options_data = _options_data()\n    engine.options_strategy = _buy_strategy(engine.options_data.schema)\n    return engine\n\n\ndef _run_python_path():\n    \"\"\"Run engine with options_budget_pct equivalent to 3% allocation.\"\"\"\n    engine = BacktestEngine(\n        {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n        cost_model=NoCosts(),\n    )\n    engine.options_budget_pct = 0.03\n    engine.stocks = _ivy_stocks()\n    engine.stocks_data = _stocks_data()\n    engine.options_data = _options_data()\n    engine.options_strategy = _buy_strategy(engine.options_data.schema)\n    engine.run(rebalance_freq=1)\n    return engine\n\n\ndef _run_rust_path():\n    \"\"\"Run engine with default config so Rust dispatch kicks in.\"\"\"\n    engine = _make_engine()\n    engine.run(rebalance_freq=1)\n    return engine\n\n\n@pytest.mark.skipif(not _RUST_OK, reason=\"Rust extension not installed\")\nclass TestRustVsPythonParity:\n    \"\"\"Numerical parity: Rust auto-dispatch must match original Backtest regression values.\n\n    The TestEngineMatchesOriginal in test_engine.py already asserts that with Rust\n    dispatch active, the engine produces identical results to the original Backtest.\n    Here we verify additional properties of the Rust output.\n    \"\"\"\n\n    @pytest.fixture(autouse=True)\n    def setup(self):\n        self.rs = _run_rust_path()\n\n    def test_trade_log_shape(self):\n        assert self.rs.trade_log.shape == (2, 10)\n\n    def test_regression_costs(self):\n        \"\"\"Known regression values — positions persist across rebalances.\"\"\"\n        tol = 0.0001\n        costs = self.rs.trade_log[\"totals\"][\"cost\"].values\n        assert np.allclose(costs, [100, 150], rtol=tol)\n\n    def test_regression_qtys(self):\n        tol = 0.0001\n        qtys = self.rs.trade_log[\"totals\"][\"qty\"].values\n        assert np.allclose(qtys, [300, 97], rtol=tol)\n\n    def test_final_capital(self):\n        final = self.rs.balance[\"total capital\"].iloc[-1]\n        assert abs(final - 957920.0) < 1.0\n\n    def test_balance_row_count(self):\n        assert len(self.rs.balance) == 61\n\n    def test_balance_column_count(self):\n        assert len(self.rs.balance.columns) == 20\n\n    def test_balance_has_all_columns(self):\n        required = [\n            \"cash\", \"options qty\", \"calls capital\", \"puts capital\",\n            \"stocks qty\", \"options capital\", \"stocks capital\",\n            \"total capital\", \"% change\", \"accumulated return\",\n        ]\n        for col in required:\n            assert col in self.rs.balance.columns, f\"Missing: {col}\"\n        for stock in _ivy_stocks():\n            assert stock.symbol in self.rs.balance.columns\n            assert f\"{stock.symbol} qty\" in self.rs.balance.columns\n\n    def test_initial_row_capital(self):\n        \"\"\"First row should have initial capital.\"\"\"\n        assert self.rs.balance[\"total capital\"].iloc[0] == 1_000_000\n\n    def test_accumulated_return_starts_at_one(self):\n        \"\"\"Second row's accumulated return should be close to 1.0.\"\"\"\n        # First row is NaN (no pct_change), second row is the first real value\n        acc_ret = self.rs.balance[\"accumulated return\"].dropna()\n        assert len(acc_ret) > 0\n\n\n@pytest.mark.skipif(not _RUST_OK, reason=\"Rust extension not installed\")\nclass TestRustDispatchGating:\n    \"\"\"Verify Rust dispatch is used/skipped under the right conditions.\"\"\"\n\n    def test_default_config_runs(self):\n        \"\"\"Default config completes successfully.\"\"\"\n        engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = _options_data()\n        engine.options_strategy = _buy_strategy(engine.options_data.schema)\n        engine.run(rebalance_freq=1)\n        assert not engine.trade_log.empty\n\n    def test_custom_cost_model_runs(self):\n        \"\"\"Custom cost model completes successfully.\"\"\"\n        engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=PerContractCommission(rate=1.0),\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = _options_data()\n        engine.options_strategy = _buy_strategy(engine.options_data.schema)\n        engine.run(rebalance_freq=1)\n        assert not engine.trade_log.empty\n\n    def test_custom_selector_runs(self):\n        \"\"\"Custom signal selector completes successfully.\"\"\"\n        engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n            signal_selector=NearestDelta(target_delta=-0.30),\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = _options_data()\n        engine.options_strategy = _buy_strategy(engine.options_data.schema)\n        engine.run(rebalance_freq=1)\n        assert not engine.trade_log.empty\n\n    def test_per_leg_override_runs(self):\n        \"\"\"Per-leg signal selector completes successfully.\"\"\"\n        from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg as NewLeg\n        from options_portfolio_backtester.execution.signal_selector import FirstMatch as FM\n\n        options_data = _options_data()\n        schema = options_data.schema\n        leg = NewLeg(\"leg_1\", schema, option_type=Type.PUT,\n                     direction=Direction.BUY, signal_selector=FM())\n        leg.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n        leg.exit_filter = schema.dte <= 30\n        strat = Strategy(schema)\n        strat.add_legs([leg])\n\n        engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = options_data\n        engine.options_strategy = strat\n        engine.run(rebalance_freq=1)\n        assert not engine.trade_log.empty\n\n\nclass TestThresholdExits:\n    \"\"\"Test profit/loss threshold exits work in the engine.\"\"\"\n\n    def test_profit_threshold_triggers_exit(self):\n        options_data = _options_data()\n        schema = options_data.schema\n        strat = Strategy(schema)\n        leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\n        leg.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n        leg.exit_filter = schema.dte <= 30\n        strat.add_legs([leg])\n        # Very tight profit threshold — should trigger exit quickly\n        strat.add_exit_thresholds(profit_pct=0.01)\n\n        engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n            signal_selector=NearestDelta(target_delta=-0.30),  # force Python path\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = options_data\n        engine.options_strategy = strat\n        engine.run(rebalance_freq=1)\n\n        # Should have completed without error\n        assert engine.balance is not None\n\n    def test_loss_threshold_triggers_exit(self):\n        options_data = _options_data()\n        schema = options_data.schema\n        strat = Strategy(schema)\n        leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\n        leg.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n        leg.exit_filter = schema.dte <= 30\n        strat.add_legs([leg])\n        strat.add_exit_thresholds(loss_pct=0.3)\n\n        engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = options_data\n        engine.options_strategy = strat\n        engine.run(rebalance_freq=1)\n\n        assert engine.balance is not None\n\n    def test_both_thresholds(self):\n        options_data = _options_data()\n        schema = options_data.schema\n        strat = Strategy(schema)\n        leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\n        leg.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n        leg.exit_filter = schema.dte <= 30\n        strat.add_legs([leg])\n        strat.add_exit_thresholds(profit_pct=0.5, loss_pct=0.3)\n\n        engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = options_data\n        engine.options_strategy = strat\n        engine.run(rebalance_freq=1)\n\n        assert engine.balance is not None\n        assert not engine.trade_log.empty\n\n\nclass TestPerLegSellDirection:\n    \"\"\"Verify per-leg fill model works for SELL-direction legs.\"\"\"\n\n    def test_sell_leg_with_midprice(self):\n        \"\"\"SELL leg with MidPrice fill should have correct sign on cost.\"\"\"\n        from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg as NewLeg\n        from options_portfolio_backtester.execution.fill_model import MidPrice\n\n        options_data = _options_data()\n        schema = options_data.schema\n\n        leg = NewLeg(\"leg_1\", schema, option_type=Type.PUT,\n                     direction=Direction.SELL, fill_model=MidPrice())\n        leg.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n        leg.exit_filter = schema.dte <= 30\n\n        strat = Strategy(schema)\n        strat.add_legs([leg])\n\n        engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = options_data\n        engine.options_strategy = strat\n        engine.run(rebalance_freq=1)\n\n        if not engine.trade_log.empty:\n            tl = engine.trade_log\n            # SELL-to-open (STO) entries should have negative cost (credit)\n            # Buy-to-close (BTC) liquidation trades will have positive cost\n            sto_mask = tl[\"leg_1\"][\"order\"] == \"STO\"\n            sto_costs = tl.loc[sto_mask, (\"leg_1\", \"cost\")].values\n            assert all(c < 0 for c in sto_costs if c != 0), (\n                f\"STO costs should be negative, got: {sto_costs}\"\n            )\n\n\nclass TestBalanceCompleteness:\n    \"\"\"Verify balance DataFrame has all expected columns after a run.\"\"\"\n\n    def test_balance_columns_present(self):\n        engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n            signal_selector=NearestDelta(target_delta=-0.30),  # force Python\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = _options_data()\n        engine.options_strategy = _buy_strategy(engine.options_data.schema)\n        engine.run(rebalance_freq=1)\n\n        required = [\n            \"cash\", \"options qty\", \"calls capital\", \"puts capital\",\n            \"stocks qty\", \"options capital\", \"stocks capital\",\n            \"total capital\", \"% change\", \"accumulated return\",\n        ]\n        for col in required:\n            assert col in engine.balance.columns, f\"Missing column: {col}\"\n\n        # Per-stock columns\n        for stock in _ivy_stocks():\n            assert stock.symbol in engine.balance.columns, (\n                f\"Missing stock column: {stock.symbol}\"\n            )\n            assert f\"{stock.symbol} qty\" in engine.balance.columns, (\n                f\"Missing stock qty column: {stock.symbol} qty\"\n            )\n\n    def test_balance_no_negative_total_capital(self):\n        \"\"\"Total capital should never go negative in a standard backtest.\"\"\"\n        engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n            signal_selector=NearestDelta(target_delta=-0.30),\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = _options_data()\n        engine.options_strategy = _buy_strategy(engine.options_data.schema)\n        engine.run(rebalance_freq=1)\n\n        total_cap = engine.balance[\"total capital\"].dropna()\n        assert (total_cap >= 0).all(), (\n            f\"Negative total capital found: {total_cap[total_cap < 0].tolist()}\"\n        )\n\n\nclass TestEdgeCases:\n    \"\"\"Edge cases that should not crash.\"\"\"\n\n    def test_high_rebalance_freq(self):\n        \"\"\"High rebalance frequency should still work.\"\"\"\n        engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = _options_data()\n        engine.options_strategy = _buy_strategy(engine.options_data.schema)\n        engine.run(rebalance_freq=3)\n\n        assert engine.balance is not None\n        assert not engine.trade_log.empty\n\n    def test_stop_if_broke(self):\n        \"\"\"stop_if_broke=True should halt cleanly.\"\"\"\n        engine = BacktestEngine(\n            {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n            cost_model=NoCosts(),\n            signal_selector=NearestDelta(target_delta=-0.30),\n            stop_if_broke=True,\n        )\n        engine.stocks = _ivy_stocks()\n        engine.stocks_data = _stocks_data()\n        engine.options_data = _options_data()\n        engine.options_strategy = _buy_strategy(engine.options_data.schema)\n        engine.run(rebalance_freq=1)\n\n        assert engine.balance is not None\n"
  },
  {
    "path": "tests/engine/test_signal_selector_wiring.py",
    "content": "\"\"\"Tests that SignalSelector is actually wired into the engine.\n\nAll execution goes through Rust, so we verify via standard selectors\nthat have to_rust_config() and produce different Rust behavior.\n\"\"\"\n\nimport os\nimport pandas as pd\nimport numpy as np\nimport pytest\n\nfrom options_portfolio_backtester.engine.engine import BacktestEngine\nfrom options_portfolio_backtester.execution.cost_model import NoCosts\nfrom options_portfolio_backtester.execution.signal_selector import (\n    FirstMatch, NearestDelta, MaxOpenInterest,\n)\n\nfrom options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData\nfrom options_portfolio_backtester.strategy.strategy import Strategy\nfrom options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\nfrom options_portfolio_backtester.core.types import Stock, OptionType as Type, Direction\n\nTEST_DIR = os.path.join(os.path.dirname(__file__), \"..\", \"test_data\")\nSTOCKS_FILE = os.path.join(TEST_DIR, \"ivy_5assets_data.csv\")\nOPTIONS_FILE = os.path.join(TEST_DIR, \"options_data.csv\")\n\n\ndef _ivy_stocks():\n    return [Stock(\"VTI\", 0.2), Stock(\"VEU\", 0.2), Stock(\"BND\", 0.2),\n            Stock(\"VNQ\", 0.2), Stock(\"DBC\", 0.2)]\n\n\ndef _stocks_data():\n    data = TiingoData(STOCKS_FILE)\n    data._data[\"adjClose\"] = 10\n    return data\n\n\ndef _options_data():\n    data = HistoricalOptionsData(OPTIONS_FILE)\n    data._data.at[2, \"ask\"] = 1\n    data._data.at[2, \"bid\"] = 0.5\n    data._data.at[51, \"ask\"] = 1.5\n    data._data.at[50, \"bid\"] = 0.5\n    data._data.at[130, \"bid\"] = 0.5\n    data._data.at[131, \"bid\"] = 1.5\n    data._data.at[206, \"bid\"] = 0.5\n    data._data.at[207, \"bid\"] = 1.5\n    return data\n\n\ndef _buy_strategy(schema):\n    strat = Strategy(schema)\n    leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\n    leg.entry_filter = (schema.underlying == \"SPX\") & (schema.dte >= 60)\n    leg.exit_filter = schema.dte <= 30\n    strat.add_legs([leg])\n    return strat\n\n\ndef _run_engine(signal_selector):\n    stocks = _ivy_stocks()\n    stocks_data = _stocks_data()\n    options_data = _options_data()\n    schema = options_data.schema\n\n    engine = BacktestEngine(\n        {\"stocks\": 0.97, \"options\": 0.03, \"cash\": 0},\n        cost_model=NoCosts(),\n        signal_selector=signal_selector,\n    )\n    engine.stocks = stocks\n    engine.stocks_data = stocks_data\n    engine.options_data = options_data\n    engine.options_strategy = _buy_strategy(schema)\n    engine.run(rebalance_freq=1)\n    return engine\n\n\nclass TestSignalSelectorWiring:\n    \"\"\"Verify the engine uses the plugged-in signal selector via Rust dispatch.\"\"\"\n\n    def test_first_match_still_works(self):\n        engine = _run_engine(FirstMatch())\n        assert not engine.trade_log.empty\n\n    def test_different_selectors_may_pick_different_contracts(self):\n        \"\"\"FirstMatch and NearestDelta should produce valid results (may differ).\"\"\"\n        first_engine = _run_engine(FirstMatch())\n        delta_engine = _run_engine(NearestDelta(target_delta=-0.30))\n\n        assert not first_engine.balance.empty\n        assert not delta_engine.balance.empty\n\n    def test_nearest_delta_runs_without_error(self):\n        engine = _run_engine(NearestDelta(target_delta=-0.30))\n        assert engine.balance is not None\n\n    def test_max_open_interest_runs(self):\n        \"\"\"MaxOpenInterest selector completes without error.\"\"\"\n        engine = _run_engine(MaxOpenInterest(oi_column=\"openinterest\"))\n        assert engine.balance is not None\n"
  },
  {
    "path": "tests/engine/test_strategy_tree.py",
    "content": "from __future__ import annotations\n\nimport pytest\n\nfrom options_portfolio_backtester.engine.strategy_tree import StrategyTreeNode, StrategyTreeEngine\n\nfrom tests.engine.test_engine import _run_engine\n\n\ndef test_strategy_tree_allocates_capital_by_weights():\n    leaf_a = StrategyTreeNode(name=\"a\", weight=2.0, engine=_run_engine())\n    leaf_b = StrategyTreeNode(name=\"b\", weight=1.0, engine=_run_engine())\n    root = StrategyTreeNode(name=\"root\", children=[leaf_a, leaf_b])\n    tree = StrategyTreeEngine(root, initial_capital=900_000)\n\n    tree.run(rebalance_freq=1)\n\n    assert abs(tree.leaf_weights[\"a\"] - (2.0 / 3.0)) < 1e-12\n    assert abs(tree.leaf_weights[\"b\"] - (1.0 / 3.0)) < 1e-12\n    assert tree.attribution[\"a\"][\"capital\"] == round(900_000 * (2.0 / 3.0))\n    assert tree.attribution[\"b\"][\"capital\"] == round(900_000 * (1.0 / 3.0))\n    assert \"total capital\" in tree.balance.columns\n\n\ndef test_nested_tree_weight_propagation():\n    leaf_a = StrategyTreeNode(name=\"a\", weight=1.0, engine=_run_engine())\n    leaf_b = StrategyTreeNode(name=\"b\", weight=3.0, engine=_run_engine())\n    branch = StrategyTreeNode(name=\"branch\", weight=2.0, children=[leaf_a, leaf_b])\n    leaf_c = StrategyTreeNode(name=\"c\", weight=1.0, engine=_run_engine())\n    root = StrategyTreeNode(name=\"root\", children=[branch, leaf_c])\n    tree = StrategyTreeEngine(root, initial_capital=1_000_000)\n\n    tree.run(rebalance_freq=1)\n\n    # branch share = 2/3; inside branch, a=1/4 and b=3/4\n    assert abs(tree.leaf_weights[\"a\"] - (2.0 / 3.0) * (1.0 / 4.0)) < 1e-12\n    assert abs(tree.leaf_weights[\"b\"] - (2.0 / 3.0) * (3.0 / 4.0)) < 1e-12\n    assert abs(tree.leaf_weights[\"c\"] - (1.0 / 3.0)) < 1e-12\n\n\ndef test_leaf_max_share_throttles_allocation():\n    leaf_a = StrategyTreeNode(name=\"a\", weight=1.0, max_share=0.20, engine=_run_engine())\n    leaf_b = StrategyTreeNode(name=\"b\", weight=1.0, engine=_run_engine())\n    root = StrategyTreeNode(name=\"root\", children=[leaf_a, leaf_b])\n    tree = StrategyTreeEngine(root, initial_capital=1_000_000)\n\n    tree.run(rebalance_freq=1)\n\n    assert abs(tree.leaf_weights[\"a\"] - 0.20) < 1e-12\n    assert \"a\" in tree.throttles\n    assert \"unallocated_cash\" in tree.balance.columns\n\n\n# ---------------------------------------------------------------------------\n# Validation (item 11)\n# ---------------------------------------------------------------------------\n\ndef test_node_rejects_engine_and_children():\n    \"\"\"A node cannot be both a leaf (engine) and a branch (children).\"\"\"\n    with pytest.raises(ValueError, match=\"both engine and children\"):\n        StrategyTreeNode(\n            name=\"bad\",\n            engine=_run_engine(),\n            children=[StrategyTreeNode(name=\"child\", engine=_run_engine())],\n        )\n\n\ndef test_empty_branch_produces_no_leaves():\n    empty = StrategyTreeNode(name=\"empty\", children=[])\n    root = StrategyTreeNode(name=\"root\", children=[empty])\n    tree = StrategyTreeEngine(root, initial_capital=1_000_000)\n    results = tree.run(rebalance_freq=1)\n    assert len(results) == 0\n    assert tree.balance.empty\n\n\n# ---------------------------------------------------------------------------\n# Capital restoration (item 7)\n# ---------------------------------------------------------------------------\n\ndef test_engine_capital_restored_after_tree_run():\n    \"\"\"StrategyTreeEngine should not permanently mutate leaf engine capital.\"\"\"\n    engine = _run_engine()\n    original_capital = engine.initial_capital\n    leaf = StrategyTreeNode(name=\"a\", weight=1.0, engine=engine)\n    root = StrategyTreeNode(name=\"root\", children=[leaf])\n    tree = StrategyTreeEngine(root, initial_capital=500_000)\n    tree.run(rebalance_freq=1)\n    assert engine.initial_capital == original_capital\n\n\n# ---------------------------------------------------------------------------\n# Round vs int for capital allocation (item 5)\n# ---------------------------------------------------------------------------\n\ndef test_capital_uses_round_not_truncate():\n    \"\"\"With 3 equal-weight leaves and 1M capital, round(1e6/3) = 333333.\"\"\"\n    engines = [_run_engine() for _ in range(3)]\n    leaves = [StrategyTreeNode(name=f\"l{i}\", weight=1.0, engine=e) for i, e in enumerate(engines)]\n    root = StrategyTreeNode(name=\"root\", children=leaves)\n    tree = StrategyTreeEngine(root, initial_capital=1_000_000)\n    tree.run(rebalance_freq=1)\n    total_allocated = sum(tree.attribution[f\"l{i}\"][\"capital\"] for i in range(3))\n    # With round(), 333333 * 3 = 999999 — only $1 lost vs $3 with int()\n    assert total_allocated >= 999_999\n\n\n# ---------------------------------------------------------------------------\n# Balance structure\n# ---------------------------------------------------------------------------\n\ndef test_balance_has_pct_change_and_accumulated_return():\n    leaf = StrategyTreeNode(name=\"a\", weight=1.0, engine=_run_engine())\n    root = StrategyTreeNode(name=\"root\", children=[leaf])\n    tree = StrategyTreeEngine(root, initial_capital=1_000_000)\n    tree.run(rebalance_freq=1)\n    assert \"% change\" in tree.balance.columns\n    assert \"accumulated return\" in tree.balance.columns\n    assert \"total capital\" in tree.balance.columns\n\n\ndef test_attribution_dict_structure():\n    leaf = StrategyTreeNode(name=\"a\", weight=1.0, engine=_run_engine())\n    root = StrategyTreeNode(name=\"root\", children=[leaf])\n    tree = StrategyTreeEngine(root, initial_capital=1_000_000)\n    tree.run(rebalance_freq=1)\n    assert \"a\" in tree.attribution\n    assert \"weight\" in tree.attribution[\"a\"]\n    assert \"capital\" in tree.attribution[\"a\"]\n    assert tree.attribution[\"a\"][\"weight\"] == 1.0\n\n\n# ---------------------------------------------------------------------------\n# to_dot() — Graphviz DOT export\n# ---------------------------------------------------------------------------\n\ndef test_to_dot_single_leaf():\n    leaf = StrategyTreeNode(name=\"leaf_a\", weight=1.0, engine=_run_engine())\n    dot = leaf.to_dot()\n    assert \"digraph StrategyTree\" in dot\n    assert \"leaf_a\" in dot\n    assert \"w=1.0\" in dot\n    assert \"ellipse\" in dot  # leaf → ellipse shape\n\n\ndef test_to_dot_nested_tree():\n    leaf_a = StrategyTreeNode(name=\"a\", weight=2.0, engine=_run_engine())\n    leaf_b = StrategyTreeNode(name=\"b\", weight=1.0, engine=_run_engine())\n    root = StrategyTreeNode(name=\"root\", children=[leaf_a, leaf_b])\n    dot = root.to_dot()\n    assert \"digraph StrategyTree\" in dot\n    assert \"root\" in dot\n    assert \"box\" in dot  # branch → box shape\n    assert \"->\" in dot  # edges exist\n    assert \"w=2.0\" in dot\n    assert \"w=1.0\" in dot\n\n\ndef test_to_dot_max_share_shown():\n    leaf = StrategyTreeNode(name=\"capped\", weight=1.0, max_share=0.25, engine=_run_engine())\n    dot = leaf.to_dot()\n    assert \"max=0.25\" in dot\n\n\ndef test_engine_to_dot_delegates_to_root():\n    leaf = StrategyTreeNode(name=\"x\", weight=1.0, engine=_run_engine())\n    root = StrategyTreeNode(name=\"top\", children=[leaf])\n    tree = StrategyTreeEngine(root, initial_capital=1_000_000)\n    dot = tree.to_dot()\n    assert \"digraph StrategyTree\" in dot\n    assert \"top\" in dot\n    assert \"x\" in dot\n"
  },
  {
    "path": "tests/execution/__init__.py",
    "content": ""
  },
  {
    "path": "tests/execution/test_cost_model.py",
    "content": "\"\"\"Tests for transaction cost models.\"\"\"\n\nfrom options_portfolio_backtester.execution.cost_model import (\n    NoCosts, PerContractCommission, TieredCommission, SpreadSlippage,\n)\n\n\nclass TestNoCosts:\n    def test_option_cost_is_zero(self):\n        m = NoCosts()\n        assert m.option_cost(2.50, 10, 100) == 0.0\n\n    def test_stock_cost_is_zero(self):\n        m = NoCosts()\n        assert m.stock_cost(150.0, 100) == 0.0\n\n\nclass TestPerContractCommission:\n    def test_default_rate(self):\n        m = PerContractCommission()\n        assert m.option_cost(2.50, 10, 100) == 6.50\n\n    def test_custom_rate(self):\n        m = PerContractCommission(rate=1.00)\n        assert m.option_cost(2.50, 10, 100) == 10.00\n\n    def test_stock_rate(self):\n        m = PerContractCommission(stock_rate=0.01)\n        assert m.stock_cost(150.0, 100) == 1.00\n\n    def test_negative_qty_uses_abs(self):\n        m = PerContractCommission(rate=0.65)\n        assert m.option_cost(2.50, -10, 100) == 6.50\n\n\nclass TestTieredCommission:\n    def test_default_tiers_small_qty(self):\n        m = TieredCommission()\n        cost = m.option_cost(2.50, 100, 100)\n        assert cost == 100 * 0.65\n\n    def test_default_tiers_large_qty(self):\n        m = TieredCommission()\n        # 10000 @ 0.65 + 5000 @ 0.50\n        cost = m.option_cost(2.50, 15000, 100)\n        assert cost == 10000 * 0.65 + 5000 * 0.50\n\n\nclass TestSpreadSlippage:\n    def test_zero_pct(self):\n        m = SpreadSlippage(pct=0.0)\n        assert m.slippage(1.0, 1.10, 10, 100) == 0.0\n\n    def test_half_spread(self):\n        m = SpreadSlippage(pct=0.5)\n        # spread = 0.10, half = 0.05, * 10 * 100 = 50.0\n        assert abs(m.slippage(1.0, 1.10, 10, 100) - 50.0) < 1e-8\n\n    def test_full_spread(self):\n        m = SpreadSlippage(pct=1.0)\n        assert abs(m.slippage(1.0, 1.10, 10, 100) - 100.0) < 1e-8\n\n    def test_option_cost_is_zero(self):\n        \"\"\"SpreadSlippage models slippage separately, not via option_cost.\"\"\"\n        m = SpreadSlippage(pct=0.5)\n        assert m.option_cost(2.50, 10, 100) == 0.0\n\n    def test_stock_cost_is_zero(self):\n        m = SpreadSlippage(pct=0.5)\n        assert m.stock_cost(150.0, 100) == 0.0\n\n\nclass TestTieredCommissionEdgeCases:\n    def test_qty_exceeds_all_tiers(self):\n        \"\"\"When quantity exceeds all tiers, remaining uses last tier rate.\"\"\"\n        m = TieredCommission(tiers=[(10, 1.0), (20, 0.5)])\n        # 10 @ 1.0 + 10 @ 0.5 + 5 @ 0.5 (last tier rate)\n        cost = m.option_cost(2.50, 25, 100)\n        assert cost == 10 * 1.0 + 10 * 0.5 + 5 * 0.5\n\n    def test_stock_cost(self):\n        m = TieredCommission(stock_rate=0.01)\n        assert m.stock_cost(150.0, 100) == 1.00\n\n    def test_tier_boundary_exact(self):\n        \"\"\"Exact tier boundary: no remaining.\"\"\"\n        m = TieredCommission(tiers=[(10, 1.0), (20, 0.5)])\n        cost = m.option_cost(2.50, 20, 100)\n        assert cost == 10 * 1.0 + 10 * 0.5\n\n\nclass TestRustConfigs:\n    def test_no_costs_rust_config(self):\n        c = NoCosts().to_rust_config()\n        assert c[\"type\"] == \"NoCosts\"\n\n    def test_per_contract_rust_config(self):\n        c = PerContractCommission(rate=0.65, stock_rate=0.01).to_rust_config()\n        assert c[\"type\"] == \"PerContract\"\n        assert c[\"rate\"] == 0.65\n        assert c[\"stock_rate\"] == 0.01\n\n    def test_tiered_rust_config(self):\n        m = TieredCommission(tiers=[(100, 0.65), (500, 0.50)])\n        c = m.to_rust_config()\n        assert c[\"type\"] == \"Tiered\"\n        assert len(c[\"tiers\"]) == 2\n"
  },
  {
    "path": "tests/execution/test_execution_deep.py",
    "content": "\"\"\"Deep execution model tests — fill models, cost models, signal selectors, sizers.\n\nTests edge cases, boundary conditions, and composition of execution components.\n\"\"\"\n\nimport numpy as np\nimport pandas as pd\nimport pytest\n\nfrom options_portfolio_backtester.execution.cost_model import (\n    NoCosts,\n    PerContractCommission,\n    TieredCommission,\n    SpreadSlippage,\n)\nfrom options_portfolio_backtester.execution.fill_model import (\n    MarketAtBidAsk,\n    MidPrice,\n    VolumeAwareFill,\n)\nfrom options_portfolio_backtester.execution.signal_selector import (\n    FirstMatch,\n    NearestDelta,\n    MaxOpenInterest,\n)\nfrom options_portfolio_backtester.execution.sizer import (\n    CapitalBased,\n    FixedQuantity,\n    FixedDollar,\n    PercentOfPortfolio,\n)\nfrom options_portfolio_backtester.core.types import Direction\n\n\n# ---------------------------------------------------------------------------\n# Cost Models — deep tests\n# ---------------------------------------------------------------------------\n\n\nclass TestNoCosts:\n    def test_option_cost_always_zero(self):\n        m = NoCosts()\n        assert m.option_cost(100.0, 50, 100) == 0.0\n        assert m.option_cost(0.0, 0, 100) == 0.0\n\n    def test_stock_cost_always_zero(self):\n        m = NoCosts()\n        assert m.stock_cost(50.0, 1000.0) == 0.0\n\n    def test_rust_config(self):\n        assert NoCosts().to_rust_config() == {\"type\": \"NoCosts\"}\n\n\nclass TestPerContractCommission:\n    def test_basic_cost(self):\n        m = PerContractCommission(rate=0.65)\n        assert m.option_cost(10.0, 10, 100) == 6.5\n\n    def test_negative_qty_uses_abs(self):\n        m = PerContractCommission(rate=1.0)\n        assert m.option_cost(5.0, -20, 100) == 20.0\n\n    def test_zero_qty(self):\n        m = PerContractCommission(rate=0.65)\n        assert m.option_cost(10.0, 0, 100) == 0.0\n\n    def test_stock_cost_per_share(self):\n        m = PerContractCommission(rate=0.65, stock_rate=0.01)\n        assert m.stock_cost(50.0, 100) == 1.0\n\n    def test_stock_cost_negative_qty(self):\n        m = PerContractCommission(stock_rate=0.005)\n        assert m.stock_cost(50.0, -200) == 1.0\n\n    def test_rust_config_roundtrip(self):\n        m = PerContractCommission(rate=0.65, stock_rate=0.005)\n        cfg = m.to_rust_config()\n        assert cfg[\"type\"] == \"PerContract\"\n        assert cfg[\"rate\"] == 0.65\n        assert cfg[\"stock_rate\"] == 0.005\n\n\nclass TestTieredCommission:\n    def test_default_tiers(self):\n        m = TieredCommission()\n        # First 10000 at $0.65\n        assert m.option_cost(1.0, 100, 100) == 100 * 0.65\n\n    def test_tier_boundary(self):\n        \"\"\"Exactly at first tier boundary.\"\"\"\n        m = TieredCommission()\n        cost = m.option_cost(1.0, 10_000, 100)\n        assert cost == 10_000 * 0.65\n\n    def test_crosses_first_tier(self):\n        \"\"\"15000 contracts: 10000 at $0.65, 5000 at $0.50.\"\"\"\n        m = TieredCommission()\n        cost = m.option_cost(1.0, 15_000, 100)\n        expected = 10_000 * 0.65 + 5_000 * 0.50\n        assert abs(cost - expected) < 0.01\n\n    def test_crosses_all_tiers(self):\n        \"\"\"200000 contracts: 10000*0.65 + 40000*0.50 + 50000*0.25 + 100000*0.25.\"\"\"\n        m = TieredCommission()\n        cost = m.option_cost(1.0, 200_000, 100)\n        expected = 10_000 * 0.65 + 40_000 * 0.50 + 50_000 * 0.25 + 100_000 * 0.25\n        assert abs(cost - expected) < 0.01\n\n    def test_custom_tiers(self):\n        m = TieredCommission(tiers=[(5, 1.0), (10, 0.5)])\n        # 7 contracts: 5*1.0 + 2*0.5\n        cost = m.option_cost(1.0, 7, 100)\n        assert abs(cost - 6.0) < 0.01\n\n    def test_negative_qty(self):\n        m = TieredCommission()\n        cost = m.option_cost(1.0, -100, 100)\n        assert cost == 100 * 0.65\n\n    def test_zero_qty(self):\n        m = TieredCommission()\n        assert m.option_cost(1.0, 0, 100) == 0.0\n\n    def test_rust_config(self):\n        m = TieredCommission()\n        cfg = m.to_rust_config()\n        assert cfg[\"type\"] == \"Tiered\"\n        assert len(cfg[\"tiers\"]) == 3\n\n\nclass TestSpreadSlippage:\n    def test_option_cost_is_zero(self):\n        m = SpreadSlippage(pct=0.5)\n        assert m.option_cost(1.0, 10, 100) == 0.0\n\n    def test_slippage_computation(self):\n        m = SpreadSlippage(pct=0.5)\n        # spread = 1.0, qty=10, spc=100\n        slippage = m.slippage(bid=9.0, ask=10.0, quantity=10, shares_per_contract=100)\n        assert slippage == 0.5 * 1.0 * 10 * 100\n\n    def test_slippage_zero_spread(self):\n        m = SpreadSlippage(pct=0.5)\n        assert m.slippage(10.0, 10.0, 10, 100) == 0.0\n\n    def test_slippage_full_pct(self):\n        m = SpreadSlippage(pct=1.0)\n        slippage = m.slippage(9.0, 10.0, 1, 100)\n        assert slippage == 100.0\n\n    def test_pct_bounds(self):\n        with pytest.raises(AssertionError):\n            SpreadSlippage(pct=-0.1)\n        with pytest.raises(AssertionError):\n            SpreadSlippage(pct=1.1)\n\n\n# ---------------------------------------------------------------------------\n# Fill Models — deep tests\n# ---------------------------------------------------------------------------\n\n\ndef _make_option_row(bid=9.0, ask=10.0, volume=100):\n    return pd.Series({\"bid\": bid, \"ask\": ask, \"volume\": volume})\n\n\nclass TestMarketAtBidAsk:\n    def test_buy_fills_at_ask(self):\n        m = MarketAtBidAsk()\n        assert m.get_fill_price(_make_option_row(bid=9, ask=10), Direction.BUY) == 10.0\n\n    def test_sell_fills_at_bid(self):\n        m = MarketAtBidAsk()\n        assert m.get_fill_price(_make_option_row(bid=9, ask=10), Direction.SELL) == 9.0\n\n    def test_zero_spread(self):\n        m = MarketAtBidAsk()\n        assert m.get_fill_price(_make_option_row(bid=10, ask=10), Direction.BUY) == 10.0\n        assert m.get_fill_price(_make_option_row(bid=10, ask=10), Direction.SELL) == 10.0\n\n\nclass TestMidPrice:\n    def test_midpoint(self):\n        m = MidPrice()\n        assert m.get_fill_price(_make_option_row(bid=9, ask=11), Direction.BUY) == 10.0\n\n    def test_same_bid_ask(self):\n        m = MidPrice()\n        assert m.get_fill_price(_make_option_row(bid=10, ask=10), Direction.BUY) == 10.0\n\n    def test_direction_doesnt_matter(self):\n        m = MidPrice()\n        row = _make_option_row(bid=8, ask=12)\n        assert m.get_fill_price(row, Direction.BUY) == m.get_fill_price(row, Direction.SELL)\n\n    def test_wide_spread(self):\n        m = MidPrice()\n        assert m.get_fill_price(_make_option_row(bid=1, ask=100), Direction.BUY) == 50.5\n\n\nclass TestVolumeAwareFill:\n    def test_high_volume_fills_at_target(self):\n        m = VolumeAwareFill(full_volume_threshold=100)\n        row = _make_option_row(bid=9, ask=10, volume=100)\n        assert m.get_fill_price(row, Direction.BUY) == 10.0\n        assert m.get_fill_price(row, Direction.SELL) == 9.0\n\n    def test_zero_volume_fills_at_mid(self):\n        m = VolumeAwareFill(full_volume_threshold=100)\n        row = _make_option_row(bid=9, ask=11, volume=0)\n        assert m.get_fill_price(row, Direction.BUY) == 10.0\n        assert m.get_fill_price(row, Direction.SELL) == 10.0\n\n    def test_half_volume_interpolates(self):\n        m = VolumeAwareFill(full_volume_threshold=100)\n        row = _make_option_row(bid=8, ask=12, volume=50)\n        # mid=10, target_buy=12, ratio=0.5 → 10 + 0.5*(12-10) = 11\n        assert m.get_fill_price(row, Direction.BUY) == 11.0\n        # mid=10, target_sell=8, ratio=0.5 → 10 + 0.5*(8-10) = 9\n        assert m.get_fill_price(row, Direction.SELL) == 9.0\n\n    def test_above_threshold_same_as_market(self):\n        m = VolumeAwareFill(full_volume_threshold=100)\n        row = _make_option_row(bid=9, ask=10, volume=500)\n        assert m.get_fill_price(row, Direction.BUY) == 10.0\n\n    def test_rust_config(self):\n        m = VolumeAwareFill(full_volume_threshold=200)\n        cfg = m.to_rust_config()\n        assert cfg[\"type\"] == \"VolumeAware\"\n        assert cfg[\"full_volume_threshold\"] == 200\n\n\n# ---------------------------------------------------------------------------\n# Signal Selectors — deep tests\n# ---------------------------------------------------------------------------\n\n\ndef _make_candidates(n=5, with_delta=True, with_oi=True):\n    data = {\n        \"contract\": [f\"SPX{i}\" for i in range(n)],\n        \"strike\": [100 + i * 5 for i in range(n)],\n        \"bid\": [1.0 + i * 0.1 for i in range(n)],\n        \"ask\": [1.5 + i * 0.1 for i in range(n)],\n    }\n    if with_delta:\n        data[\"delta\"] = [-0.05 - i * 0.05 for i in range(n)]  # -0.05, -0.10, ...\n    if with_oi:\n        data[\"openinterest\"] = [100 * (i + 1) for i in range(n)]\n    return pd.DataFrame(data)\n\n\nclass TestFirstMatch:\n    def test_selects_first_row(self):\n        s = FirstMatch()\n        df = _make_candidates()\n        selected = s.select(df)\n        assert selected[\"contract\"] == \"SPX0\"\n\n    def test_single_row(self):\n        s = FirstMatch()\n        df = _make_candidates(n=1)\n        selected = s.select(df)\n        assert selected[\"contract\"] == \"SPX0\"\n\n\nclass TestNearestDelta:\n    def test_selects_closest_delta(self):\n        s = NearestDelta(target_delta=-0.15)\n        df = _make_candidates()\n        # deltas: -0.05, -0.10, -0.15, -0.20, -0.25\n        selected = s.select(df)\n        assert selected[\"contract\"] == \"SPX2\"  # delta=-0.15\n\n    def test_boundary_delta(self):\n        s = NearestDelta(target_delta=-0.075)\n        df = _make_candidates()\n        # Closest to -0.075 is -0.05 (diff=0.025) or -0.10 (diff=0.025)\n        selected = s.select(df)\n        assert selected[\"contract\"] in {\"SPX0\", \"SPX1\"}\n\n    def test_missing_delta_column_fallback(self):\n        s = NearestDelta(target_delta=-0.30)\n        df = _make_candidates(with_delta=False)\n        selected = s.select(df)\n        # Falls back to iloc[0]\n        assert selected[\"contract\"] == \"SPX0\"\n\n    def test_column_requirements(self):\n        s = NearestDelta(delta_column=\"my_delta\")\n        assert \"my_delta\" in s.column_requirements\n\n    def test_rust_config(self):\n        s = NearestDelta(target_delta=-0.25, delta_column=\"delta\")\n        cfg = s.to_rust_config()\n        assert cfg[\"target\"] == -0.25\n\n\nclass TestMaxOpenInterest:\n    def test_selects_highest_oi(self):\n        s = MaxOpenInterest()\n        df = _make_candidates()\n        selected = s.select(df)\n        assert selected[\"contract\"] == \"SPX4\"  # oi=500\n\n    def test_missing_oi_column_fallback(self):\n        s = MaxOpenInterest()\n        df = _make_candidates(with_oi=False)\n        selected = s.select(df)\n        assert selected[\"contract\"] == \"SPX0\"  # fallback\n\n    def test_custom_oi_column(self):\n        s = MaxOpenInterest(oi_column=\"my_oi\")\n        df = _make_candidates(with_oi=False)\n        df[\"my_oi\"] = [10, 50, 30, 20, 40]\n        selected = s.select(df)\n        assert selected[\"contract\"] == \"SPX1\"  # oi=50\n\n\n# ---------------------------------------------------------------------------\n# Position Sizers — deep tests\n# ---------------------------------------------------------------------------\n\n\nclass TestCapitalBasedSizer:\n    def test_basic_sizing(self):\n        s = CapitalBased()\n        assert s.size(cost_per_contract=100, available_capital=1000, total_capital=10000) == 10\n\n    def test_fractional_truncated(self):\n        s = CapitalBased()\n        assert s.size(150, 1000, 10000) == 6  # 1000/150 = 6.66 → 6\n\n    def test_zero_cost(self):\n        s = CapitalBased()\n        assert s.size(0, 1000, 10000) == 0\n\n    def test_cost_exceeds_capital(self):\n        s = CapitalBased()\n        assert s.size(2000, 1000, 10000) == 0\n\n    def test_negative_cost_uses_abs(self):\n        s = CapitalBased()\n        assert s.size(-100, 1000, 10000) == 10\n\n\nclass TestFixedQuantitySizer:\n    def test_within_budget(self):\n        s = FixedQuantity(quantity=5)\n        assert s.size(100, 1000, 10000) == 5\n\n    def test_exceeds_budget_scales_down(self):\n        s = FixedQuantity(quantity=20)\n        assert s.size(100, 1000, 10000) == 10  # 1000/100 = 10\n\n    def test_zero_cost_returns_fixed_qty(self):\n        \"\"\"Zero cost doesn't trigger the budget check, returns fixed qty.\"\"\"\n        s = FixedQuantity(quantity=5)\n        assert s.size(0, 1000, 10000) == 5\n\n    def test_one_contract(self):\n        s = FixedQuantity(quantity=1)\n        assert s.size(100, 1000, 10000) == 1\n\n\nclass TestFixedDollarSizer:\n    def test_within_budget(self):\n        s = FixedDollar(amount=500)\n        assert s.size(100, 1000, 10000) == 5\n\n    def test_amount_exceeds_available(self):\n        s = FixedDollar(amount=2000)\n        assert s.size(100, 500, 10000) == 5  # uses min(2000, 500)\n\n    def test_zero_cost(self):\n        s = FixedDollar(amount=500)\n        assert s.size(0, 1000, 10000) == 0\n\n\nclass TestPercentOfPortfolioSizer:\n    def test_basic(self):\n        s = PercentOfPortfolio(pct=0.01)\n        # 0.01 * 10000 = 100; 100/50 = 2\n        assert s.size(50, 1000, 10000) == 2\n\n    def test_pct_exceeds_available(self):\n        s = PercentOfPortfolio(pct=0.5)\n        # 0.5 * 10000 = 5000, but available = 1000 → min(5000,1000) = 1000\n        assert s.size(100, 1000, 10000) == 10\n\n    def test_invalid_pct(self):\n        with pytest.raises(AssertionError):\n            PercentOfPortfolio(pct=0.0)\n        with pytest.raises(AssertionError):\n            PercentOfPortfolio(pct=1.5)\n\n    def test_zero_cost(self):\n        s = PercentOfPortfolio(pct=0.01)\n        assert s.size(0, 1000, 10000) == 0\n"
  },
  {
    "path": "tests/execution/test_execution_pbt.py",
    "content": "\"\"\"Property-based tests for execution models — cost, fill, sizer, selector.\n\nUses Hypothesis to fuzz all execution components with random inputs and verify\nmathematical invariants hold across the entire input space.\n\"\"\"\n\nimport numpy as np\nimport pandas as pd\nimport pytest\nfrom hypothesis import given, settings, assume, HealthCheck\nfrom hypothesis import strategies as st\n\nfrom options_portfolio_backtester.execution.cost_model import (\n    NoCosts,\n    PerContractCommission,\n    TieredCommission,\n    SpreadSlippage,\n)\nfrom options_portfolio_backtester.execution.fill_model import (\n    MarketAtBidAsk,\n    MidPrice,\n    VolumeAwareFill,\n)\nfrom options_portfolio_backtester.execution.signal_selector import (\n    FirstMatch,\n    NearestDelta,\n    MaxOpenInterest,\n)\nfrom options_portfolio_backtester.execution.sizer import (\n    CapitalBased,\n    FixedQuantity,\n    FixedDollar,\n    PercentOfPortfolio,\n)\nfrom options_portfolio_backtester.core.types import Direction\n\n# ---------------------------------------------------------------------------\n# Hypothesis strategies\n# ---------------------------------------------------------------------------\n\nprice = st.floats(min_value=0.01, max_value=10_000.0, allow_nan=False, allow_infinity=False)\nquantity_int = st.integers(min_value=0, max_value=100_000)\nsigned_qty = st.integers(min_value=-100_000, max_value=100_000)\nspc = st.sampled_from([1, 10, 100, 1000])\nrate = st.floats(min_value=0.001, max_value=10.0, allow_nan=False, allow_infinity=False)\npct_01 = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)\ncapital = st.floats(min_value=1.0, max_value=1e9, allow_nan=False, allow_infinity=False)\ndirection = st.sampled_from([Direction.BUY, Direction.SELL])\nvolume = st.integers(min_value=0, max_value=10_000)\n\n\n# ---------------------------------------------------------------------------\n# Cost models — property-based\n# ---------------------------------------------------------------------------\n\n\nclass TestNoCostsPBT:\n    @given(price, signed_qty, spc)\n    @settings(max_examples=100)\n    def test_always_zero(self, p, q, s):\n        m = NoCosts()\n        assert m.option_cost(p, q, s) == 0.0\n        assert m.stock_cost(p, q) == 0.0\n\n\nclass TestPerContractPBT:\n    @given(rate, price, signed_qty, spc)\n    @settings(max_examples=200)\n    def test_non_negative(self, r, p, q, s):\n        m = PerContractCommission(rate=r)\n        assert m.option_cost(p, q, s) >= 0.0\n\n    @given(rate, price, quantity_int, spc)\n    @settings(max_examples=200)\n    def test_symmetric_buy_sell(self, r, p, q, s):\n        \"\"\"Commission is the same for +q and -q (direction-independent).\"\"\"\n        m = PerContractCommission(rate=r)\n        assert m.option_cost(p, q, s) == m.option_cost(p, -q, s)\n\n    @given(rate, price, quantity_int, spc)\n    @settings(max_examples=100)\n    def test_linear_in_quantity(self, r, p, q, s):\n        \"\"\"Doubling quantity doubles cost.\"\"\"\n        assume(q <= 50_000)\n        m = PerContractCommission(rate=r)\n        c1 = m.option_cost(p, q, s)\n        c2 = m.option_cost(p, 2 * q, s)\n        assert abs(c2 - 2 * c1) < 1e-8\n\n    @given(rate, price, quantity_int)\n    @settings(max_examples=100)\n    def test_independent_of_price_and_spc(self, r, p, q):\n        \"\"\"Per-contract commission doesn't depend on price or spc.\"\"\"\n        m = PerContractCommission(rate=r)\n        c1 = m.option_cost(p, q, 100)\n        c2 = m.option_cost(p * 2, q, 1000)\n        assert abs(c1 - c2) < 1e-8\n\n    @given(st.floats(min_value=0.001, max_value=1.0, allow_nan=False, allow_infinity=False),\n           price, signed_qty)\n    @settings(max_examples=100)\n    def test_stock_cost_non_negative(self, sr, p, q):\n        m = PerContractCommission(stock_rate=sr)\n        assert m.stock_cost(p, q) >= 0.0\n\n\nclass TestTieredCommissionPBT:\n    @given(price, quantity_int, spc)\n    @settings(max_examples=200)\n    def test_non_negative(self, p, q, s):\n        m = TieredCommission()\n        assert m.option_cost(p, q, s) >= 0.0\n\n    @given(price, quantity_int, spc)\n    @settings(max_examples=200)\n    def test_symmetric(self, p, q, s):\n        m = TieredCommission()\n        assert m.option_cost(p, q, s) == m.option_cost(p, -q, s)\n\n    @given(st.integers(min_value=1, max_value=50_000))\n    @settings(max_examples=200)\n    def test_monotone_in_quantity(self, q):\n        \"\"\"More contracts never costs less total.\"\"\"\n        m = TieredCommission()\n        c1 = m.option_cost(1.0, q, 100)\n        c2 = m.option_cost(1.0, q + 1, 100)\n        assert c2 >= c1 - 1e-10\n\n    @given(st.integers(min_value=1, max_value=100_000))\n    @settings(max_examples=100)\n    def test_bounded_by_flat_rate(self, q):\n        \"\"\"Tiered cost <= flat rate at highest tier rate * quantity.\"\"\"\n        m = TieredCommission()\n        cost = m.option_cost(1.0, q, 100)\n        max_rate = max(r for _, r in m.tiers)\n        assert cost <= max_rate * q + 1e-8\n\n    @given(st.integers(min_value=1, max_value=100_000))\n    @settings(max_examples=100)\n    def test_bounded_below_by_lowest_rate(self, q):\n        \"\"\"Tiered cost >= min_rate * quantity.\"\"\"\n        m = TieredCommission()\n        cost = m.option_cost(1.0, q, 100)\n        min_rate = min(r for _, r in m.tiers)\n        assert cost >= min_rate * q - 1e-8\n\n    @given(st.integers(min_value=1, max_value=100_000))\n    @settings(max_examples=100)\n    def test_average_rate_decreasing(self, q):\n        \"\"\"Average cost per contract decreases (or stays same) with volume.\"\"\"\n        assume(q < 100_000)\n        m = TieredCommission()\n        c1 = m.option_cost(1.0, q, 100)\n        c2 = m.option_cost(1.0, q + 1000, 100)\n        avg1 = c1 / q\n        avg2 = c2 / (q + 1000)\n        assert avg2 <= avg1 + 1e-10\n\n\nclass TestSpreadSlippagePBT:\n    @given(pct_01, price, signed_qty, spc)\n    @settings(max_examples=200)\n    def test_option_cost_always_zero(self, pct, p, q, s):\n        m = SpreadSlippage(pct=pct)\n        assert m.option_cost(p, q, s) == 0.0\n\n    @given(pct_01, st.floats(min_value=0, max_value=100, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=0, max_value=100, allow_nan=False, allow_infinity=False),\n           quantity_int, spc)\n    @settings(max_examples=200)\n    def test_slippage_non_negative(self, pct, bid, ask, q, s):\n        m = SpreadSlippage(pct=pct)\n        slip = m.slippage(bid, ask, q, s)\n        assert slip >= -1e-10\n\n    @given(pct_01, price, quantity_int, spc)\n    @settings(max_examples=100)\n    def test_zero_spread_zero_slippage(self, pct, p, q, s):\n        m = SpreadSlippage(pct=pct)\n        assert m.slippage(p, p, q, s) == 0.0\n\n    @given(st.floats(min_value=0.01, max_value=0.99, allow_nan=False, allow_infinity=False),\n           price, quantity_int, spc)\n    @settings(max_examples=100)\n    def test_slippage_monotone_in_pct(self, pct, p, q, s):\n        \"\"\"Higher pct means more slippage.\"\"\"\n        assume(q > 0)\n        bid = p * 0.95\n        ask = p * 1.05\n        m1 = SpreadSlippage(pct=pct)\n        m2 = SpreadSlippage(pct=min(pct + 0.01, 1.0))\n        assert m2.slippage(bid, ask, q, s) >= m1.slippage(bid, ask, q, s) - 1e-10\n\n    @given(st.floats(min_value=0.01, max_value=0.99, allow_nan=False, allow_infinity=False),\n           price, spc)\n    @settings(max_examples=100)\n    def test_slippage_linear_in_quantity(self, pct, p, s):\n        \"\"\"Double quantity → double slippage.\"\"\"\n        m = SpreadSlippage(pct=pct)\n        bid, ask = p * 0.95, p * 1.05\n        s1 = m.slippage(bid, ask, 10, s)\n        s2 = m.slippage(bid, ask, 20, s)\n        assert abs(s2 - 2 * s1) < 1e-6\n\n\n# ---------------------------------------------------------------------------\n# Fill models — property-based\n# ---------------------------------------------------------------------------\n\n\nclass TestMarketAtBidAskPBT:\n    @given(st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False))\n    @settings(max_examples=200)\n    def test_buy_at_ask_sell_at_bid(self, bid, ask):\n        assume(bid <= ask)\n        m = MarketAtBidAsk()\n        row = pd.Series({\"bid\": bid, \"ask\": ask, \"volume\": 100})\n        assert m.get_fill_price(row, Direction.BUY) == ask\n        assert m.get_fill_price(row, Direction.SELL) == bid\n\n    @given(price)\n    @settings(max_examples=100)\n    def test_zero_spread_both_equal(self, p):\n        m = MarketAtBidAsk()\n        row = pd.Series({\"bid\": p, \"ask\": p, \"volume\": 100})\n        assert m.get_fill_price(row, Direction.BUY) == m.get_fill_price(row, Direction.SELL)\n\n    @given(st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False))\n    @settings(max_examples=200)\n    def test_buy_never_cheaper_than_sell(self, bid, ask):\n        \"\"\"Buy fill >= sell fill (you always pay more to buy).\"\"\"\n        assume(bid <= ask)\n        m = MarketAtBidAsk()\n        row = pd.Series({\"bid\": bid, \"ask\": ask, \"volume\": 100})\n        assert m.get_fill_price(row, Direction.BUY) >= m.get_fill_price(row, Direction.SELL)\n\n\nclass TestMidPricePBT:\n    @given(st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False),\n           direction)\n    @settings(max_examples=200)\n    def test_between_bid_and_ask(self, bid, ask, d):\n        assume(bid <= ask)\n        m = MidPrice()\n        row = pd.Series({\"bid\": bid, \"ask\": ask, \"volume\": 100})\n        mid = m.get_fill_price(row, d)\n        assert bid - 1e-10 <= mid <= ask + 1e-10\n\n    @given(st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False))\n    @settings(max_examples=200)\n    def test_direction_independent(self, bid, ask):\n        assume(bid <= ask)\n        m = MidPrice()\n        row = pd.Series({\"bid\": bid, \"ask\": ask, \"volume\": 100})\n        assert m.get_fill_price(row, Direction.BUY) == m.get_fill_price(row, Direction.SELL)\n\n    @given(st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False))\n    @settings(max_examples=100)\n    def test_midpoint_formula(self, bid, ask):\n        assume(bid <= ask)\n        m = MidPrice()\n        row = pd.Series({\"bid\": bid, \"ask\": ask, \"volume\": 100})\n        expected = (bid + ask) / 2.0\n        assert abs(m.get_fill_price(row, Direction.BUY) - expected) < 1e-10\n\n\nclass TestVolumeAwareFillPBT:\n    @given(st.floats(min_value=0.01, max_value=500, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=0.01, max_value=500, allow_nan=False, allow_infinity=False),\n           volume, st.integers(min_value=1, max_value=10_000), direction)\n    @settings(max_examples=300)\n    def test_fill_between_mid_and_edge(self, bid, ask, vol, threshold, d):\n        \"\"\"Fill price is always between mid and bid/ask.\"\"\"\n        assume(bid <= ask)\n        m = VolumeAwareFill(full_volume_threshold=threshold)\n        row = pd.Series({\"bid\": bid, \"ask\": ask, \"volume\": vol})\n        fill = m.get_fill_price(row, d)\n        mid = (bid + ask) / 2.0\n        if d == Direction.BUY:\n            assert mid - 1e-10 <= fill <= ask + 1e-10\n        else:\n            assert bid - 1e-10 <= fill <= mid + 1e-10\n\n    @given(st.floats(min_value=0.01, max_value=500, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=0.01, max_value=500, allow_nan=False, allow_infinity=False),\n           st.integers(min_value=1, max_value=1000))\n    @settings(max_examples=200)\n    def test_zero_volume_fills_at_mid(self, bid, ask, threshold):\n        assume(bid <= ask)\n        m = VolumeAwareFill(full_volume_threshold=threshold)\n        row = pd.Series({\"bid\": bid, \"ask\": ask, \"volume\": 0})\n        mid = (bid + ask) / 2.0\n        assert abs(m.get_fill_price(row, Direction.BUY) - mid) < 1e-10\n        assert abs(m.get_fill_price(row, Direction.SELL) - mid) < 1e-10\n\n    @given(st.floats(min_value=0.01, max_value=500, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=0.01, max_value=500, allow_nan=False, allow_infinity=False),\n           st.integers(min_value=1, max_value=500), direction)\n    @settings(max_examples=200)\n    def test_higher_volume_moves_toward_edge(self, bid, ask, threshold, d):\n        \"\"\"More volume pushes fill toward bid/ask (worse for trader).\"\"\"\n        assume(bid < ask)\n        assume(threshold >= 4)\n        m = VolumeAwareFill(full_volume_threshold=threshold)\n        low_vol = pd.Series({\"bid\": bid, \"ask\": ask, \"volume\": threshold // 4})\n        high_vol = pd.Series({\"bid\": bid, \"ask\": ask, \"volume\": threshold // 2})\n        fill_low = m.get_fill_price(low_vol, d)\n        fill_high = m.get_fill_price(high_vol, d)\n        if d == Direction.BUY:\n            assert fill_high >= fill_low - 1e-10\n        else:\n            assert fill_high <= fill_low + 1e-10\n\n    @given(st.floats(min_value=0.01, max_value=500, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=0.01, max_value=500, allow_nan=False, allow_infinity=False),\n           st.integers(min_value=1, max_value=500))\n    @settings(max_examples=200)\n    def test_above_threshold_equals_market(self, bid, ask, threshold):\n        assume(bid <= ask)\n        m = VolumeAwareFill(full_volume_threshold=threshold)\n        row = pd.Series({\"bid\": bid, \"ask\": ask, \"volume\": threshold * 2})\n        market = MarketAtBidAsk()\n        assert abs(m.get_fill_price(row, Direction.BUY) - market.get_fill_price(row, Direction.BUY)) < 1e-10\n        assert abs(m.get_fill_price(row, Direction.SELL) - market.get_fill_price(row, Direction.SELL)) < 1e-10\n\n\n# ---------------------------------------------------------------------------\n# Signal selectors — property-based\n# ---------------------------------------------------------------------------\n\ndef _make_candidates(n, deltas=None, ois=None):\n    data = {\n        \"contract\": [f\"SPX{i}\" for i in range(n)],\n        \"strike\": [100 + i * 5 for i in range(n)],\n        \"bid\": [1.0 + i * 0.1 for i in range(n)],\n        \"ask\": [1.5 + i * 0.1 for i in range(n)],\n    }\n    if deltas is not None:\n        data[\"delta\"] = deltas\n    if ois is not None:\n        data[\"openinterest\"] = ois\n    return pd.DataFrame(data)\n\n\nclass TestFirstMatchPBT:\n    @given(st.integers(min_value=1, max_value=50))\n    @settings(max_examples=50)\n    def test_always_selects_row_from_dataframe(self, n):\n        s = FirstMatch()\n        df = _make_candidates(n)\n        result = s.select(df)\n        assert result[\"contract\"] in df[\"contract\"].values\n\n    @given(st.integers(min_value=1, max_value=50))\n    @settings(max_examples=50)\n    def test_always_selects_first(self, n):\n        s = FirstMatch()\n        df = _make_candidates(n)\n        assert s.select(df)[\"contract\"] == \"SPX0\"\n\n\nclass TestNearestDeltaPBT:\n    @given(st.floats(min_value=-1.0, max_value=0.0, allow_nan=False, allow_infinity=False),\n           st.integers(min_value=2, max_value=30))\n    @settings(max_examples=200)\n    def test_selected_is_closest_to_target(self, target, n):\n        \"\"\"Selected row has smallest |delta - target| of all rows.\"\"\"\n        deltas = [-0.05 * (i + 1) for i in range(n)]\n        s = NearestDelta(target_delta=target)\n        df = _make_candidates(n, deltas=deltas)\n        result = s.select(df)\n        result_diff = abs(result[\"delta\"] - target)\n        min_diff = (df[\"delta\"] - target).abs().min()\n        assert result_diff <= min_diff + 1e-10\n\n    @given(st.floats(min_value=-1.0, max_value=0.0, allow_nan=False, allow_infinity=False),\n           st.integers(min_value=1, max_value=20))\n    @settings(max_examples=100)\n    def test_result_is_valid_row(self, target, n):\n        deltas = [-0.05 * (i + 1) for i in range(n)]\n        s = NearestDelta(target_delta=target)\n        df = _make_candidates(n, deltas=deltas)\n        result = s.select(df)\n        assert result[\"contract\"] in df[\"contract\"].values\n\n    @given(st.integers(min_value=1, max_value=20))\n    @settings(max_examples=50)\n    def test_missing_delta_falls_back_to_first(self, n):\n        s = NearestDelta(target_delta=-0.30)\n        df = _make_candidates(n)  # no delta column\n        result = s.select(df)\n        assert result[\"contract\"] == \"SPX0\"\n\n\nclass TestMaxOpenInterestPBT:\n    @given(st.lists(st.integers(min_value=0, max_value=100_000), min_size=2, max_size=30))\n    @settings(max_examples=200)\n    def test_selects_max_oi(self, ois):\n        s = MaxOpenInterest()\n        df = _make_candidates(len(ois), ois=ois)\n        result = s.select(df)\n        assert result[\"openinterest\"] == max(ois)\n\n    @given(st.integers(min_value=1, max_value=20))\n    @settings(max_examples=50)\n    def test_missing_oi_falls_back(self, n):\n        s = MaxOpenInterest()\n        df = _make_candidates(n)  # no OI column\n        result = s.select(df)\n        assert result[\"contract\"] == \"SPX0\"\n\n    @given(st.integers(min_value=2, max_value=20), st.integers(min_value=1, max_value=100_000))\n    @settings(max_examples=100)\n    def test_uniform_oi_selects_some_row(self, n, oi):\n        \"\"\"When all OI equal, still returns a valid row.\"\"\"\n        s = MaxOpenInterest()\n        df = _make_candidates(n, ois=[oi] * n)\n        result = s.select(df)\n        assert result[\"contract\"] in df[\"contract\"].values\n\n\n# ---------------------------------------------------------------------------\n# Sizers — property-based\n# ---------------------------------------------------------------------------\n\n\nclass TestCapitalBasedPBT:\n    @given(st.floats(min_value=0.01, max_value=10_000, allow_nan=False, allow_infinity=False),\n           capital, capital)\n    @settings(max_examples=200)\n    def test_non_negative_integer(self, cost, avail, total):\n        s = CapitalBased()\n        qty = s.size(cost, avail, total)\n        assert isinstance(qty, int)\n        assert qty >= 0\n\n    @given(st.floats(min_value=0.01, max_value=10_000, allow_nan=False, allow_infinity=False),\n           capital, capital)\n    @settings(max_examples=200)\n    def test_total_cost_within_budget(self, cost, avail, total):\n        \"\"\"qty * cost <= available_capital.\"\"\"\n        s = CapitalBased()\n        qty = s.size(cost, avail, total)\n        assert qty * cost <= avail + 1e-6\n\n    @given(capital, capital)\n    @settings(max_examples=50)\n    def test_zero_cost_returns_zero(self, avail, total):\n        s = CapitalBased()\n        assert s.size(0, avail, total) == 0\n\n    @given(st.floats(min_value=0.01, max_value=100, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=1.0, max_value=1e6, allow_nan=False, allow_infinity=False),\n           capital)\n    @settings(max_examples=200)\n    def test_more_capital_more_contracts(self, cost, avail, total):\n        \"\"\"Doubling available capital never decreases contracts.\"\"\"\n        assume(avail * 2 <= 1e9)\n        s = CapitalBased()\n        q1 = s.size(cost, avail, total)\n        q2 = s.size(cost, avail * 2, total)\n        assert q2 >= q1\n\n    @given(st.floats(min_value=0.01, max_value=10_000, allow_nan=False, allow_infinity=False),\n           capital, capital)\n    @settings(max_examples=100)\n    def test_negative_cost_same_as_positive(self, cost, avail, total):\n        s = CapitalBased()\n        assert s.size(cost, avail, total) == s.size(-cost, avail, total)\n\n\nclass TestFixedQuantityPBT:\n    @given(st.integers(min_value=1, max_value=1000),\n           st.floats(min_value=0.01, max_value=10_000, allow_nan=False, allow_infinity=False),\n           capital, capital)\n    @settings(max_examples=200)\n    def test_never_exceeds_budget(self, qty, cost, avail, total):\n        s = FixedQuantity(quantity=qty)\n        result = s.size(cost, avail, total)\n        assert result * cost <= avail + 1e-6\n\n    @given(st.integers(min_value=1, max_value=100),\n           st.floats(min_value=0.01, max_value=100, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=1e6, max_value=1e9, allow_nan=False, allow_infinity=False),\n           capital)\n    @settings(max_examples=100)\n    def test_large_budget_returns_fixed(self, qty, cost, avail, total):\n        \"\"\"With huge available capital, always returns the fixed quantity.\"\"\"\n        s = FixedQuantity(quantity=qty)\n        assert s.size(cost, avail, total) == qty\n\n    @given(st.integers(min_value=1, max_value=1000),\n           st.floats(min_value=0.01, max_value=10_000, allow_nan=False, allow_infinity=False),\n           capital, capital)\n    @settings(max_examples=100)\n    def test_bounded_by_capital_based(self, qty, cost, avail, total):\n        \"\"\"FixedQuantity result <= CapitalBased result or == qty.\"\"\"\n        s = FixedQuantity(quantity=qty)\n        result = s.size(cost, avail, total)\n        cap_result = CapitalBased().size(cost, avail, total)\n        assert result <= max(qty, cap_result) + 1\n\n\nclass TestFixedDollarPBT:\n    @given(st.floats(min_value=100, max_value=1e6, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=0.01, max_value=10_000, allow_nan=False, allow_infinity=False),\n           capital, capital)\n    @settings(max_examples=200)\n    def test_non_negative_integer(self, amount, cost, avail, total):\n        s = FixedDollar(amount=amount)\n        qty = s.size(cost, avail, total)\n        assert isinstance(qty, int)\n        assert qty >= 0\n\n    @given(st.floats(min_value=100, max_value=1e6, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=0.01, max_value=10_000, allow_nan=False, allow_infinity=False),\n           capital, capital)\n    @settings(max_examples=200)\n    def test_total_cost_within_min_amount_avail(self, amount, cost, avail, total):\n        \"\"\"qty * cost <= min(amount, avail).\"\"\"\n        s = FixedDollar(amount=amount)\n        qty = s.size(cost, avail, total)\n        assert qty * cost <= min(amount, avail) + 1e-6\n\n    @given(st.floats(min_value=100, max_value=1e6, allow_nan=False, allow_infinity=False),\n           capital, capital)\n    @settings(max_examples=50)\n    def test_zero_cost_returns_zero(self, amount, avail, total):\n        s = FixedDollar(amount=amount)\n        assert s.size(0, avail, total) == 0\n\n\nclass TestPercentOfPortfolioPBT:\n    @given(st.floats(min_value=0.001, max_value=0.99, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=0.01, max_value=10_000, allow_nan=False, allow_infinity=False),\n           capital,\n           st.floats(min_value=1000, max_value=1e9, allow_nan=False, allow_infinity=False))\n    @settings(max_examples=200)\n    def test_non_negative_integer(self, pct, cost, avail, total):\n        s = PercentOfPortfolio(pct=pct)\n        qty = s.size(cost, avail, total)\n        assert isinstance(qty, int)\n        assert qty >= 0\n\n    @given(st.floats(min_value=0.001, max_value=0.99, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=0.01, max_value=10_000, allow_nan=False, allow_infinity=False),\n           capital,\n           st.floats(min_value=1000, max_value=1e9, allow_nan=False, allow_infinity=False))\n    @settings(max_examples=200)\n    def test_cost_bounded_by_pct_of_total(self, pct, cost, avail, total):\n        \"\"\"qty * cost <= pct * total (or available, whichever is smaller).\"\"\"\n        s = PercentOfPortfolio(pct=pct)\n        qty = s.size(cost, avail, total)\n        assert qty * cost <= min(pct * total, avail) + 1e-6\n\n    @given(st.floats(min_value=0.001, max_value=0.99, allow_nan=False, allow_infinity=False),\n           capital,\n           st.floats(min_value=1000, max_value=1e9, allow_nan=False, allow_infinity=False))\n    @settings(max_examples=50)\n    def test_zero_cost_returns_zero(self, pct, avail, total):\n        s = PercentOfPortfolio(pct=pct)\n        assert s.size(0, avail, total) == 0\n\n    @given(st.floats(min_value=0.01, max_value=0.49, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=0.01, max_value=100, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=1e6, max_value=1e9, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=1e6, max_value=1e9, allow_nan=False, allow_infinity=False))\n    @settings(max_examples=100)\n    def test_higher_pct_more_contracts(self, pct, cost, avail, total):\n        \"\"\"Higher pct never gives fewer contracts.\"\"\"\n        s1 = PercentOfPortfolio(pct=pct)\n        s2 = PercentOfPortfolio(pct=min(pct + 0.01, 1.0))\n        assert s2.size(cost, avail, total) >= s1.size(cost, avail, total)\n"
  },
  {
    "path": "tests/execution/test_fill_model.py",
    "content": "\"\"\"Tests for fill models.\"\"\"\n\nimport pandas as pd\n\nfrom options_portfolio_backtester.core.types import Direction\nfrom options_portfolio_backtester.execution.fill_model import (\n    MarketAtBidAsk, MidPrice, VolumeAwareFill,\n)\n\n\ndef _make_row(bid: float = 1.00, ask: float = 1.10, volume: int = 100) -> pd.Series:\n    return pd.Series({\"bid\": bid, \"ask\": ask, \"volume\": volume})\n\n\nclass TestMarketAtBidAsk:\n    def test_buy_fills_at_ask(self):\n        m = MarketAtBidAsk()\n        assert m.get_fill_price(_make_row(), Direction.BUY) == 1.10\n\n    def test_sell_fills_at_bid(self):\n        m = MarketAtBidAsk()\n        assert m.get_fill_price(_make_row(), Direction.SELL) == 1.00\n\n\nclass TestMidPrice:\n    def test_mid(self):\n        m = MidPrice()\n        assert m.get_fill_price(_make_row(), Direction.BUY) == 1.05\n\n    def test_mid_sell(self):\n        m = MidPrice()\n        assert m.get_fill_price(_make_row(), Direction.SELL) == 1.05\n\n\nclass TestVolumeAwareFill:\n    def test_high_volume_fills_at_target(self):\n        m = VolumeAwareFill(full_volume_threshold=100)\n        assert m.get_fill_price(_make_row(volume=200), Direction.BUY) == 1.10\n\n    def test_zero_volume_fills_at_mid(self):\n        m = VolumeAwareFill(full_volume_threshold=100)\n        assert m.get_fill_price(_make_row(volume=0), Direction.BUY) == 1.05\n\n    def test_half_volume_interpolates(self):\n        m = VolumeAwareFill(full_volume_threshold=100)\n        price = m.get_fill_price(_make_row(volume=50), Direction.BUY)\n        # mid=1.05, target=1.10, ratio=0.5 -> 1.05 + 0.5*0.05 = 1.075\n        assert abs(price - 1.075) < 1e-10\n"
  },
  {
    "path": "tests/execution/test_rust_parity_execution.py",
    "content": "\"\"\"Rust execution model tests: edge cases, invariants, PBT, integration.\n\nCovers:\n- Edge-case fuzzing (NaN, Inf, empty, zero, extreme values)\n- Property-based testing (Hypothesis)\n- Invariant tests (cost >= 0, fill between bid/ask, index in range, monotonicity)\n- Integration tests (Python classes delegating to Rust)\n\"\"\"\n\nimport pandas as pd\nimport pytest\nfrom hypothesis import given, settings\nfrom hypothesis import strategies as st\n\nfrom options_portfolio_backtester.core.types import Direction, Greeks\nfrom options_portfolio_backtester._ob_rust import (\n    rust_option_cost,\n    rust_stock_cost,\n    rust_fill_price,\n    rust_nearest_delta_index,\n    rust_max_value_index,\n    rust_risk_check,\n)\n\n\n# ===================================================================\n# EDGE-CASE / FUZZING TESTS\n# ===================================================================\n\nclass TestCostModelEdgeCases:\n\n    def test_per_contract_basic(self):\n        assert rust_option_cost(\"PerContract\", 0.65, 0.005, [], 10.0, 10.0, 100) == pytest.approx(6.5)\n\n    def test_per_contract_negative_quantity(self):\n        assert rust_option_cost(\"PerContract\", 0.65, 0.005, [], 10.0, -10.0, 100) == pytest.approx(6.5)\n\n    def test_per_contract_zero_quantity(self):\n        assert rust_option_cost(\"PerContract\", 0.65, 0.005, [], 10.0, 0.0, 100) == 0.0\n\n    def test_per_contract_stock(self):\n        assert rust_stock_cost(\"PerContract\", 0.65, 0.005, [], 150.0, 100.0) == pytest.approx(0.5)\n\n    def test_zero_rate(self):\n        assert rust_option_cost(\"PerContract\", 0.0, 0.0, [], 10.0, 100.0, 100) == 0.0\n        assert rust_stock_cost(\"PerContract\", 0.65, 0.0, [], 150.0, 100.0) == 0.0\n\n    def test_very_small_quantity(self):\n        assert rust_option_cost(\"PerContract\", 0.65, 0.005, [], 10.0, 0.001, 100) == pytest.approx(0.65 * 0.001)\n\n    def test_tiered_within_first_tier(self):\n        tiers = [(10_000, 0.65), (50_000, 0.50), (100_000, 0.25)]\n        assert rust_option_cost(\"Tiered\", 0.0, 0.005, tiers, 10.0, 100.0, 100) == pytest.approx(65.0)\n\n    def test_tiered_spanning_tiers(self):\n        tiers = [(10_000, 0.65), (50_000, 0.50), (100_000, 0.25)]\n        expected = 10_000 * 0.65 + 5_000 * 0.50\n        assert rust_option_cost(\"Tiered\", 0.0, 0.005, tiers, 10.0, 15_000.0, 100) == pytest.approx(expected)\n\n    def test_tiered_beyond_all(self):\n        tiers = [(10_000, 0.65), (50_000, 0.50), (100_000, 0.25)]\n        expected = 10_000 * 0.65 + 40_000 * 0.50 + 50_000 * 0.25 + 20_000 * 0.25\n        assert rust_option_cost(\"Tiered\", 0.0, 0.005, tiers, 10.0, 120_000.0, 100) == pytest.approx(expected)\n\n    def test_tiered_exactly_at_boundary(self):\n        tiers = [(10_000, 0.65), (50_000, 0.50), (100_000, 0.25)]\n        assert rust_option_cost(\"Tiered\", 0.0, 0.005, tiers, 10.0, 10_000.0, 100) == pytest.approx(6500.0)\n\n    def test_tiered_negative_quantity(self):\n        tiers = [(10_000, 0.65), (50_000, 0.50), (100_000, 0.25)]\n        expected = 10_000 * 0.65 + 5_000 * 0.50\n        assert rust_option_cost(\"Tiered\", 0.0, 0.005, tiers, 10.0, -15_000.0, 100) == pytest.approx(expected)\n\n    def test_very_large_quantity(self):\n        tiers = [(10_000, 0.65), (50_000, 0.50), (100_000, 0.25)]\n        assert rust_option_cost(\"Tiered\", 0.0, 0.005, tiers, 10.0, 1e9, 100) > 0\n\n    def test_invalid_model_type_raises(self):\n        with pytest.raises(ValueError, match=\"Unknown cost model type\"):\n            rust_option_cost(\"Bogus\", 0.65, 0.005, [], 10.0, 10.0, 100)\n\n\nclass TestFillModelEdgeCases:\n\n    def test_full_volume_buy(self):\n        assert rust_fill_price(\"VolumeAware\", 100, 9.0, 10.0, 100.0, True) == pytest.approx(10.0)\n\n    def test_full_volume_sell(self):\n        assert rust_fill_price(\"VolumeAware\", 100, 9.0, 10.0, 200.0, False) == pytest.approx(9.0)\n\n    def test_zero_volume(self):\n        mid = (9.0 + 10.0) / 2.0\n        assert rust_fill_price(\"VolumeAware\", 100, 9.0, 10.0, 0.0, True) == pytest.approx(mid)\n        assert rust_fill_price(\"VolumeAware\", 100, 9.0, 10.0, 0.0, False) == pytest.approx(mid)\n\n    def test_half_volume(self):\n        assert rust_fill_price(\"VolumeAware\", 100, 9.0, 10.0, 50.0, True) == pytest.approx(9.75)\n        assert rust_fill_price(\"VolumeAware\", 100, 9.0, 10.0, 50.0, False) == pytest.approx(9.25)\n\n    def test_missing_volume(self):\n        assert rust_fill_price(\"VolumeAware\", 100, 9.0, 10.0, None, True) == pytest.approx(10.0)\n\n    def test_zero_bid_ask(self):\n        assert rust_fill_price(\"VolumeAware\", 100, 0.0, 0.0, 50.0, True) == 0.0\n\n    def test_bid_equals_ask(self):\n        assert rust_fill_price(\"VolumeAware\", 100, 5.0, 5.0, 50.0, True) == pytest.approx(5.0)\n\n    def test_invalid_fill_type_raises(self):\n        with pytest.raises(ValueError, match=\"Unknown fill model type\"):\n            rust_fill_price(\"Bogus\", 100, 9.0, 10.0, 50.0, True)\n\n\nclass TestSignalSelectorEdgeCases:\n\n    def test_nearest_delta_exact(self):\n        assert rust_nearest_delta_index([-0.20, -0.30, -0.45], -0.30) == 1\n\n    def test_nearest_delta_between(self):\n        assert rust_nearest_delta_index([-0.20, -0.30, -0.45], -0.35) == 1\n\n    def test_empty_list(self):\n        assert rust_nearest_delta_index([], -0.30) == 0\n        assert rust_max_value_index([]) == 0\n\n    def test_all_nan_nearest(self):\n        assert rust_nearest_delta_index([float('nan'), float('nan')], -0.30) == 0\n\n    def test_all_nan_max(self):\n        assert rust_max_value_index([float('nan'), float('nan')]) == 0\n\n    def test_single_element(self):\n        assert rust_nearest_delta_index([0.5], 0.0) == 0\n        assert rust_max_value_index([42.0]) == 0\n\n    def test_max_value_basic(self):\n        assert rust_max_value_index([500.0, 1200.0, 800.0]) == 1\n\n    def test_max_value_negative(self):\n        assert rust_max_value_index([-10.0, -5.0, -20.0]) == 1\n\n    def test_max_value_ties_first_wins(self):\n        assert rust_max_value_index([100.0, 100.0, 50.0]) == 0\n\n    def test_large_list(self):\n        assert rust_max_value_index([float(v) for v in range(10_000)]) == 9_999\n\n\nclass TestRiskCheckEdgeCases:\n\n    def test_max_delta_allows(self):\n        assert rust_risk_check(\"MaxDelta\", 100.0, [50, 0, 0, 0], [30, 0, 0, 0], 1e6, 1e6) is True\n\n    def test_max_delta_rejects(self):\n        assert rust_risk_check(\"MaxDelta\", 100.0, [80, 0, 0, 0], [30, 0, 0, 0], 1e6, 1e6) is False\n\n    def test_max_delta_exactly_at_limit(self):\n        assert rust_risk_check(\"MaxDelta\", 100.0, [50, 0, 0, 0], [50, 0, 0, 0], 1e6, 1e6) is True\n\n    def test_max_delta_negative(self):\n        assert rust_risk_check(\"MaxDelta\", 100.0, [-80, 0, 0, 0], [-30, 0, 0, 0], 1e6, 1e6) is False\n\n    def test_max_vega_allows(self):\n        assert rust_risk_check(\"MaxVega\", 50.0, [0, 0, 0, 20], [0, 0, 0, 10], 1e6, 1e6) is True\n\n    def test_max_vega_rejects(self):\n        assert rust_risk_check(\"MaxVega\", 50.0, [0, 0, 0, 40], [0, 0, 0, 20], 1e6, 1e6) is False\n\n    def test_max_drawdown_allows(self):\n        g = [0, 0, 0, 0]\n        assert rust_risk_check(\"MaxDrawdown\", 0.20, g, g, 900_000, 1_000_000) is True\n\n    def test_max_drawdown_rejects(self):\n        g = [0, 0, 0, 0]\n        assert rust_risk_check(\"MaxDrawdown\", 0.20, g, g, 750_000, 1_000_000) is False\n\n    def test_max_drawdown_zero_peak(self):\n        g = [0, 0, 0, 0]\n        assert rust_risk_check(\"MaxDrawdown\", 0.20, g, g, 100, 0.0) is True\n\n    def test_zero_greeks(self):\n        g = [0, 0, 0, 0]\n        assert rust_risk_check(\"MaxDelta\", 100.0, g, g, 1e6, 1e6) is True\n        assert rust_risk_check(\"MaxVega\", 50.0, g, g, 1e6, 1e6) is True\n\n    def test_negative_peak_value(self):\n        g = [0, 0, 0, 0]\n        assert rust_risk_check(\"MaxDrawdown\", 0.20, g, g, 100, -1.0) is True\n\n    def test_invalid_constraint_raises(self):\n        with pytest.raises(ValueError, match=\"Unknown risk constraint type\"):\n            rust_risk_check(\"Bogus\", 100.0, [0]*4, [0]*4, 1e6, 1e6)\n\n\n# ===================================================================\n# PROPERTY-BASED TESTS\n# ===================================================================\n\nsafe_float = st.floats(min_value=0.01, max_value=1e6, allow_nan=False, allow_infinity=False)\nsafe_qty = st.floats(min_value=1.0, max_value=1e6, allow_nan=False, allow_infinity=False)\ndelta_float = st.floats(min_value=-1.0, max_value=1.0, allow_nan=False, allow_infinity=False)\npositive_float = st.floats(min_value=0.01, max_value=1e8, allow_nan=False, allow_infinity=False)\n\n\nclass TestCostInvariants:\n\n    @given(rate=safe_float, qty=safe_qty)\n    @settings(max_examples=200)\n    def test_cost_always_non_negative(self, rate, qty):\n        assert rust_option_cost(\"PerContract\", rate, 0.005, [], 10.0, qty, 100) >= 0.0\n\n    @given(qty=st.floats(min_value=0.0, max_value=200_000.0, allow_nan=False, allow_infinity=False))\n    @settings(max_examples=200)\n    def test_tiered_cost_non_negative(self, qty):\n        tiers = [(10_000, 0.65), (50_000, 0.50), (100_000, 0.25)]\n        assert rust_option_cost(\"Tiered\", 0.0, 0.005, tiers, 10.0, qty, 100) >= 0.0\n\n    @given(rate=safe_float, qty=safe_qty)\n    @settings(max_examples=200)\n    def test_sign_symmetry(self, rate, qty):\n        pos = rust_option_cost(\"PerContract\", rate, 0.005, [], 10.0, qty, 100)\n        neg = rust_option_cost(\"PerContract\", rate, 0.005, [], 10.0, -qty, 100)\n        assert pos == pytest.approx(neg, rel=1e-12)\n\n\nclass TestFillInvariants:\n\n    @given(\n        bid=st.floats(min_value=0.01, max_value=500.0, allow_nan=False, allow_infinity=False),\n        spread=st.floats(min_value=0.0, max_value=50.0, allow_nan=False, allow_infinity=False),\n        vol=st.floats(min_value=0.0, max_value=1000.0, allow_nan=False, allow_infinity=False),\n        is_buy=st.booleans(),\n    )\n    @settings(max_examples=200)\n    def test_fill_between_bid_ask(self, bid, spread, vol, is_buy):\n        ask = bid + spread\n        price = rust_fill_price(\"VolumeAware\", 100, bid, ask, vol, is_buy)\n        assert price >= bid - 1e-10\n        assert price <= ask + 1e-10\n\n\nclass TestSelectorInvariants:\n\n    @given(\n        values=st.lists(delta_float, min_size=1, max_size=50),\n        target=delta_float,\n    )\n    @settings(max_examples=200)\n    def test_index_in_range(self, values, target):\n        idx = rust_nearest_delta_index(values, target)\n        assert 0 <= idx < len(values)\n\n    @given(\n        values=st.lists(\n            st.floats(min_value=-1e6, max_value=1e6, allow_nan=False, allow_infinity=False),\n            min_size=1, max_size=50,\n        ),\n    )\n    @settings(max_examples=200)\n    def test_max_index_in_range(self, values):\n        idx = rust_max_value_index(values)\n        assert 0 <= idx < len(values)\n\n\nclass TestRiskInvariants:\n\n    @given(\n        limit_low=st.floats(min_value=0.0, max_value=500.0, allow_nan=False, allow_infinity=False),\n        limit_high=st.floats(min_value=500.0, max_value=10000.0, allow_nan=False, allow_infinity=False),\n        delta=st.floats(min_value=-1000.0, max_value=1000.0, allow_nan=False, allow_infinity=False),\n    )\n    @settings(max_examples=200)\n    def test_higher_limit_more_permissive(self, limit_low, limit_high, delta):\n        cur = [delta, 0.0, 0.0, 0.0]\n        prop = [0.0, 0.0, 0.0, 0.0]\n        low_ok = rust_risk_check(\"MaxDelta\", limit_low, cur, prop, 1e6, 1e6)\n        high_ok = rust_risk_check(\"MaxDelta\", limit_high, cur, prop, 1e6, 1e6)\n        if low_ok:\n            assert high_ok\n\n\n# ===================================================================\n# INTEGRATION: Python classes delegating to Rust\n# ===================================================================\n\nclass TestPythonClassDelegation:\n\n    def test_per_contract_via_class(self):\n        from options_portfolio_backtester.execution.cost_model import PerContractCommission\n        pc = PerContractCommission(0.65, 0.005)\n        assert pc.option_cost(10.0, 10, 100) == pytest.approx(6.5)\n        assert pc.stock_cost(150.0, 100) == pytest.approx(0.5)\n\n    def test_tiered_via_class(self):\n        from options_portfolio_backtester.execution.cost_model import TieredCommission\n        tc = TieredCommission()\n        assert tc.option_cost(10.0, 100, 100) == pytest.approx(65.0)\n        assert tc.option_cost(10.0, 15_000, 100) == pytest.approx(10_000 * 0.65 + 5_000 * 0.50)\n\n    def test_volume_aware_via_class(self):\n        from options_portfolio_backtester.execution.fill_model import VolumeAwareFill\n        vf = VolumeAwareFill(100)\n        row = pd.Series({\"bid\": 9.0, \"ask\": 10.0, \"volume\": 50.0})\n        assert vf.get_fill_price(row, Direction.BUY) == pytest.approx(9.75)\n        assert vf.get_fill_price(row, Direction.SELL) == pytest.approx(9.25)\n\n    def test_nearest_delta_via_class(self):\n        from options_portfolio_backtester.execution.signal_selector import NearestDelta\n        df = pd.DataFrame({\"delta\": [-0.20, -0.30, -0.45], \"price\": [1.0, 2.0, 3.0]})\n        nd = NearestDelta(-0.30)\n        assert nd.select(df)[\"delta\"] == pytest.approx(-0.30)\n\n    def test_max_oi_via_class(self):\n        from options_portfolio_backtester.execution.signal_selector import MaxOpenInterest\n        df = pd.DataFrame({\"openinterest\": [500, 1200, 800], \"price\": [1.0, 2.0, 3.0]})\n        assert MaxOpenInterest().select(df)[\"openinterest\"] == 1200\n\n    def test_max_delta_via_class(self):\n        from options_portfolio_backtester.portfolio.risk import MaxDelta\n        md = MaxDelta(100.0)\n        assert md.check(Greeks(50, 0, 0, 0), Greeks(30, 0, 0, 0), 1e6, 1e6) is True\n        assert md.check(Greeks(80, 0, 0, 0), Greeks(30, 0, 0, 0), 1e6, 1e6) is False\n\n    def test_max_vega_via_class(self):\n        from options_portfolio_backtester.portfolio.risk import MaxVega\n        mv = MaxVega(50.0)\n        assert mv.check(Greeks(0, 0, 0, 20), Greeks(0, 0, 0, 10), 1e6, 1e6) is True\n        assert mv.check(Greeks(0, 0, 0, 40), Greeks(0, 0, 0, 20), 1e6, 1e6) is False\n\n    def test_max_drawdown_via_class(self):\n        from options_portfolio_backtester.portfolio.risk import MaxDrawdown\n        mdd = MaxDrawdown(0.20)\n        assert mdd.check(Greeks(), Greeks(), 900_000, 1_000_000) is True\n        assert mdd.check(Greeks(), Greeks(), 750_000, 1_000_000) is False\n        assert mdd.check(Greeks(), Greeks(), 100, 0.0) is True\n"
  },
  {
    "path": "tests/execution/test_signal_selector.py",
    "content": "\"\"\"Tests for signal selectors.\"\"\"\n\nimport pandas as pd\n\nfrom options_portfolio_backtester.execution.signal_selector import (\n    FirstMatch, NearestDelta, MaxOpenInterest,\n)\n\n\ndef _make_candidates() -> pd.DataFrame:\n    return pd.DataFrame({\n        \"contract\": [\"A\", \"B\", \"C\"],\n        \"delta\": [-0.10, -0.30, -0.50],\n        \"openinterest\": [100, 500, 200],\n        \"ask\": [1.0, 2.0, 3.0],\n    })\n\n\nclass TestFirstMatch:\n    def test_picks_first(self):\n        s = FirstMatch()\n        result = s.select(_make_candidates())\n        assert result[\"contract\"] == \"A\"\n\n\nclass TestNearestDelta:\n    def test_nearest_to_target(self):\n        s = NearestDelta(target_delta=-0.30)\n        result = s.select(_make_candidates())\n        assert result[\"contract\"] == \"B\"\n\n    def test_nearest_to_different_target(self):\n        s = NearestDelta(target_delta=-0.45)\n        result = s.select(_make_candidates())\n        assert result[\"contract\"] == \"C\"\n\n    def test_fallback_without_column(self):\n        s = NearestDelta(target_delta=-0.30)\n        df = _make_candidates().drop(columns=[\"delta\"])\n        result = s.select(df)\n        assert result[\"contract\"] == \"A\"  # falls back to first\n\n\nclass TestMaxOpenInterest:\n    def test_picks_max_oi(self):\n        s = MaxOpenInterest()\n        result = s.select(_make_candidates())\n        assert result[\"contract\"] == \"B\"  # OI=500\n\n    def test_fallback_without_column(self):\n        s = MaxOpenInterest()\n        df = _make_candidates().drop(columns=[\"openinterest\"])\n        result = s.select(df)\n        assert result[\"contract\"] == \"A\"\n"
  },
  {
    "path": "tests/execution/test_sizer.py",
    "content": "\"\"\"Tests for position sizing models.\"\"\"\n\nfrom options_portfolio_backtester.execution.sizer import (\n    CapitalBased, FixedQuantity, FixedDollar, PercentOfPortfolio,\n)\n\n\nclass TestCapitalBased:\n    def test_basic_sizing(self):\n        s = CapitalBased()\n        assert s.size(250.0, 1000.0, 100_000.0) == 4\n\n    def test_zero_cost(self):\n        s = CapitalBased()\n        assert s.size(0.0, 1000.0, 100_000.0) == 0\n\n    def test_insufficient_capital(self):\n        s = CapitalBased()\n        assert s.size(500.0, 100.0, 100_000.0) == 0\n\n\nclass TestFixedQuantity:\n    def test_fixed(self):\n        s = FixedQuantity(quantity=5)\n        assert s.size(100.0, 10_000.0, 100_000.0) == 5\n\n    def test_insufficient_capital_reduces(self):\n        s = FixedQuantity(quantity=10)\n        assert s.size(100.0, 500.0, 100_000.0) == 5\n\n\nclass TestFixedDollar:\n    def test_fixed_amount(self):\n        s = FixedDollar(amount=1000.0)\n        assert s.size(250.0, 10_000.0, 100_000.0) == 4\n\n    def test_amount_capped_by_available(self):\n        s = FixedDollar(amount=10_000.0)\n        assert s.size(250.0, 500.0, 100_000.0) == 2\n\n\nclass TestPercentOfPortfolio:\n    def test_one_percent(self):\n        s = PercentOfPortfolio(pct=0.01)\n        # 1% of 100k = 1000, 1000 // 250 = 4\n        assert s.size(250.0, 10_000.0, 100_000.0) == 4\n\n    def test_capped_by_available(self):\n        s = PercentOfPortfolio(pct=0.10)\n        # 10% of 100k = 10000, but only 500 available\n        assert s.size(250.0, 500.0, 100_000.0) == 2\n"
  },
  {
    "path": "tests/portfolio/__init__.py",
    "content": ""
  },
  {
    "path": "tests/portfolio/test_greeks_aggregation.py",
    "content": "\"\"\"Tests for portfolio-level Greeks aggregation.\"\"\"\n\nfrom options_portfolio_backtester.core.types import (\n    Direction, OptionType, Order, Greeks,\n)\nfrom options_portfolio_backtester.portfolio.position import (\n    OptionPosition, PositionLeg,\n)\nfrom options_portfolio_backtester.portfolio.greeks import aggregate_greeks\n\n\ndef _make_position(pid, direction=Direction.BUY, qty=10):\n    pos = OptionPosition(position_id=pid, quantity=qty, entry_cost=100.0)\n    pos.add_leg(PositionLeg(\n        name=\"leg_1\",\n        contract_id=f\"SPY_C_{pid}\",\n        underlying=\"SPY\",\n        expiration=\"2024-01-01\",\n        option_type=OptionType.CALL,\n        strike=450.0,\n        entry_price=5.0,\n        direction=direction,\n        order=Order.BTO if direction == Direction.BUY else Order.STO,\n    ))\n    return pos\n\n\ndef test_aggregate_single_position():\n    pos = _make_position(1, Direction.BUY, qty=10)\n    leg_greeks = {1: {\"leg_1\": Greeks(delta=0.5, gamma=0.05, theta=-0.1, vega=0.3)}}\n    total = aggregate_greeks({1: pos}, leg_greeks)\n    # BUY direction: sign=+1, qty=10\n    assert total.delta == 0.5 * 10\n    assert total.gamma == 0.05 * 10\n    assert total.theta == -0.1 * 10\n    assert total.vega == 0.3 * 10\n\n\ndef test_aggregate_sell_position():\n    pos = _make_position(1, Direction.SELL, qty=5)\n    leg_greeks = {1: {\"leg_1\": Greeks(delta=0.5, gamma=0.05, theta=-0.1, vega=0.3)}}\n    total = aggregate_greeks({1: pos}, leg_greeks)\n    # SELL direction: sign=-1, qty=5\n    assert total.delta == 0.5 * (-1) * 5\n    assert total.vega == 0.3 * (-1) * 5\n\n\ndef test_aggregate_multiple_positions():\n    pos1 = _make_position(1, Direction.BUY, qty=10)\n    pos2 = _make_position(2, Direction.SELL, qty=5)\n    leg_greeks = {\n        1: {\"leg_1\": Greeks(delta=0.5, gamma=0.05, theta=-0.1, vega=0.3)},\n        2: {\"leg_1\": Greeks(delta=0.4, gamma=0.04, theta=-0.08, vega=0.2)},\n    }\n    total = aggregate_greeks({1: pos1, 2: pos2}, leg_greeks)\n    expected_delta = 0.5 * 10 + 0.4 * (-1) * 5\n    assert abs(total.delta - expected_delta) < 1e-10\n\n\ndef test_aggregate_empty():\n    total = aggregate_greeks({}, {})\n    assert total.delta == 0.0\n    assert total.gamma == 0.0\n    assert total.theta == 0.0\n    assert total.vega == 0.0\n\n\ndef test_aggregate_missing_greeks_for_position():\n    pos = _make_position(1, Direction.BUY, qty=10)\n    # No greeks provided for position 1\n    total = aggregate_greeks({1: pos}, {})\n    assert total.delta == 0.0\n    assert total.vega == 0.0\n"
  },
  {
    "path": "tests/portfolio/test_portfolio.py",
    "content": "\"\"\"Tests for Portfolio class.\"\"\"\n\nfrom options_portfolio_backtester.core.types import Direction, OptionType, Order, Greeks\nfrom options_portfolio_backtester.portfolio.portfolio import Portfolio, StockHolding\nfrom options_portfolio_backtester.portfolio.position import OptionPosition, PositionLeg\n\n\ndef _make_portfolio() -> Portfolio:\n    p = Portfolio(initial_cash=100_000.0)\n\n    pos = OptionPosition(position_id=p.next_position_id(), quantity=10, entry_cost=-5000.0)\n    pos.add_leg(PositionLeg(\n        name=\"leg_1\", contract_id=\"SPY_C_500\", underlying=\"SPY\",\n        expiration=\"2024-01-19\", option_type=OptionType.CALL,\n        strike=500.0, entry_price=5.0, direction=Direction.BUY,\n        order=Order.BTO,\n    ))\n    p.add_option_position(pos)\n    p.set_stock_holding(\"SPY\", 100, 480.0)\n    return p\n\n\nclass TestPortfolio:\n    def test_initial_cash(self):\n        p = Portfolio(initial_cash=50_000.0)\n        assert p.cash == 50_000.0\n\n    def test_add_remove_position(self):\n        p = Portfolio()\n        pos = OptionPosition(position_id=0, quantity=1)\n        p.add_option_position(pos)\n        assert 0 in p.option_positions\n        removed = p.remove_option_position(0)\n        assert removed is pos\n        assert 0 not in p.option_positions\n\n    def test_remove_nonexistent(self):\n        p = Portfolio()\n        assert p.remove_option_position(999) is None\n\n    def test_next_position_id_increments(self):\n        p = Portfolio()\n        assert p.next_position_id() == 0\n        assert p.next_position_id() == 1\n        assert p.next_position_id() == 2\n\n    def test_options_value(self):\n        p = _make_portfolio()\n        # BUY leg: +1 * 6.0 * 10 * 100 = 6000\n        val = p.options_value({0: {\"leg_1\": 6.0}}, 100)\n        assert val == 6000.0\n\n    def test_stocks_value(self):\n        p = _make_portfolio()\n        val = p.stocks_value({\"SPY\": 490.0})\n        assert val == 100 * 490.0\n\n    def test_total_value(self):\n        p = _make_portfolio()\n        total = p.total_value(\n            stock_prices={\"SPY\": 490.0},\n            option_prices={0: {\"leg_1\": 6.0}},\n            shares_per_contract=100,\n        )\n        # cash=100000, stocks=49000, options=6000\n        assert total == 155_000.0\n\n    def test_clear_stock_holdings(self):\n        p = _make_portfolio()\n        p.clear_stock_holdings()\n        assert len(p.stock_holdings) == 0\n        assert p.stocks_value({\"SPY\": 490.0}) == 0.0\n\n    def test_portfolio_greeks(self):\n        p = _make_portfolio()\n        greeks_map = {0: {\"leg_1\": Greeks(delta=0.5, gamma=0.01)}}\n        result = p.portfolio_greeks(greeks_map)\n        # BUY, qty=10: delta = 0.5 * 10 = 5.0\n        assert abs(result.delta - 5.0) < 1e-10\n\n\nclass TestPortfolioInvariant:\n    \"\"\"Test: cash + stocks + options == total on every operation.\"\"\"\n\n    def test_invariant_after_stock_buy(self):\n        p = Portfolio(initial_cash=100_000.0)\n        price = 150.0\n        qty = 100\n        p.cash -= price * qty\n        p.set_stock_holding(\"SPY\", qty, price)\n        total = p.total_value({\"SPY\": price}, {}, 100)\n        assert abs(total - 100_000.0) < 1e-10\n\n    def test_invariant_after_option_buy(self):\n        p = Portfolio(initial_cash=100_000.0)\n        cost = 5.0 * 10 * 100  # 5000\n        p.cash -= cost\n        pos = OptionPosition(position_id=0, quantity=10, entry_cost=-cost)\n        pos.add_leg(PositionLeg(\n            name=\"leg_1\", contract_id=\"C1\", underlying=\"SPY\",\n            expiration=\"2024-01-19\", option_type=OptionType.CALL,\n            strike=500.0, entry_price=5.0, direction=Direction.BUY,\n            order=Order.BTO,\n        ))\n        p.add_option_position(pos)\n        total = p.total_value({}, {0: {\"leg_1\": 5.0}}, 100)\n        assert abs(total - 100_000.0) < 1e-10\n"
  },
  {
    "path": "tests/portfolio/test_position.py",
    "content": "\"\"\"Tests for option position and position leg.\"\"\"\n\nfrom options_portfolio_backtester.core.types import Direction, OptionType, Order, Greeks\nfrom options_portfolio_backtester.portfolio.position import PositionLeg, OptionPosition\n\n\nclass TestPositionLeg:\n    def test_exit_order_inverts(self):\n        leg = PositionLeg(\n            name=\"leg_1\", contract_id=\"SPY_C\", underlying=\"SPY\",\n            expiration=\"2024-01-19\", option_type=OptionType.CALL,\n            strike=500.0, entry_price=5.0, direction=Direction.BUY,\n            order=Order.BTO,\n        )\n        assert leg.exit_order == Order.STC\n\n    def test_buy_leg_value(self):\n        leg = PositionLeg(\n            name=\"leg_1\", contract_id=\"SPY_C\", underlying=\"SPY\",\n            expiration=\"2024-01-19\", option_type=OptionType.CALL,\n            strike=500.0, entry_price=5.0, direction=Direction.BUY,\n            order=Order.BTO,\n        )\n        # BUY: +1 * 6.0 * 10 * 100 = 6000\n        assert leg.current_value(6.0, 10, 100) == 6000.0\n\n    def test_sell_leg_value(self):\n        leg = PositionLeg(\n            name=\"leg_1\", contract_id=\"SPY_P\", underlying=\"SPY\",\n            expiration=\"2024-01-19\", option_type=OptionType.PUT,\n            strike=400.0, entry_price=3.0, direction=Direction.SELL,\n            order=Order.STO,\n        )\n        # SELL: -1 * 4.0 * 10 * 100 = -4000\n        assert leg.current_value(4.0, 10, 100) == -4000.0\n\n\nclass TestOptionPosition:\n    def _make_position(self) -> OptionPosition:\n        pos = OptionPosition(position_id=0, quantity=5, entry_cost=-1500.0)\n        pos.add_leg(PositionLeg(\n            name=\"leg_1\", contract_id=\"SPY_C_500\", underlying=\"SPY\",\n            expiration=\"2024-01-19\", option_type=OptionType.CALL,\n            strike=500.0, entry_price=3.0, direction=Direction.BUY,\n            order=Order.BTO,\n        ))\n        return pos\n\n    def test_current_value(self):\n        pos = self._make_position()\n        # BUY leg: +1 * 4.0 * 5 * 100 = 2000\n        val = pos.current_value({\"leg_1\": 4.0}, 100)\n        assert val == 2000.0\n\n    def test_current_value_missing_price(self):\n        pos = self._make_position()\n        # Missing price defaults to 0\n        assert pos.current_value({}, 100) == 0.0\n\n    def test_greeks(self):\n        pos = self._make_position()\n        leg_greeks = {\"leg_1\": Greeks(delta=0.5, gamma=0.01, theta=-0.02, vega=0.1)}\n        result = pos.greeks(leg_greeks)\n        # BUY direction, qty=5: delta = 0.5 * 5 = 2.5\n        assert abs(result.delta - 2.5) < 1e-10\n        assert abs(result.gamma - 0.05) < 1e-10\n        assert abs(result.theta - (-0.1)) < 1e-10\n        assert abs(result.vega - 0.5) < 1e-10\n\n    def test_multi_leg_greeks(self):\n        pos = OptionPosition(position_id=1, quantity=10)\n        pos.add_leg(PositionLeg(\n            name=\"leg_1\", contract_id=\"C1\", underlying=\"SPY\",\n            expiration=\"2024-01-19\", option_type=OptionType.CALL,\n            strike=500.0, entry_price=3.0, direction=Direction.BUY,\n            order=Order.BTO,\n        ))\n        pos.add_leg(PositionLeg(\n            name=\"leg_2\", contract_id=\"P1\", underlying=\"SPY\",\n            expiration=\"2024-01-19\", option_type=OptionType.PUT,\n            strike=480.0, entry_price=2.0, direction=Direction.SELL,\n            order=Order.STO,\n        ))\n        leg_greeks = {\n            \"leg_1\": Greeks(delta=0.6, gamma=0.02, theta=-0.03, vega=0.15),\n            \"leg_2\": Greeks(delta=-0.4, gamma=0.01, theta=-0.02, vega=0.10),\n        }\n        result = pos.greeks(leg_greeks)\n        # leg_1 BUY: +1*10 * (0.6, 0.02, -0.03, 0.15) = (6, 0.2, -0.3, 1.5)\n        # leg_2 SELL: -1*10 * (-0.4, 0.01, -0.02, 0.10) = (4, -0.1, 0.2, -1.0)\n        # total: (10, 0.1, -0.1, 0.5)\n        assert abs(result.delta - 10.0) < 1e-10\n        assert abs(result.gamma - 0.1) < 1e-10\n        assert abs(result.theta - (-0.1)) < 1e-10\n        assert abs(result.vega - 0.5) < 1e-10\n"
  },
  {
    "path": "tests/portfolio/test_property_based.py",
    "content": "\"\"\"Property-based tests for portfolio position and portfolio invariants.\"\"\"\n\nfrom hypothesis import given, settings, assume\nfrom hypothesis import strategies as st\n\nfrom options_portfolio_backtester.core.types import Direction, OptionType, Order\nfrom options_portfolio_backtester.portfolio.portfolio import Portfolio\nfrom options_portfolio_backtester.portfolio.position import PositionLeg, OptionPosition\n\n\n# ---------------------------------------------------------------------------\n# Strategies\n# ---------------------------------------------------------------------------\n\nprice = st.floats(min_value=0.01, max_value=10000.0, allow_nan=False, allow_infinity=False)\nquantity_int = st.integers(min_value=0, max_value=10000)\ncash_amount = st.floats(min_value=0.0, max_value=1e8, allow_nan=False, allow_infinity=False)\nspc = st.sampled_from([1, 10, 100])\n\n\n# ---------------------------------------------------------------------------\n# Position properties\n# ---------------------------------------------------------------------------\n\nclass TestPositionProperties:\n    @given(price, spc)\n    @settings(max_examples=50)\n    def test_zero_quantity_zero_value(self, p, shares_per_contract):\n        \"\"\"Position with quantity 0 has value 0.\"\"\"\n        pos = OptionPosition(position_id=0, quantity=0)\n        pos.add_leg(PositionLeg(\n            name=\"leg_1\", contract_id=\"C1\", underlying=\"SPY\",\n            expiration=\"2024-01-19\", option_type=OptionType.CALL,\n            strike=500.0, entry_price=5.0, direction=Direction.BUY,\n            order=Order.BTO,\n        ))\n        val = pos.current_value({\"leg_1\": p}, shares_per_contract)\n        assert val == 0.0\n\n    @given(quantity_int, price, spc)\n    @settings(max_examples=50)\n    def test_buy_leg_value_formula(self, qty, p, shares_per_contract):\n        \"\"\"BUY leg value = +1 * price * quantity * shares_per_contract.\"\"\"\n        pos = OptionPosition(position_id=0, quantity=qty)\n        pos.add_leg(PositionLeg(\n            name=\"leg_1\", contract_id=\"C1\", underlying=\"SPY\",\n            expiration=\"2024-01-19\", option_type=OptionType.CALL,\n            strike=500.0, entry_price=5.0, direction=Direction.BUY,\n            order=Order.BTO,\n        ))\n        val = pos.current_value({\"leg_1\": p}, shares_per_contract)\n        expected = p * qty * shares_per_contract\n        assert abs(val - expected) < 1e-6\n\n    @given(quantity_int, price, spc)\n    @settings(max_examples=50)\n    def test_sell_leg_value_formula(self, qty, p, shares_per_contract):\n        \"\"\"SELL leg value = -1 * price * quantity * shares_per_contract.\"\"\"\n        pos = OptionPosition(position_id=0, quantity=qty)\n        pos.add_leg(PositionLeg(\n            name=\"leg_1\", contract_id=\"P1\", underlying=\"SPY\",\n            expiration=\"2024-01-19\", option_type=OptionType.PUT,\n            strike=400.0, entry_price=3.0, direction=Direction.SELL,\n            order=Order.STO,\n        ))\n        val = pos.current_value({\"leg_1\": p}, shares_per_contract)\n        expected = -p * qty * shares_per_contract\n        assert abs(val - expected) < 1e-6\n\n\n# ---------------------------------------------------------------------------\n# Portfolio properties\n# ---------------------------------------------------------------------------\n\nclass TestPortfolioProperties:\n    @given(cash_amount, price, quantity_int)\n    @settings(max_examples=50)\n    def test_total_value_is_sum(self, cash, stock_price, qty):\n        \"\"\"Total value = cash + sum of stock values.\"\"\"\n        p = Portfolio(initial_cash=cash)\n        if qty > 0:\n            p.set_stock_holding(\"SPY\", qty, stock_price)\n        total = p.total_value({\"SPY\": stock_price}, {}, 100)\n        expected = cash + qty * stock_price\n        assert abs(total - expected) < 1e-4\n\n    @given(cash_amount, quantity_int, price, spc)\n    @settings(max_examples=50)\n    def test_add_remove_position_roundtrip(self, cash, qty, p, shares_per_contract):\n        \"\"\"Adding then removing a position returns to original state.\"\"\"\n        portfolio = Portfolio(initial_cash=cash)\n        original_total = portfolio.total_value({}, {}, shares_per_contract)\n\n        pos = OptionPosition(position_id=portfolio.next_position_id(), quantity=qty)\n        pos.add_leg(PositionLeg(\n            name=\"leg_1\", contract_id=\"C1\", underlying=\"SPY\",\n            expiration=\"2024-01-19\", option_type=OptionType.CALL,\n            strike=500.0, entry_price=5.0, direction=Direction.BUY,\n            order=Order.BTO,\n        ))\n        portfolio.add_option_position(pos)\n        portfolio.remove_option_position(pos.position_id)\n\n        after_total = portfolio.total_value({}, {}, shares_per_contract)\n        assert abs(after_total - original_total) < 1e-10\n\n    @given(cash_amount, price, quantity_int)\n    @settings(max_examples=50)\n    def test_portfolio_value_non_negative(self, cash, stock_price, qty):\n        \"\"\"Portfolio value >= 0 when all inputs are non-negative.\"\"\"\n        p = Portfolio(initial_cash=cash)\n        if qty > 0:\n            p.set_stock_holding(\"SPY\", qty, stock_price)\n        total = p.total_value({\"SPY\": stock_price}, {}, 100)\n        assert total >= -1e-10\n"
  },
  {
    "path": "tests/portfolio/test_risk.py",
    "content": "\"\"\"Tests for risk management.\"\"\"\n\nfrom options_portfolio_backtester.core.types import Greeks\nfrom options_portfolio_backtester.portfolio.risk import (\n    RiskManager, MaxDelta, MaxVega, MaxDrawdown,\n)\n\n\nclass TestMaxDelta:\n    def test_within_limit(self):\n        c = MaxDelta(limit=100.0)\n        assert c.check(Greeks(delta=50.0), Greeks(delta=30.0), 1e6, 1e6) is True\n\n    def test_exceeds_limit(self):\n        c = MaxDelta(limit=100.0)\n        assert c.check(Greeks(delta=80.0), Greeks(delta=30.0), 1e6, 1e6) is False\n\n    def test_negative_delta(self):\n        c = MaxDelta(limit=100.0)\n        assert c.check(Greeks(delta=-80.0), Greeks(delta=-30.0), 1e6, 1e6) is False\n\n\nclass TestMaxVega:\n    def test_within_limit(self):\n        c = MaxVega(limit=50.0)\n        assert c.check(Greeks(vega=20.0), Greeks(vega=10.0), 1e6, 1e6) is True\n\n    def test_exceeds_limit(self):\n        c = MaxVega(limit=50.0)\n        assert c.check(Greeks(vega=40.0), Greeks(vega=20.0), 1e6, 1e6) is False\n\n\nclass TestMaxDrawdown:\n    def test_no_drawdown(self):\n        c = MaxDrawdown(max_dd_pct=0.20)\n        assert c.check(Greeks(), Greeks(), 1e6, 1e6) is True\n\n    def test_within_limit(self):\n        c = MaxDrawdown(max_dd_pct=0.20)\n        assert c.check(Greeks(), Greeks(), 900_000, 1_000_000) is True\n\n    def test_exceeds_limit(self):\n        c = MaxDrawdown(max_dd_pct=0.20)\n        assert c.check(Greeks(), Greeks(), 790_000, 1_000_000) is False\n\n\nclass TestRiskManager:\n    def test_empty_allows(self):\n        rm = RiskManager()\n        allowed, reason = rm.is_allowed(Greeks(), Greeks(), 1e6, 1e6)\n        assert allowed is True\n        assert reason == \"\"\n\n    def test_single_constraint_blocks(self):\n        rm = RiskManager([MaxDelta(limit=10.0)])\n        allowed, reason = rm.is_allowed(Greeks(delta=8.0), Greeks(delta=5.0), 1e6, 1e6)\n        assert allowed is False\n        assert \"MaxDelta\" in reason\n\n    def test_multiple_constraints_first_blocks(self):\n        rm = RiskManager([MaxDelta(limit=100.0), MaxDrawdown(max_dd_pct=0.10)])\n        allowed, reason = rm.is_allowed(Greeks(delta=50.0), Greeks(delta=10.0),\n                                        850_000, 1_000_000)\n        assert allowed is False\n        assert \"MaxDrawdown\" in reason\n\n    def test_add_constraint(self):\n        rm = RiskManager()\n        rm.add_constraint(MaxVega(limit=5.0))\n        allowed, _ = rm.is_allowed(Greeks(vega=3.0), Greeks(vega=3.0), 1e6, 1e6)\n        assert allowed is False\n"
  },
  {
    "path": "tests/strategy/__init__.py",
    "content": ""
  },
  {
    "path": "tests/strategy/test_presets.py",
    "content": "\"\"\"Tests for strategy preset constructors.\"\"\"\n\nimport math\n\nfrom options_portfolio_backtester.core.types import Direction, OptionType\nfrom options_portfolio_backtester.data.schema import Schema\nfrom options_portfolio_backtester.strategy.presets import (\n    strangle, iron_condor, covered_call, cash_secured_put, collar, butterfly,\n)\n\n\ndef _options_schema():\n    s = Schema.options()\n    s.update({\n        \"contract\": \"optionroot\",\n        \"date\": \"quotedate\",\n        \"dte\": \"dte\",\n        \"last\": \"last\",\n        \"open_interest\": \"openinterest\",\n        \"impliedvol\": \"impliedvol\",\n        \"delta\": \"delta\",\n        \"gamma\": \"gamma\",\n        \"theta\": \"theta\",\n        \"vega\": \"vega\",\n    })\n    return s\n\n\nclass TestStrangleFunction:\n    def test_creates_two_legs(self):\n        s = strangle(_options_schema(), \"SPY\", Direction.SELL,\n                     dte_range=(30, 60), dte_exit=7)\n        assert len(s.legs) == 2\n\n    def test_leg_types(self):\n        s = strangle(_options_schema(), \"SPY\", Direction.BUY,\n                     dte_range=(30, 60), dte_exit=7)\n        assert s.legs[0].type == OptionType.CALL\n        assert s.legs[1].type == OptionType.PUT\n\n    def test_leg_directions_match_input(self):\n        s = strangle(_options_schema(), \"SPY\", Direction.SELL,\n                     dte_range=(30, 60), dte_exit=7)\n        assert s.legs[0].direction == Direction.SELL\n        assert s.legs[1].direction == Direction.SELL\n\n    def test_exit_thresholds(self):\n        s = strangle(_options_schema(), \"SPY\", Direction.SELL,\n                     dte_range=(30, 60), dte_exit=7,\n                     exit_thresholds=(0.5, 0.3))\n        assert s.exit_thresholds == (0.5, 0.3)\n\n    def test_default_exit_thresholds_are_inf(self):\n        s = strangle(_options_schema(), \"SPY\", Direction.SELL,\n                     dte_range=(30, 60), dte_exit=7)\n        assert s.exit_thresholds == (math.inf, math.inf)\n\n\nclass TestIronCondor:\n    def test_creates_four_legs(self):\n        s = iron_condor(_options_schema(), \"SPY\",\n                        dte_range=(30, 60), dte_exit=7)\n        assert len(s.legs) == 4\n\n    def test_leg_directions(self):\n        s = iron_condor(_options_schema(), \"SPY\",\n                        dte_range=(30, 60), dte_exit=7)\n        # short call, long call, short put, long put\n        assert s.legs[0].direction == Direction.SELL\n        assert s.legs[1].direction == Direction.BUY\n        assert s.legs[2].direction == Direction.SELL\n        assert s.legs[3].direction == Direction.BUY\n\n    def test_leg_types(self):\n        s = iron_condor(_options_schema(), \"SPY\",\n                        dte_range=(30, 60), dte_exit=7)\n        assert s.legs[0].type == OptionType.CALL\n        assert s.legs[1].type == OptionType.CALL\n        assert s.legs[2].type == OptionType.PUT\n        assert s.legs[3].type == OptionType.PUT\n\n\nclass TestCoveredCall:\n    def test_creates_one_leg(self):\n        s = covered_call(_options_schema(), \"SPY\",\n                         dte_range=(30, 60), dte_exit=7)\n        assert len(s.legs) == 1\n\n    def test_leg_is_sell_call(self):\n        s = covered_call(_options_schema(), \"SPY\",\n                         dte_range=(30, 60), dte_exit=7)\n        assert s.legs[0].direction == Direction.SELL\n        assert s.legs[0].type == OptionType.CALL\n\n\nclass TestCashSecuredPut:\n    def test_creates_one_leg(self):\n        s = cash_secured_put(_options_schema(), \"SPY\",\n                             dte_range=(30, 60), dte_exit=7)\n        assert len(s.legs) == 1\n\n    def test_leg_is_sell_put(self):\n        s = cash_secured_put(_options_schema(), \"SPY\",\n                             dte_range=(30, 60), dte_exit=7)\n        assert s.legs[0].direction == Direction.SELL\n        assert s.legs[0].type == OptionType.PUT\n\n\nclass TestCollar:\n    def test_creates_two_legs(self):\n        s = collar(_options_schema(), \"SPY\",\n                   dte_range=(30, 60), dte_exit=7)\n        assert len(s.legs) == 2\n\n    def test_leg_types_and_directions(self):\n        s = collar(_options_schema(), \"SPY\",\n                   dte_range=(30, 60), dte_exit=7)\n        # short call + long put\n        assert s.legs[0].direction == Direction.SELL\n        assert s.legs[0].type == OptionType.CALL\n        assert s.legs[1].direction == Direction.BUY\n        assert s.legs[1].type == OptionType.PUT\n\n\nclass TestButterfly:\n    def test_creates_three_legs(self):\n        s = butterfly(_options_schema(), \"SPY\",\n                      dte_range=(30, 60), dte_exit=7)\n        assert len(s.legs) == 3\n\n    def test_default_type_is_call(self):\n        s = butterfly(_options_schema(), \"SPY\",\n                      dte_range=(30, 60), dte_exit=7)\n        for leg in s.legs:\n            assert leg.type == OptionType.CALL\n\n    def test_put_butterfly(self):\n        s = butterfly(_options_schema(), \"SPY\",\n                      dte_range=(30, 60), dte_exit=7,\n                      option_type=OptionType.PUT)\n        for leg in s.legs:\n            assert leg.type == OptionType.PUT\n\n    def test_directions(self):\n        s = butterfly(_options_schema(), \"SPY\",\n                      dte_range=(30, 60), dte_exit=7)\n        # buy lower, sell middle, buy upper\n        assert s.legs[0].direction == Direction.BUY\n        assert s.legs[1].direction == Direction.SELL\n        assert s.legs[2].direction == Direction.BUY\n\n    def test_entry_sort_on_wings(self):\n        s = butterfly(_options_schema(), \"SPY\",\n                      dte_range=(30, 60), dte_exit=7)\n        assert s.legs[0].entry_sort == (\"strike\", True)   # ascending\n        assert s.legs[2].entry_sort == (\"strike\", False)   # descending\n"
  },
  {
    "path": "tests/strategy/test_strangle.py",
    "content": "\"\"\"Tests for Strangle preset strategy.\"\"\"\n\nimport pytest\n\nfrom options_portfolio_backtester.strategy.presets import Strangle\nfrom options_portfolio_backtester.data.schema import Schema\nfrom options_portfolio_backtester.core.types import OptionType as Type, Direction\n\n\n@pytest.fixture\ndef schema():\n    s = Schema.options()\n    s.update({'dte': 'dte'})\n    return s\n\n\nclass TestStrangle:\n    def test_long_strangle_creates_buy_call_and_buy_put(self, schema):\n        s = Strangle(schema, \"long\", \"SPX\", (30, 60), 10)\n        assert len(s.legs) == 2\n        assert s.legs[0].type == Type.CALL\n        assert s.legs[0].direction == Direction.BUY\n        assert s.legs[1].type == Type.PUT\n        assert s.legs[1].direction == Direction.BUY\n\n    def test_short_strangle_creates_sell_call_and_sell_put(self, schema):\n        s = Strangle(schema, \"short\", \"SPX\", (30, 60), 10)\n        assert len(s.legs) == 2\n        assert s.legs[0].type == Type.CALL\n        assert s.legs[0].direction == Direction.SELL\n        assert s.legs[1].type == Type.PUT\n        assert s.legs[1].direction == Direction.SELL\n\n    def test_invalid_name_asserts(self, schema):\n        with pytest.raises(AssertionError):\n            Strangle(schema, \"invalid\", \"SPX\", (30, 60), 10)\n\n    def test_exit_thresholds_propagate(self, schema):\n        s = Strangle(schema, \"long\", \"SPX\", (30, 60), 10, exit_thresholds=(0.5, 0.3))\n        assert s.exit_thresholds == (0.5, 0.3)\n\n    def test_case_insensitive_name(self, schema):\n        s = Strangle(schema, \"Long\", \"SPX\", (30, 60), 10)\n        assert s.legs[0].direction == Direction.BUY\n        s2 = Strangle(schema, \"SHORT\", \"SPX\", (30, 60), 10)\n        assert s2.legs[0].direction == Direction.SELL\n"
  },
  {
    "path": "tests/strategy/test_strategy.py",
    "content": "\"\"\"Tests for Strategy class: adding/removing legs, thresholds.\"\"\"\n\nimport math\n\nimport pytest\nimport numpy as np\nimport pandas as pd\n\nfrom options_portfolio_backtester.strategy.strategy import Strategy\nfrom options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\nfrom options_portfolio_backtester.data.schema import Schema\nfrom options_portfolio_backtester.core.types import OptionType as Type, Direction\n\n\n@pytest.fixture\ndef schema():\n    return Schema.options()\n\n\n@pytest.fixture\ndef strategy(schema):\n    return Strategy(schema)\n\n\n@pytest.fixture\ndef make_leg(schema):\n    def _make(option_type=Type.CALL, direction=Direction.BUY):\n        return StrategyLeg(\"leg\", schema, option_type=option_type, direction=direction)\n    return _make\n\n\nclass TestAddLeg:\n    def test_add_one_leg(self, strategy, make_leg):\n        leg = make_leg()\n        strategy.add_leg(leg)\n        assert len(strategy.legs) == 1\n        assert strategy.legs[0].name == \"leg_1\"\n\n    def test_add_two_legs(self, strategy, make_leg):\n        strategy.add_leg(make_leg())\n        strategy.add_leg(make_leg(Type.PUT))\n        assert len(strategy.legs) == 2\n        assert strategy.legs[0].name == \"leg_1\"\n        assert strategy.legs[1].name == \"leg_2\"\n\n    def test_add_legs_batch(self, strategy, make_leg):\n        legs = [make_leg(), make_leg(Type.PUT)]\n        strategy.add_legs(legs)\n        assert len(strategy.legs) == 2\n\n    def test_add_leg_schema_mismatch_asserts(self, strategy):\n        other_schema = Schema.options()\n        other_schema.update({\"underlying\": \"different_col\"})\n        leg = StrategyLeg(\"leg\", other_schema)\n        with pytest.raises(AssertionError):\n            strategy.add_leg(leg)\n\n\nclass TestRemoveLeg:\n    def test_remove_leg(self, strategy, make_leg):\n        strategy.add_legs([make_leg(), make_leg(Type.PUT)])\n        strategy.remove_leg(0)\n        assert len(strategy.legs) == 1\n\n    def test_clear_legs(self, strategy, make_leg):\n        strategy.add_legs([make_leg(), make_leg(Type.PUT)])\n        strategy.clear_legs()\n        assert len(strategy.legs) == 0\n\n\nclass TestExitThresholds:\n    def test_default_thresholds(self, strategy):\n        assert strategy.exit_thresholds == (math.inf, math.inf)\n\n    def test_set_valid_thresholds(self, strategy):\n        strategy.add_exit_thresholds(0.5, 0.3)\n        assert strategy.exit_thresholds == (0.5, 0.3)\n\n    def test_negative_profit_asserts(self, strategy):\n        with pytest.raises(AssertionError):\n            strategy.add_exit_thresholds(-0.1, 0.3)\n\n    def test_negative_loss_asserts(self, strategy):\n        with pytest.raises(AssertionError):\n            strategy.add_exit_thresholds(0.5, -0.1)\n\n\nclass TestFilterThresholds:\n    def test_within_bounds_no_exit(self, strategy):\n        strategy.add_exit_thresholds(0.5, 0.5)\n        entry_cost = pd.Series([100.0])\n        current_cost = pd.Series([-110.0])\n        result = strategy.filter_thresholds(entry_cost, current_cost)\n        assert not result.any()\n\n    def test_profit_exceeded(self, strategy):\n        strategy.add_exit_thresholds(0.5, 0.5)\n        entry_cost = pd.Series([100.0])\n        current_cost = pd.Series([-200.0])\n        result = strategy.filter_thresholds(entry_cost, current_cost)\n        assert result.all()\n\n    def test_loss_exceeded(self, strategy):\n        strategy.add_exit_thresholds(0.5, 0.5)\n        entry_cost = pd.Series([100.0])\n        current_cost = pd.Series([-10.0])\n        result = strategy.filter_thresholds(entry_cost, current_cost)\n        assert result.all()\n"
  },
  {
    "path": "tests/strategy/test_strategy_deep.py",
    "content": "\"\"\"Deep strategy & risk tests — presets, multi-leg construction, portfolio, positions, Greeks.\n\nTests strategy construction edge cases, preset validation, risk constraint\nboundary conditions, and position/portfolio accounting.\n\"\"\"\n\nimport math\nimport os\n\nimport numpy as np\nimport pandas as pd\nimport pytest\n\nfrom options_portfolio_backtester.core.types import (\n    Direction,\n    OptionType,\n    Order,\n    Signal,\n    Greeks,\n    Fill,\n    Stock,\n    get_order,\n)\nfrom options_portfolio_backtester.data.providers import HistoricalOptionsData\nfrom options_portfolio_backtester.data.schema import Schema, Field, Filter\nfrom options_portfolio_backtester.strategy.strategy import Strategy\nfrom options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\nfrom options_portfolio_backtester.strategy.presets import (\n    strangle,\n    iron_condor,\n    covered_call,\n    cash_secured_put,\n    collar,\n    butterfly,\n    Strangle,\n)\nfrom options_portfolio_backtester.portfolio.risk import (\n    RiskManager,\n    MaxDelta,\n    MaxVega,\n    MaxDrawdown,\n)\nfrom options_portfolio_backtester.portfolio.portfolio import Portfolio, StockHolding\nfrom options_portfolio_backtester.portfolio.position import OptionPosition, PositionLeg\nfrom options_portfolio_backtester.portfolio.greeks import aggregate_greeks\n\nTEST_DIR = os.path.join(os.path.dirname(__file__), \"..\", \"test_data\")\nOPTIONS_FILE = os.path.join(TEST_DIR, \"options_data.csv\")\n\n\n@pytest.fixture\ndef schema():\n    data = HistoricalOptionsData(OPTIONS_FILE)\n    return data.schema\n\n\n# ---------------------------------------------------------------------------\n# Strategy preset construction\n# ---------------------------------------------------------------------------\n\n\nclass TestStranglePreset:\n    def test_has_two_legs(self, schema):\n        s = strangle(schema, \"SPX\", Direction.BUY, (30, 60), 14)\n        assert len(s.legs) == 2\n\n    def test_leg_types(self, schema):\n        s = strangle(schema, \"SPX\", Direction.BUY, (30, 60), 14)\n        types = {leg.type for leg in s.legs}\n        assert types == {OptionType.CALL, OptionType.PUT}\n\n    def test_leg_directions_long(self, schema):\n        s = strangle(schema, \"SPX\", Direction.BUY, (30, 60), 14)\n        for leg in s.legs:\n            assert leg.direction == Direction.BUY\n\n    def test_leg_directions_short(self, schema):\n        s = strangle(schema, \"SPX\", Direction.SELL, (30, 60), 14)\n        for leg in s.legs:\n            assert leg.direction == Direction.SELL\n\n    def test_exit_thresholds_applied(self, schema):\n        s = strangle(schema, \"SPX\", Direction.BUY, (30, 60), 14,\n                      exit_thresholds=(2.0, 0.5))\n        assert s.exit_thresholds == (2.0, 0.5)\n\n    def test_default_exit_thresholds(self, schema):\n        s = strangle(schema, \"SPX\", Direction.BUY, (30, 60), 14)\n        assert s.exit_thresholds == (float(\"inf\"), float(\"inf\"))\n\n\nclass TestIronCondorPreset:\n    def test_has_four_legs(self, schema):\n        s = iron_condor(schema, \"SPX\", (30, 60), 14)\n        assert len(s.legs) == 4\n\n    def test_two_sells_two_buys(self, schema):\n        s = iron_condor(schema, \"SPX\", (30, 60), 14)\n        sells = [l for l in s.legs if l.direction == Direction.SELL]\n        buys = [l for l in s.legs if l.direction == Direction.BUY]\n        assert len(sells) == 2\n        assert len(buys) == 2\n\n    def test_has_both_option_types(self, schema):\n        s = iron_condor(schema, \"SPX\", (30, 60), 14)\n        types = {l.type for l in s.legs}\n        assert types == {OptionType.CALL, OptionType.PUT}\n\n\nclass TestCoveredCallPreset:\n    def test_one_sell_call_leg(self, schema):\n        s = covered_call(schema, \"SPX\", (30, 60), 14)\n        assert len(s.legs) == 1\n        assert s.legs[0].direction == Direction.SELL\n        assert s.legs[0].type == OptionType.CALL\n\n    def test_otm_pct_applied(self, schema):\n        s1 = covered_call(schema, \"SPX\", (30, 60), 14, otm_pct=1.0)\n        s2 = covered_call(schema, \"SPX\", (30, 60), 14, otm_pct=5.0)\n        # Different OTM should produce different entry filters\n        assert s1.legs[0].entry_filter.query != s2.legs[0].entry_filter.query\n\n\nclass TestCashSecuredPutPreset:\n    def test_one_sell_put_leg(self, schema):\n        s = cash_secured_put(schema, \"SPX\", (30, 60), 14)\n        assert len(s.legs) == 1\n        assert s.legs[0].direction == Direction.SELL\n        assert s.legs[0].type == OptionType.PUT\n\n\nclass TestCollarPreset:\n    def test_two_legs(self, schema):\n        s = collar(schema, \"SPX\", (30, 60), 14)\n        assert len(s.legs) == 2\n\n    def test_short_call_long_put(self, schema):\n        s = collar(schema, \"SPX\", (30, 60), 14)\n        call_leg = [l for l in s.legs if l.type == OptionType.CALL][0]\n        put_leg = [l for l in s.legs if l.type == OptionType.PUT][0]\n        assert call_leg.direction == Direction.SELL\n        assert put_leg.direction == Direction.BUY\n\n\nclass TestButterflyPreset:\n    def test_three_legs(self, schema):\n        s = butterfly(schema, \"SPX\", (30, 60), 14)\n        assert len(s.legs) == 3\n\n    def test_buy_sell_buy_pattern(self, schema):\n        s = butterfly(schema, \"SPX\", (30, 60), 14)\n        dirs = [l.direction for l in s.legs]\n        assert dirs == [Direction.BUY, Direction.SELL, Direction.BUY]\n\n    def test_call_butterfly(self, schema):\n        s = butterfly(schema, \"SPX\", (30, 60), 14, option_type=OptionType.CALL)\n        for leg in s.legs:\n            assert leg.type == OptionType.CALL\n\n    def test_put_butterfly(self, schema):\n        s = butterfly(schema, \"SPX\", (30, 60), 14, option_type=OptionType.PUT)\n        for leg in s.legs:\n            assert leg.type == OptionType.PUT\n\n    def test_lower_wing_has_asc_sort(self, schema):\n        s = butterfly(schema, \"SPX\", (30, 60), 14)\n        assert s.legs[0].entry_sort == (\"strike\", True)\n\n    def test_upper_wing_has_desc_sort(self, schema):\n        s = butterfly(schema, \"SPX\", (30, 60), 14)\n        assert s.legs[2].entry_sort == (\"strike\", False)\n\n\nclass TestStrangleClassBased:\n    def test_long_strangle(self, schema):\n        s = Strangle(schema, \"long\", \"SPX\", (30, 60), 14)\n        assert len(s.legs) == 2\n        for leg in s.legs:\n            assert leg.direction == Direction.BUY\n\n    def test_short_strangle(self, schema):\n        s = Strangle(schema, \"short\", \"SPX\", (30, 60), 14)\n        for leg in s.legs:\n            assert leg.direction == Direction.SELL\n\n    def test_invalid_name_raises(self, schema):\n        with pytest.raises(AssertionError):\n            Strangle(schema, \"neutral\", \"SPX\", (30, 60), 14)\n\n\n# ---------------------------------------------------------------------------\n# Strategy operations\n# ---------------------------------------------------------------------------\n\n\nclass TestStrategyOperations:\n    def test_add_and_remove_leg(self, schema):\n        s = Strategy(schema)\n        leg = StrategyLeg(\"x\", schema, option_type=OptionType.PUT, direction=Direction.BUY)\n        leg.entry_filter = schema.underlying == \"SPX\"\n        leg.exit_filter = schema.dte <= 30\n        s.add_leg(leg)\n        assert len(s.legs) == 1\n        s.remove_leg(0)\n        assert len(s.legs) == 0\n\n    def test_clear_legs(self, schema):\n        s = strangle(schema, \"SPX\", Direction.BUY, (30, 60), 14)\n        assert len(s.legs) == 2\n        s.clear_legs()\n        assert len(s.legs) == 0\n\n    def test_exit_thresholds_validation(self, schema):\n        s = Strategy(schema)\n        with pytest.raises(AssertionError):\n            s.add_exit_thresholds(profit_pct=-1.0)\n        with pytest.raises(AssertionError):\n            s.add_exit_thresholds(loss_pct=-0.5)\n\n    def test_filter_thresholds_series(self, schema):\n        s = Strategy(schema)\n        s.add_exit_thresholds(profit_pct=0.5, loss_pct=0.3)\n        entry = pd.Series([-100.0, -200.0, -50.0])\n        current = pd.Series([-50.0, -300.0, -25.0])\n        result = s.filter_thresholds(entry, current)\n        assert isinstance(result, pd.Series)\n        assert result.dtype == bool\n\n\n# ---------------------------------------------------------------------------\n# Strategy leg entry/exit filters\n# ---------------------------------------------------------------------------\n\n\nclass TestStrategyLegFilters:\n    def test_base_entry_filter_buy_requires_ask_gt_zero(self, schema):\n        leg = StrategyLeg(\"x\", schema, option_type=OptionType.PUT, direction=Direction.BUY)\n        assert \"ask > 0\" in leg.entry_filter.query\n\n    def test_base_entry_filter_sell_requires_bid_gt_zero(self, schema):\n        leg = StrategyLeg(\"x\", schema, option_type=OptionType.PUT, direction=Direction.SELL)\n        assert \"bid > 0\" in leg.entry_filter.query\n\n    def test_custom_entry_filter_combines_with_base(self, schema):\n        leg = StrategyLeg(\"x\", schema, option_type=OptionType.CALL, direction=Direction.BUY)\n        leg.entry_filter = schema.dte >= 30\n        assert \"ask > 0\" in leg.entry_filter.query\n        assert \"dte >= 30\" in leg.entry_filter.query\n\n    def test_exit_filter_includes_type(self, schema):\n        leg = StrategyLeg(\"x\", schema, option_type=OptionType.PUT, direction=Direction.BUY)\n        assert \"put\" in leg.exit_filter.query\n\n\n# ---------------------------------------------------------------------------\n# Risk constraints — boundary conditions\n# ---------------------------------------------------------------------------\n\n\nclass TestMaxDeltaConstraint:\n    def test_within_limit_allowed(self):\n        c = MaxDelta(limit=100.0)\n        assert c.check(Greeks(delta=50), Greeks(delta=30), 1e6, 1e6) is True\n\n    def test_at_limit_allowed(self):\n        c = MaxDelta(limit=100.0)\n        assert c.check(Greeks(delta=50), Greeks(delta=50), 1e6, 1e6) is True\n\n    def test_exceeds_limit_blocked(self):\n        c = MaxDelta(limit=100.0)\n        assert c.check(Greeks(delta=90), Greeks(delta=20), 1e6, 1e6) is False\n\n    def test_negative_delta(self):\n        c = MaxDelta(limit=50.0)\n        # -30 + -30 = -60, abs = 60 > 50\n        assert c.check(Greeks(delta=-30), Greeks(delta=-30), 1e6, 1e6) is False\n\n    def test_describe(self):\n        c = MaxDelta(limit=50.0)\n        assert \"50.0\" in c.describe()\n\n\nclass TestMaxVegaConstraint:\n    def test_within_limit(self):\n        c = MaxVega(limit=100.0)\n        assert c.check(Greeks(vega=40), Greeks(vega=40), 1e6, 1e6) is True\n\n    def test_exceeds_limit(self):\n        c = MaxVega(limit=50.0)\n        assert c.check(Greeks(vega=30), Greeks(vega=30), 1e6, 1e6) is False\n\n\nclass TestMaxDrawdownConstraint:\n    def test_no_drawdown_allowed(self):\n        c = MaxDrawdown(max_dd_pct=0.20)\n        assert c.check(Greeks(), Greeks(), 1e6, 1e6) is True\n\n    def test_at_drawdown_limit(self):\n        c = MaxDrawdown(max_dd_pct=0.20)\n        # dd = (1e6 - 800000) / 1e6 = 0.20 → NOT blocked (< not <=)\n        assert c.check(Greeks(), Greeks(), 800_000, 1e6) is False\n\n    def test_beyond_drawdown(self):\n        c = MaxDrawdown(max_dd_pct=0.20)\n        assert c.check(Greeks(), Greeks(), 700_000, 1e6) is False\n\n    def test_peak_is_zero(self):\n        c = MaxDrawdown(max_dd_pct=0.20)\n        assert c.check(Greeks(), Greeks(), 100, 0) is True\n\n\nclass TestRiskManagerComposite:\n    def test_empty_constraints_allows_all(self):\n        rm = RiskManager()\n        ok, reason = rm.is_allowed(Greeks(), Greeks(), 1e6, 1e6)\n        assert ok is True\n        assert reason == \"\"\n\n    def test_single_violation_blocks(self):\n        rm = RiskManager([MaxDelta(limit=10)])\n        ok, reason = rm.is_allowed(Greeks(delta=50), Greeks(delta=50), 1e6, 1e6)\n        assert ok is False\n        assert \"MaxDelta\" in reason\n\n    def test_first_failure_reported(self):\n        rm = RiskManager([MaxDelta(limit=10), MaxVega(limit=10)])\n        ok, reason = rm.is_allowed(\n            Greeks(delta=50, vega=50), Greeks(delta=50, vega=50), 1e6, 1e6\n        )\n        assert ok is False\n        assert \"MaxDelta\" in reason  # first constraint to fail\n\n    def test_all_pass(self):\n        rm = RiskManager([MaxDelta(limit=1000), MaxVega(limit=1000)])\n        ok, _ = rm.is_allowed(Greeks(delta=1, vega=1), Greeks(delta=1, vega=1), 1e6, 1e6)\n        assert ok is True\n\n\n# ---------------------------------------------------------------------------\n# Greeks algebra\n# ---------------------------------------------------------------------------\n\n\nclass TestGreeksAlgebra:\n    def test_addition(self):\n        g1 = Greeks(delta=1, gamma=2, theta=3, vega=4)\n        g2 = Greeks(delta=10, gamma=20, theta=30, vega=40)\n        result = g1 + g2\n        assert result.delta == 11\n        assert result.gamma == 22\n        assert result.theta == 33\n        assert result.vega == 44\n\n    def test_scalar_multiplication(self):\n        g = Greeks(delta=1, gamma=2, theta=3, vega=4)\n        result = g * 3\n        assert result.delta == 3\n        assert result.vega == 12\n\n    def test_rmul(self):\n        g = Greeks(delta=1, gamma=2, theta=3, vega=4)\n        result = 3 * g\n        assert result == g * 3\n\n    def test_negation(self):\n        g = Greeks(delta=10, gamma=5, theta=-3, vega=1)\n        neg = -g\n        assert neg.delta == -10\n        assert neg.theta == 3\n\n    def test_as_dict(self):\n        g = Greeks(delta=1, gamma=2, theta=3, vega=4)\n        d = g.as_dict\n        assert d[\"delta\"] == 1\n        assert len(d) == 4\n\n\n# ---------------------------------------------------------------------------\n# Order mapping\n# ---------------------------------------------------------------------------\n\n\nclass TestOrderMapping:\n    def test_buy_entry_bto(self):\n        assert get_order(Direction.BUY, Signal.ENTRY) == Order.BTO\n\n    def test_buy_exit_stc(self):\n        assert get_order(Direction.BUY, Signal.EXIT) == Order.STC\n\n    def test_sell_entry_sto(self):\n        assert get_order(Direction.SELL, Signal.ENTRY) == Order.STO\n\n    def test_sell_exit_btc(self):\n        assert get_order(Direction.SELL, Signal.EXIT) == Order.BTC\n\n    def test_order_inversion(self):\n        assert ~Order.BTO == Order.STC\n        assert ~Order.STC == Order.BTO\n        assert ~Order.STO == Order.BTC\n        assert ~Order.BTC == Order.STO\n\n\nclass TestDirectionInversion:\n    def test_buy_inverts_to_sell(self):\n        assert ~Direction.BUY == Direction.SELL\n\n    def test_sell_inverts_to_buy(self):\n        assert ~Direction.SELL == Direction.BUY\n\n\nclass TestOptionTypeInversion:\n    def test_call_inverts_to_put(self):\n        assert ~OptionType.CALL == OptionType.PUT\n\n    def test_put_inverts_to_call(self):\n        assert ~OptionType.PUT == OptionType.CALL\n\n\n# ---------------------------------------------------------------------------\n# Fill dataclass\n# ---------------------------------------------------------------------------\n\n\nclass TestFillNotional:\n    def test_buy_fill_negative_notional(self):\n        f = Fill(price=5.0, quantity=10, direction=Direction.BUY)\n        # BUY → sign=-1, notional = -1 * 5 * 10 * 100 = -5000\n        assert f.notional == -5000.0\n\n    def test_sell_fill_positive_notional(self):\n        f = Fill(price=5.0, quantity=10, direction=Direction.SELL)\n        assert f.notional == 5000.0\n\n    def test_commission_deducted(self):\n        f = Fill(price=5.0, quantity=10, direction=Direction.BUY, commission=50.0)\n        assert f.notional == -5050.0\n\n    def test_slippage_deducted(self):\n        f = Fill(price=5.0, quantity=10, direction=Direction.SELL, slippage=100.0)\n        assert f.notional == 4900.0\n\n\n# ---------------------------------------------------------------------------\n# Portfolio and Position\n# ---------------------------------------------------------------------------\n\n\nclass TestPortfolio:\n    def test_initial_cash(self):\n        p = Portfolio(initial_cash=100_000)\n        assert p.cash == 100_000\n\n    def test_add_remove_option_position(self):\n        p = Portfolio()\n        pos = OptionPosition(position_id=0, quantity=10)\n        p.add_option_position(pos)\n        assert 0 in p.option_positions\n        removed = p.remove_option_position(0)\n        assert removed is pos\n        assert 0 not in p.option_positions\n\n    def test_remove_nonexistent_returns_none(self):\n        p = Portfolio()\n        assert p.remove_option_position(999) is None\n\n    def test_stock_holdings(self):\n        p = Portfolio()\n        p.set_stock_holding(\"AAPL\", 100, 150.0)\n        assert p.stock_holdings[\"AAPL\"].quantity == 100\n        assert p.stocks_value({\"AAPL\": 160.0}) == 16_000.0\n\n    def test_clear_stock_holdings(self):\n        p = Portfolio()\n        p.set_stock_holding(\"AAPL\", 100, 150.0)\n        p.clear_stock_holdings()\n        assert len(p.stock_holdings) == 0\n\n    def test_total_value(self):\n        p = Portfolio(initial_cash=10_000)\n        p.set_stock_holding(\"AAPL\", 100, 150.0)\n        total = p.total_value(\n            stock_prices={\"AAPL\": 160.0},\n            option_prices={},\n            shares_per_contract=100,\n        )\n        assert total == 10_000 + 16_000\n\n    def test_next_position_id_increments(self):\n        p = Portfolio()\n        assert p.next_position_id() == 0\n        assert p.next_position_id() == 1\n        assert p.next_position_id() == 2\n\n\nclass TestPositionLeg:\n    def test_buy_leg_positive_value(self):\n        leg = PositionLeg(\n            name=\"leg_1\", contract_id=\"SPX1\", underlying=\"SPX\",\n            expiration=pd.Timestamp(\"2025-01-01\"), option_type=OptionType.PUT,\n            strike=100.0, entry_price=5.0, direction=Direction.BUY,\n            order=Order.BTO,\n        )\n        # BUY: value = +1 * current_price * qty * spc\n        value = leg.current_value(current_price=6.0, quantity=10, shares_per_contract=100)\n        assert value == 6000.0\n\n    def test_sell_leg_negative_value(self):\n        leg = PositionLeg(\n            name=\"leg_1\", contract_id=\"SPX1\", underlying=\"SPX\",\n            expiration=pd.Timestamp(\"2025-01-01\"), option_type=OptionType.PUT,\n            strike=100.0, entry_price=5.0, direction=Direction.SELL,\n            order=Order.STO,\n        )\n        value = leg.current_value(current_price=6.0, quantity=10, shares_per_contract=100)\n        assert value == -6000.0\n\n    def test_exit_order(self):\n        leg = PositionLeg(\n            name=\"x\", contract_id=\"X\", underlying=\"SPX\",\n            expiration=pd.Timestamp(\"2025-01-01\"), option_type=OptionType.CALL,\n            strike=100.0, entry_price=5.0, direction=Direction.BUY, order=Order.BTO,\n        )\n        assert leg.exit_order == Order.STC\n\n\nclass TestOptionPosition:\n    def test_multi_leg_value(self):\n        pos = OptionPosition(position_id=0, quantity=10)\n        pos.add_leg(PositionLeg(\n            \"call\", \"C1\", \"SPX\", pd.Timestamp(\"2025-01-01\"),\n            OptionType.CALL, 100.0, 3.0, Direction.BUY, Order.BTO,\n        ))\n        pos.add_leg(PositionLeg(\n            \"put\", \"P1\", \"SPX\", pd.Timestamp(\"2025-01-01\"),\n            OptionType.PUT, 100.0, 2.0, Direction.SELL, Order.STO,\n        ))\n        value = pos.current_value({\"call\": 4.0, \"put\": 3.0}, shares_per_contract=100)\n        # call: +4*10*100=4000, put: -3*10*100=-3000\n        assert value == 1000.0\n\n    def test_greeks_aggregation(self):\n        pos = OptionPosition(position_id=0, quantity=5)\n        pos.add_leg(PositionLeg(\n            \"call\", \"C1\", \"SPX\", pd.Timestamp(\"2025-01-01\"),\n            OptionType.CALL, 100.0, 3.0, Direction.BUY, Order.BTO,\n        ))\n        pos.add_leg(PositionLeg(\n            \"put\", \"P1\", \"SPX\", pd.Timestamp(\"2025-01-01\"),\n            OptionType.PUT, 100.0, 2.0, Direction.SELL, Order.STO,\n        ))\n        greeks = pos.greeks({\n            \"call\": Greeks(delta=0.5, gamma=0.02, theta=-0.01, vega=0.1),\n            \"put\": Greeks(delta=-0.3, gamma=0.01, theta=-0.02, vega=0.05),\n        })\n        # call: BUY → sign=+1, qty=5: delta=0.5*5=2.5\n        # put: SELL → sign=-1, qty=5: delta=-0.3*(-1)*5=1.5\n        assert abs(greeks.delta - 4.0) < 1e-10\n\n\n# ---------------------------------------------------------------------------\n# Portfolio-level Greeks aggregation\n# ---------------------------------------------------------------------------\n\n\nclass TestAggregateGreeks:\n    def test_empty_portfolio(self):\n        g = aggregate_greeks({}, {})\n        assert g.delta == 0.0\n\n    def test_single_position(self):\n        pos = OptionPosition(position_id=0, quantity=1)\n        pos.add_leg(PositionLeg(\n            \"leg_1\", \"C1\", \"SPX\", pd.Timestamp(\"2025-01-01\"),\n            OptionType.CALL, 100.0, 5.0, Direction.BUY, Order.BTO,\n        ))\n        greeks_map = {0: {\"leg_1\": Greeks(delta=0.5, gamma=0.02, theta=-0.01, vega=0.1)}}\n        result = aggregate_greeks({0: pos}, greeks_map)\n        assert abs(result.delta - 0.5) < 1e-10\n\n    def test_multiple_positions(self):\n        p1 = OptionPosition(position_id=0, quantity=10)\n        p1.add_leg(PositionLeg(\n            \"leg_1\", \"C1\", \"SPX\", pd.Timestamp(\"2025-01-01\"),\n            OptionType.CALL, 100.0, 5.0, Direction.BUY, Order.BTO,\n        ))\n        p2 = OptionPosition(position_id=1, quantity=5)\n        p2.add_leg(PositionLeg(\n            \"leg_1\", \"P1\", \"SPX\", pd.Timestamp(\"2025-01-01\"),\n            OptionType.PUT, 100.0, 3.0, Direction.BUY, Order.BTO,\n        ))\n        greeks_map = {\n            0: {\"leg_1\": Greeks(delta=0.5)},\n            1: {\"leg_1\": Greeks(delta=-0.3)},\n        }\n        result = aggregate_greeks({0: p1, 1: p2}, greeks_map)\n        # p1: BUY, qty=10, delta=0.5*1*10 = 5.0\n        # p2: BUY, qty=5, delta=-0.3*1*5 = -1.5\n        assert abs(result.delta - 3.5) < 1e-10\n"
  },
  {
    "path": "tests/strategy/test_strategy_leg.py",
    "content": "\"\"\"Tests for StrategyLeg: entry/exit filters, custom filters.\"\"\"\n\nimport pandas as pd\n\nfrom options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\nfrom options_portfolio_backtester.data.schema import Schema\nfrom options_portfolio_backtester.core.types import OptionType as Type, Direction\n\n\ndef make_options_df():\n    \"\"\"Minimal options DataFrame for testing filters.\"\"\"\n    return pd.DataFrame({\n        'type': ['call', 'put', 'call', 'put'],\n        'ask': [1.5, 2.0, 0.0, 1.0],\n        'bid': [1.0, 1.5, 0.5, 0.0],\n    })\n\n\nclass TestDefaultEntryFilter:\n    def test_buy_call_filters_calls_with_positive_ask(self):\n        schema = Schema.options()\n        leg = StrategyLeg(\"leg_1\", schema, option_type=Type.CALL, direction=Direction.BUY)\n        df = make_options_df()\n        result = df[leg.entry_filter(df)]\n        # Should match rows where type=='call' AND ask > 0 => row 0 only (row 2 has ask=0)\n        assert len(result) == 1\n        assert result.iloc[0]['ask'] == 1.5\n\n    def test_sell_put_filters_puts_with_positive_bid(self):\n        schema = Schema.options()\n        leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.SELL)\n        df = make_options_df()\n        result = df[leg.entry_filter(df)]\n        # Should match rows where type=='put' AND bid > 0 => row 1 only (row 3 has bid=0)\n        assert len(result) == 1\n        assert result.iloc[0]['bid'] == 1.5\n\n    def test_buy_put_filters_puts_with_positive_ask(self):\n        schema = Schema.options()\n        leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\n        df = make_options_df()\n        result = df[leg.entry_filter(df)]\n        # type=='put' AND ask > 0 => rows 1, 3\n        assert len(result) == 2\n\n    def test_sell_call_filters_calls_with_positive_bid(self):\n        schema = Schema.options()\n        leg = StrategyLeg(\"leg_1\", schema, option_type=Type.CALL, direction=Direction.SELL)\n        df = make_options_df()\n        result = df[leg.entry_filter(df)]\n        # type=='call' AND bid > 0 => rows 0, 2\n        assert len(result) == 2\n\n\nclass TestDefaultExitFilter:\n    def test_exit_filter_matches_type(self):\n        schema = Schema.options()\n        leg = StrategyLeg(\"leg_1\", schema, option_type=Type.CALL, direction=Direction.BUY)\n        df = make_options_df()\n        result = df[leg.exit_filter(df)]\n        # Should match all calls (rows 0, 2)\n        assert len(result) == 2\n        assert (result['type'] == 'call').all()\n\n\nclass TestCustomFilter:\n    def test_custom_entry_filter_is_anded_with_base(self):\n        schema = Schema.options()\n        leg = StrategyLeg(\"leg_1\", schema, option_type=Type.CALL, direction=Direction.BUY)\n        # Add a custom filter: ask > 1.0\n        leg.entry_filter = schema.ask > 1.0\n        df = make_options_df()\n        result = df[leg.entry_filter(df)]\n        # Base: type=='call' AND ask > 0. Custom AND'd: ask > 1.0\n        # Row 0: type=call, ask=1.5 => matches (1.5 > 0 and 1.5 > 1.0)\n        # Row 2: type=call, ask=0.0 => no (ask not > 0)\n        assert len(result) == 1\n\n    def test_custom_exit_filter_is_anded_with_base(self):\n        schema = Schema.options()\n        leg = StrategyLeg(\"leg_1\", schema, option_type=Type.PUT, direction=Direction.BUY)\n        # Custom exit: bid > 1.0\n        leg.exit_filter = schema.bid > 1.0\n        df = make_options_df()\n        result = df[leg.exit_filter(df)]\n        # Base: type=='put'. Custom AND'd: bid > 1.0\n        # Row 1: type=put, bid=1.5 => matches\n        # Row 3: type=put, bid=0.0 => no\n        assert len(result) == 1\n"
  },
  {
    "path": "tests/strategy/test_strategy_pbt.py",
    "content": "\"\"\"Property-based tests for strategies, risk constraints, and Greeks algebra.\n\nFuzzes strategy preset construction, risk constraint monotonicity and composition,\nand Greeks vector-space properties with Hypothesis.\n\"\"\"\n\nimport numpy as np\nimport pandas as pd\nimport pytest\nfrom hypothesis import given, settings, assume, HealthCheck\nfrom hypothesis import strategies as st\n\nfrom options_portfolio_backtester.core.types import (\n    Direction, OptionType, Order, Signal, Greeks, Fill, get_order,\n)\nfrom options_portfolio_backtester.portfolio.risk import (\n    MaxDelta, MaxVega, MaxDrawdown, RiskConstraint, RiskManager,\n)\nfrom options_portfolio_backtester.strategy.strategy import Strategy\nfrom options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\nfrom options_portfolio_backtester.strategy.presets import (\n    strangle, iron_condor, covered_call, cash_secured_put, collar, butterfly,\n    Strangle,\n)\nfrom options_portfolio_backtester.data.schema import Schema\n\n# ---------------------------------------------------------------------------\n# Hypothesis strategies\n# ---------------------------------------------------------------------------\n\ngreek_float = st.floats(min_value=-1000, max_value=1000, allow_nan=False, allow_infinity=False)\nscalar = st.floats(min_value=-100, max_value=100, allow_nan=False, allow_infinity=False)\npositive_float = st.floats(min_value=0.01, max_value=1e6, allow_nan=False, allow_infinity=False)\nlimit_float = st.floats(min_value=0.01, max_value=1e4, allow_nan=False, allow_infinity=False)\ndd_pct = st.floats(min_value=0.01, max_value=0.99, allow_nan=False, allow_infinity=False)\ndirection = st.sampled_from([Direction.BUY, Direction.SELL])\noption_type = st.sampled_from([OptionType.CALL, OptionType.PUT])\nsignal = st.sampled_from([Signal.ENTRY, Signal.EXIT])\ndte_min = st.integers(min_value=1, max_value=90)\ndte_exit = st.integers(min_value=0, max_value=30)\notm_pct = st.floats(min_value=0.0, max_value=20.0, allow_nan=False, allow_infinity=False)\npct_tol = st.floats(min_value=0.1, max_value=10.0, allow_nan=False, allow_infinity=False)\n\ngreeks_strat = st.builds(\n    Greeks,\n    delta=greek_float,\n    gamma=greek_float,\n    theta=greek_float,\n    vega=greek_float,\n)\n\n\ndef _options_schema():\n    s = Schema.options()\n    s.update({\n        \"contract\": \"optionroot\",\n        \"date\": \"quotedate\",\n        \"dte\": \"dte\",\n        \"last\": \"last\",\n        \"open_interest\": \"openinterest\",\n        \"impliedvol\": \"impliedvol\",\n        \"delta\": \"delta\",\n        \"gamma\": \"gamma\",\n        \"theta\": \"theta\",\n        \"vega\": \"vega\",\n    })\n    return s\n\n\n# ---------------------------------------------------------------------------\n# Greeks algebra — vector space properties\n# ---------------------------------------------------------------------------\n\n\nclass TestGreeksAlgebraPBT:\n    @given(greeks_strat, greeks_strat)\n    @settings(max_examples=200)\n    def test_addition_commutative(self, a, b):\n        r1 = a + b\n        r2 = b + a\n        assert abs(r1.delta - r2.delta) < 1e-10\n        assert abs(r1.gamma - r2.gamma) < 1e-10\n        assert abs(r1.theta - r2.theta) < 1e-10\n        assert abs(r1.vega - r2.vega) < 1e-10\n\n    @given(greeks_strat, greeks_strat, greeks_strat)\n    @settings(max_examples=200)\n    def test_addition_associative(self, a, b, c):\n        r1 = (a + b) + c\n        r2 = a + (b + c)\n        assert abs(r1.delta - r2.delta) < 1e-8\n        assert abs(r1.gamma - r2.gamma) < 1e-8\n        assert abs(r1.theta - r2.theta) < 1e-8\n        assert abs(r1.vega - r2.vega) < 1e-8\n\n    @given(greeks_strat)\n    @settings(max_examples=100)\n    def test_additive_identity(self, g):\n        zero = Greeks()\n        r = g + zero\n        assert abs(r.delta - g.delta) < 1e-10\n        assert abs(r.gamma - g.gamma) < 1e-10\n        assert abs(r.theta - g.theta) < 1e-10\n        assert abs(r.vega - g.vega) < 1e-10\n\n    @given(greeks_strat)\n    @settings(max_examples=100)\n    def test_additive_inverse(self, g):\n        \"\"\"g + (-g) == zero.\"\"\"\n        r = g + (-g)\n        assert abs(r.delta) < 1e-10\n        assert abs(r.gamma) < 1e-10\n        assert abs(r.theta) < 1e-10\n        assert abs(r.vega) < 1e-10\n\n    @given(greeks_strat, scalar)\n    @settings(max_examples=200)\n    def test_scalar_mul_distributes_over_components(self, g, s):\n        r = g * s\n        assert abs(r.delta - g.delta * s) < 1e-6\n        assert abs(r.gamma - g.gamma * s) < 1e-6\n        assert abs(r.theta - g.theta * s) < 1e-6\n        assert abs(r.vega - g.vega * s) < 1e-6\n\n    @given(greeks_strat, scalar, scalar)\n    @settings(max_examples=200)\n    def test_scalar_mul_composition(self, g, a, b):\n        \"\"\"(a * b) * g == a * (b * g) within tolerance.\"\"\"\n        assume(abs(a * b) < 1e6)\n        r1 = g * (a * b)\n        r2 = (g * b) * a\n        assert abs(r1.delta - r2.delta) < max(abs(r1.delta), 1) * 1e-6 + 1e-10\n        assert abs(r1.vega - r2.vega) < max(abs(r1.vega), 1) * 1e-6 + 1e-10\n\n    @given(greeks_strat, greeks_strat, scalar)\n    @settings(max_examples=200)\n    def test_scalar_mul_distributes_over_addition(self, a, b, s):\n        \"\"\"s * (a + b) == s*a + s*b.\"\"\"\n        r1 = (a + b) * s\n        r2 = (a * s) + (b * s)\n        assert abs(r1.delta - r2.delta) < max(abs(r1.delta), 1) * 1e-6 + 1e-10\n        assert abs(r1.gamma - r2.gamma) < max(abs(r1.gamma), 1) * 1e-6 + 1e-10\n\n    @given(greeks_strat, scalar)\n    @settings(max_examples=100)\n    def test_rmul_equals_mul(self, g, s):\n        \"\"\"s * g == g * s.\"\"\"\n        r1 = s * g\n        r2 = g * s\n        assert abs(r1.delta - r2.delta) < 1e-10\n        assert abs(r1.vega - r2.vega) < 1e-10\n\n    @given(greeks_strat)\n    @settings(max_examples=100)\n    def test_mul_by_one_is_identity(self, g):\n        r = g * 1.0\n        assert abs(r.delta - g.delta) < 1e-10\n        assert abs(r.vega - g.vega) < 1e-10\n\n    @given(greeks_strat)\n    @settings(max_examples=100)\n    def test_mul_by_zero_is_zero(self, g):\n        r = g * 0.0\n        assert abs(r.delta) < 1e-10\n        assert abs(r.gamma) < 1e-10\n        assert abs(r.theta) < 1e-10\n        assert abs(r.vega) < 1e-10\n\n    @given(greeks_strat)\n    @settings(max_examples=100)\n    def test_neg_is_mul_minus_one(self, g):\n        r1 = -g\n        r2 = g * -1.0\n        assert abs(r1.delta - r2.delta) < 1e-10\n        assert abs(r1.vega - r2.vega) < 1e-10\n\n    @given(greeks_strat)\n    @settings(max_examples=100)\n    def test_as_dict_roundtrip(self, g):\n        d = g.as_dict\n        reconstructed = Greeks(**d)\n        assert abs(reconstructed.delta - g.delta) < 1e-10\n        assert abs(reconstructed.gamma - g.gamma) < 1e-10\n        assert abs(reconstructed.theta - g.theta) < 1e-10\n        assert abs(reconstructed.vega - g.vega) < 1e-10\n\n\n# ---------------------------------------------------------------------------\n# Direction / Order / OptionType inversions\n# ---------------------------------------------------------------------------\n\n\nclass TestEnumInversionsPBT:\n    @given(direction)\n    @settings(max_examples=10)\n    def test_direction_double_invert(self, d):\n        assert ~~d == d\n\n    @given(option_type)\n    @settings(max_examples=10)\n    def test_option_type_double_invert(self, ot):\n        assert ~~ot == ot\n\n    @given(direction, signal)\n    @settings(max_examples=10)\n    def test_order_double_invert(self, d, s):\n        order = get_order(d, s)\n        assert ~~order == order\n\n    @given(direction)\n    @settings(max_examples=10)\n    def test_direction_invert_differs(self, d):\n        assert ~d != d\n\n    @given(option_type)\n    @settings(max_examples=10)\n    def test_option_type_invert_differs(self, ot):\n        assert ~ot != ot\n\n\n# ---------------------------------------------------------------------------\n# Fill value properties\n# ---------------------------------------------------------------------------\n\n\nclass TestFillPBT:\n    @given(st.floats(min_value=0.01, max_value=10_000, allow_nan=False, allow_infinity=False),\n           st.integers(min_value=1, max_value=10_000),\n           direction,\n           st.sampled_from([1, 10, 100, 1000]))\n    @settings(max_examples=200)\n    def test_buy_negative_sell_positive_notional(self, price, qty, d, spc):\n        \"\"\"BUY direction_sign = -1 (cash out), SELL direction_sign = +1 (cash in).\"\"\"\n        f = Fill(price=price, quantity=qty, direction=d, shares_per_contract=spc)\n        if d == Direction.BUY:\n            assert f.direction_sign == -1\n        else:\n            assert f.direction_sign == 1\n\n    @given(st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False),\n           st.integers(min_value=1, max_value=1000),\n           st.sampled_from([1, 10, 100]))\n    @settings(max_examples=200)\n    def test_sell_notional_exceeds_buy_notional(self, price, qty, spc):\n        \"\"\"With zero costs, sell notional > buy notional (opposite signs).\"\"\"\n        buy = Fill(price=price, quantity=qty, direction=Direction.BUY, shares_per_contract=spc)\n        sell = Fill(price=price, quantity=qty, direction=Direction.SELL, shares_per_contract=spc)\n        assert sell.notional > buy.notional\n\n    @given(st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False),\n           st.integers(min_value=1, max_value=1000),\n           direction,\n           st.sampled_from([1, 10, 100]),\n           st.floats(min_value=0.0, max_value=100, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=0.0, max_value=100, allow_nan=False, allow_infinity=False))\n    @settings(max_examples=200)\n    def test_commission_slippage_reduce_notional(self, price, qty, d, spc, comm, slip):\n        \"\"\"Adding commission/slippage always reduces notional.\"\"\"\n        f_clean = Fill(price=price, quantity=qty, direction=d, shares_per_contract=spc)\n        f_costs = Fill(price=price, quantity=qty, direction=d, shares_per_contract=spc,\n                       commission=comm, slippage=slip)\n        assert f_costs.notional <= f_clean.notional + 1e-10\n\n    @given(st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False),\n           st.integers(min_value=1, max_value=1000),\n           direction,\n           st.sampled_from([100]))\n    @settings(max_examples=100)\n    def test_notional_formula(self, price, qty, d, spc):\n        \"\"\"Verify notional = direction_sign * price * qty * spc - commission - slippage.\"\"\"\n        comm, slip = 5.0, 2.0\n        f = Fill(price=price, quantity=qty, direction=d, shares_per_contract=spc,\n                 commission=comm, slippage=slip)\n        expected = f.direction_sign * price * qty * spc - comm - slip\n        assert abs(f.notional - expected) < 1e-6\n\n\n# ---------------------------------------------------------------------------\n# Risk constraints — property-based\n# ---------------------------------------------------------------------------\n\n\nclass TestMaxDeltaPBT:\n    @given(limit_float, greeks_strat, greeks_strat, positive_float, positive_float)\n    @settings(max_examples=200)\n    def test_within_limit_passes(self, limit, current, proposed, pv, peak):\n        \"\"\"If |current.delta + proposed.delta| <= limit, check returns True.\"\"\"\n        new_delta = current.delta + proposed.delta\n        m = MaxDelta(limit=limit)\n        result = m.check(current, proposed, pv, peak)\n        if abs(new_delta) <= limit:\n            assert result is True\n        else:\n            assert result is False\n\n    @given(limit_float)\n    @settings(max_examples=50)\n    def test_zero_greeks_always_pass(self, limit):\n        m = MaxDelta(limit=limit)\n        zero = Greeks()\n        assert m.check(zero, zero, 100.0, 100.0) is True\n\n    @given(st.floats(min_value=0.01, max_value=100, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=0.01, max_value=100, allow_nan=False, allow_infinity=False))\n    @settings(max_examples=100)\n    def test_tighter_limit_blocks_more(self, tight, loose):\n        \"\"\"A tighter limit blocks at least as many trades as a looser one.\"\"\"\n        assume(tight < loose)\n        m_tight = MaxDelta(limit=tight)\n        m_loose = MaxDelta(limit=loose)\n        g = Greeks(delta=tight + 0.01)\n        proposed = Greeks()\n        # If tight blocks it, check that it makes sense\n        if not m_tight.check(g, proposed, 100, 100):\n            # loose may or may not block — but tight blocks\n            pass\n        if m_loose.check(g, proposed, 100, 100):\n            # loose passes → tight may or may not\n            pass\n\n\nclass TestMaxVegaPBT:\n    @given(limit_float, greeks_strat, greeks_strat, positive_float, positive_float)\n    @settings(max_examples=200)\n    def test_correctness(self, limit, current, proposed, pv, peak):\n        new_vega = current.vega + proposed.vega\n        m = MaxVega(limit=limit)\n        result = m.check(current, proposed, pv, peak)\n        if abs(new_vega) <= limit:\n            assert result is True\n        else:\n            assert result is False\n\n    @given(limit_float)\n    @settings(max_examples=50)\n    def test_zero_greeks_always_pass(self, limit):\n        m = MaxVega(limit=limit)\n        zero = Greeks()\n        assert m.check(zero, zero, 100.0, 100.0) is True\n\n\nclass TestMaxDrawdownPBT:\n    @given(dd_pct,\n           st.floats(min_value=1.0, max_value=1e6, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=1.0, max_value=1e6, allow_nan=False, allow_infinity=False))\n    @settings(max_examples=200)\n    def test_correctness(self, max_dd, pv, peak):\n        assume(peak > 0)\n        m = MaxDrawdown(max_dd_pct=max_dd)\n        dd = (peak - pv) / peak\n        result = m.check(Greeks(), Greeks(), pv, peak)\n        if dd < max_dd:\n            assert result is True\n        else:\n            assert result is False\n\n    @given(dd_pct, positive_float)\n    @settings(max_examples=100)\n    def test_at_peak_always_passes(self, max_dd, peak):\n        \"\"\"No drawdown at peak → always allowed.\"\"\"\n        m = MaxDrawdown(max_dd_pct=max_dd)\n        assert m.check(Greeks(), Greeks(), peak, peak) is True\n\n    @given(dd_pct)\n    @settings(max_examples=50)\n    def test_zero_peak_always_passes(self, max_dd):\n        m = MaxDrawdown(max_dd_pct=max_dd)\n        assert m.check(Greeks(), Greeks(), 50.0, 0.0) is True\n\n    @given(st.floats(min_value=0.01, max_value=0.49, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=100, max_value=1e6, allow_nan=False, allow_infinity=False))\n    @settings(max_examples=100)\n    def test_tighter_limit_blocks_more(self, tight, peak):\n        \"\"\"A tighter drawdown limit blocks at a higher portfolio value.\"\"\"\n        loose = tight + 0.1\n        pv = peak * (1 - (tight + 0.05))  # dd = tight + 0.05\n        m_tight = MaxDrawdown(max_dd_pct=tight)\n        m_loose = MaxDrawdown(max_dd_pct=loose)\n        assert m_tight.check(Greeks(), Greeks(), pv, peak) is False\n        assert m_loose.check(Greeks(), Greeks(), pv, peak) is True\n\n\nclass TestRiskManagerPBT:\n    @given(greeks_strat, greeks_strat, positive_float, positive_float)\n    @settings(max_examples=100)\n    def test_no_constraints_always_passes(self, current, proposed, pv, peak):\n        rm = RiskManager()\n        allowed, reason = rm.is_allowed(current, proposed, pv, peak)\n        assert allowed is True\n        assert reason == \"\"\n\n    @given(limit_float, limit_float, greeks_strat, greeks_strat, positive_float, positive_float)\n    @settings(max_examples=200)\n    def test_composite_is_conjunction(self, delta_limit, vega_limit, current, proposed, pv, peak):\n        \"\"\"RiskManager passes iff ALL individual constraints pass.\"\"\"\n        rm = RiskManager([MaxDelta(delta_limit), MaxVega(vega_limit)])\n        allowed, _ = rm.is_allowed(current, proposed, pv, peak)\n\n        delta_ok = MaxDelta(delta_limit).check(current, proposed, pv, peak)\n        vega_ok = MaxVega(vega_limit).check(current, proposed, pv, peak)\n\n        assert allowed == (delta_ok and vega_ok)\n\n    @given(limit_float, limit_float, dd_pct, greeks_strat, greeks_strat,\n           st.floats(min_value=1.0, max_value=1e6, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=1.0, max_value=1e6, allow_nan=False, allow_infinity=False))\n    @settings(max_examples=200)\n    def test_triple_constraint_conjunction(self, dl, vl, ddp, curr, prop, pv, peak):\n        assume(peak > 0)\n        rm = RiskManager([MaxDelta(dl), MaxVega(vl), MaxDrawdown(ddp)])\n        allowed, _ = rm.is_allowed(curr, prop, pv, peak)\n\n        d_ok = MaxDelta(dl).check(curr, prop, pv, peak)\n        v_ok = MaxVega(vl).check(curr, prop, pv, peak)\n        dd_ok = MaxDrawdown(ddp).check(curr, prop, pv, peak)\n\n        assert allowed == (d_ok and v_ok and dd_ok)\n\n    @given(greeks_strat, greeks_strat, positive_float, positive_float)\n    @settings(max_examples=50)\n    def test_adding_constraints_only_restricts(self, current, proposed, pv, peak):\n        \"\"\"Adding a constraint can block but never unblock.\"\"\"\n        rm1 = RiskManager([MaxDelta(50)])\n        rm2 = RiskManager([MaxDelta(50), MaxVega(50)])\n        a1, _ = rm1.is_allowed(current, proposed, pv, peak)\n        a2, _ = rm2.is_allowed(current, proposed, pv, peak)\n        if a2:\n            assert a1  # if composite passes, each individual must pass too\n\n\n# ---------------------------------------------------------------------------\n# Strategy presets — property-based\n# ---------------------------------------------------------------------------\n\n\nclass TestStrategyPresetsPBT:\n    @given(direction, dte_min, dte_exit, otm_pct, pct_tol)\n    @settings(max_examples=100, suppress_health_check=[HealthCheck.too_slow])\n    def test_strangle_always_two_legs(self, d, dte_lo, dte_ex, otm, tol):\n        assume(dte_lo > dte_ex)\n        schema = _options_schema()\n        s = strangle(schema, \"SPY\", d, (dte_lo, dte_lo + 30), dte_ex, otm, tol)\n        assert len(s.legs) == 2\n        types = {leg.type for leg in s.legs}\n        assert types == {OptionType.CALL, OptionType.PUT}\n\n    @given(dte_min, dte_exit)\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_iron_condor_four_legs(self, dte_lo, dte_ex):\n        assume(dte_lo > dte_ex)\n        schema = _options_schema()\n        s = iron_condor(schema, \"SPY\", (dte_lo, dte_lo + 30), dte_ex)\n        assert len(s.legs) == 4\n\n    @given(dte_min, dte_exit, otm_pct, pct_tol)\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_covered_call_one_leg(self, dte_lo, dte_ex, otm, tol):\n        assume(dte_lo > dte_ex)\n        schema = _options_schema()\n        s = covered_call(schema, \"SPY\", (dte_lo, dte_lo + 30), dte_ex, otm, tol)\n        assert len(s.legs) == 1\n        assert s.legs[0].type == OptionType.CALL\n        assert s.legs[0].direction == Direction.SELL\n\n    @given(dte_min, dte_exit, otm_pct, pct_tol)\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_cash_secured_put_one_leg(self, dte_lo, dte_ex, otm, tol):\n        assume(dte_lo > dte_ex)\n        schema = _options_schema()\n        s = cash_secured_put(schema, \"SPY\", (dte_lo, dte_lo + 30), dte_ex, otm, tol)\n        assert len(s.legs) == 1\n        assert s.legs[0].type == OptionType.PUT\n        assert s.legs[0].direction == Direction.SELL\n\n    @given(dte_min, dte_exit, otm_pct, otm_pct, pct_tol)\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_collar_two_legs(self, dte_lo, dte_ex, call_otm, put_otm, tol):\n        assume(dte_lo > dte_ex)\n        schema = _options_schema()\n        s = collar(schema, \"SPY\", (dte_lo, dte_lo + 30), dte_ex, call_otm, put_otm, tol)\n        assert len(s.legs) == 2\n        directions = {leg.direction for leg in s.legs}\n        assert directions == {Direction.BUY, Direction.SELL}\n\n    @given(dte_min, dte_exit, option_type)\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_butterfly_three_legs(self, dte_lo, dte_ex, ot):\n        assume(dte_lo > dte_ex)\n        schema = _options_schema()\n        s = butterfly(schema, \"SPY\", (dte_lo, dte_lo + 30), dte_ex, option_type=ot)\n        assert len(s.legs) == 3\n\n    @given(st.sampled_from([\"long\", \"short\"]),\n           dte_min, dte_exit, otm_pct, pct_tol)\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_strangle_class_matches_function(self, name, dte_lo, dte_ex, otm, tol):\n        \"\"\"Strangle class produces same leg count and types as strangle() function.\"\"\"\n        assume(dte_lo > dte_ex)\n        schema = _options_schema()\n        d = Direction.BUY if name == \"long\" else Direction.SELL\n        s_func = strangle(schema, \"SPY\", d, (dte_lo, dte_lo + 30), dte_ex, otm, tol)\n        s_cls = Strangle(schema, name, \"SPY\", (dte_lo, dte_lo + 30), dte_ex, otm, tol)\n        assert len(s_func.legs) == len(s_cls.legs)\n        for fl, cl in zip(s_func.legs, s_cls.legs):\n            assert fl.type == cl.type\n            assert fl.direction == cl.direction\n\n\nclass TestStrategyOperationsPBT:\n    @given(st.integers(min_value=1, max_value=8), direction, option_type)\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_add_remove_legs_preserves_length(self, n, d, ot):\n        \"\"\"Adding n legs then removing last gives n-1 legs.\"\"\"\n        schema = _options_schema()\n        s = Strategy(schema)\n        for i in range(n):\n            leg = StrategyLeg(f\"leg_{i}\", schema, option_type=ot, direction=d)\n            s.add_leg(leg)\n        assert len(s.legs) == n\n        s.legs.pop()\n        assert len(s.legs) == n - 1\n\n    @given(st.floats(min_value=0.01, max_value=100, allow_nan=False, allow_infinity=False),\n           st.floats(min_value=0.01, max_value=100, allow_nan=False, allow_infinity=False))\n    @settings(max_examples=50)\n    def test_exit_thresholds_stored(self, profit, loss):\n        schema = _options_schema()\n        s = Strategy(schema)\n        s.add_exit_thresholds(profit, loss)\n        assert s.exit_thresholds == (profit, loss)\n\n    @given(direction, option_type)\n    @settings(max_examples=20)\n    def test_clear_legs(self, d, ot):\n        schema = _options_schema()\n        s = Strategy(schema)\n        leg = StrategyLeg(\"leg_1\", schema, option_type=ot, direction=d)\n        s.add_leg(leg)\n        s.legs.clear()\n        assert len(s.legs) == 0\n"
  },
  {
    "path": "tests/test_cleanup.py",
    "content": "\"\"\"Tests for post-refactor cleanup — verify dead code removed, imports correct.\"\"\"\n\nimport importlib\n\n\ndef test_top_level_exports_trimmed():\n    \"\"\"__init__.py exports only core types, not pipeline bulk.\"\"\"\n    import options_portfolio_backtester as pkg\n    # Should be present\n    assert hasattr(pkg, \"BacktestEngine\")\n    assert hasattr(pkg, \"Stock\")\n    assert hasattr(pkg, \"Direction\")\n    assert hasattr(pkg, \"BacktestStats\")\n    assert hasattr(pkg, \"TradingClock\")\n    assert hasattr(pkg, \"summary\")\n    # Pipeline algos should NOT be in top-level\n    assert not hasattr(pkg, \"AlgoPipelineBacktester\")\n    assert not hasattr(pkg, \"RunMonthly\")\n    assert not hasattr(pkg, \"SelectAll\")\n    assert not hasattr(pkg, \"WeighEqually\")\n    assert not hasattr(pkg, \"Rebalance\")\n    assert not hasattr(pkg, \"EngineRunMonthly\")\n    assert not hasattr(pkg, \"StrategyTreeNode\")\n\n\ndef test_pipeline_importable_from_submodule():\n    \"\"\"Pipeline algos still importable from engine.pipeline.\"\"\"\n    from options_portfolio_backtester.engine.pipeline import (\n        AlgoPipelineBacktester,\n        RunMonthly, RunWeekly, RunDaily,\n        SelectAll, SelectThese,\n        WeighEqually, WeighInvVol,\n        LimitWeights, Rebalance,\n    )\n    assert callable(RunMonthly)\n    assert callable(SelectAll)\n    assert callable(WeighEqually)\n    assert AlgoPipelineBacktester is not None\n\n\ndef test_algo_adapters_importable_from_submodule():\n    \"\"\"Algo adapters still importable from engine.algo_adapters.\"\"\"\n    from options_portfolio_backtester.engine.algo_adapters import (\n        EngineAlgo, EngineStepDecision, EnginePipelineContext,\n        EngineRunMonthly, BudgetPercent, RangeFilter,\n        SelectByDelta, SelectByDTE, IVRankFilter,\n        MaxGreekExposure, ExitOnThreshold,\n    )\n    assert EngineAlgo is not None\n    assert EngineRunMonthly is not None\n\n\ndef test_strategy_tree_importable_from_submodule():\n    \"\"\"Strategy tree still importable from engine.strategy_tree.\"\"\"\n    from options_portfolio_backtester.engine.strategy_tree import (\n        StrategyTreeNode, StrategyTreeEngine,\n    )\n    assert StrategyTreeNode is not None\n    assert StrategyTreeEngine is not None\n\n\ndef test_compat_directory_removed():\n    \"\"\"compat/ directory should not exist.\"\"\"\n    import pytest\n    with pytest.raises(ModuleNotFoundError):\n        importlib.import_module(\"options_portfolio_backtester.compat\")\n    with pytest.raises(ModuleNotFoundError):\n        importlib.import_module(\"options_portfolio_backtester.compat.v0\")\n\n\ndef test_no_duplicate_import_in_engine():\n    \"\"\"engine.py should have Stock in the first import block, no duplicate.\"\"\"\n    from options_portfolio_backtester.engine.engine import BacktestEngine, Stock\n    assert Stock is not None\n    assert BacktestEngine is not None\n\n\ndef test_safe_ratio_removed():\n    \"\"\"_safe_ratio should not exist in stats module.\"\"\"\n    from options_portfolio_backtester.analytics import stats\n    assert not hasattr(stats, \"_safe_ratio\")\n\n\ndef test_rust_extension_importable():\n    \"\"\"Rust extension is importable.\"\"\"\n    from options_portfolio_backtester import _ob_rust\n    assert _ob_rust is not None\n"
  },
  {
    "path": "tests/test_data/ivy_5assets_data.csv",
    "content": ",symbol,date,close,high,low,open,volume,adjClose,adjHigh,adjLow,adjOpen,adjVolume,divCash,splitFactor\n1246,VTI,2014-12-15,102.59,104.18,102.2368,103.75,4328145,92.66455890799999,94.10072859959999,92.3455305211,93.7123305069,4328145,0.0,1.0\n1247,VTI,2014-12-16,101.83,104.01,101.8,102.03,7528271,91.97808786040001,93.94717586530001,91.9509903191,92.15873813610001,7528271,0.0,1.0\n1248,VTI,2014-12-17,104.01,104.21,102.01,102.25,4949023,93.94717586530001,94.127826141,92.14067310850001,92.3574534393,4949023,0.0,1.0\n1249,VTI,2014-12-18,106.47,106.47,105.0299,105.56,6384417,96.1691742561,96.1691742561,94.86840194610001,95.34721550180001,6384417,0.0,1.0\n1250,VTI,2014-12-19,106.91,107.25,106.38,106.61,3575627,96.5666048626,96.8737103312,96.087881632,96.2956294491,3575627,0.0,1.0\n1251,VTI,2014-12-22,106.77,106.78,106.28,106.66,5630224,96.94687369290001,96.9559536661,96.5019550068,96.8469939878,5630224,0.561,1.0\n1252,VTI,2014-12-23,107.01,107.24,106.82,107.24,4050508,97.1647930493,97.3736324326,96.9922735588,97.3736324326,4050508,0.0,1.0\n1253,VTI,2014-12-24,107.04,107.35,107.02,107.35,1702637,97.1920329689,97.4735121376,97.1738730225,97.4735121376,1702637,0.0,1.0\n1254,VTI,2014-12-26,107.36,107.63,107.27,107.31,3293504,97.4825921108,97.72775138680001,97.4008723521,97.4371922448,3293504,0.0,1.0\n1255,VTI,2014-12-29,107.59,107.705,107.29,107.36,3746681,97.691431494,97.7958511856,97.4190322985,97.4825921108,3746681,0.0,1.0\n1256,VTI,2014-12-30,107.06,107.46,106.99,107.42,4472965,97.21019291520001,97.5733918426,97.14663310290001,97.5370719499,4472965,0.0,1.0\n1257,VTI,2014-12-31,106.0,107.39,105.98,107.19,2224372,96.2477157577,97.5098320303,96.22955581129999,97.3282325666,2224372,0.0,1.0\n1258,VTI,2015-01-02,105.92,106.72,105.27,106.49,5298337,96.17507597219999,96.901473827,95.58487771520001,96.69263444370002,5298337,0.0,1.0\n1259,VTI,2015-01-05,104.1,105.55,103.86,105.35,5383515,94.5225208526,95.8391169643,94.3046014961,95.6575175006,5383515,0.0,1.0\n1260,VTI,2015-01-06,103.08,104.5,102.51,104.4,4226088,93.59636358770001,94.88571978,93.0788051162,94.7949200481,4226088,0.0,1.0\n1261,VTI,2015-01-07,104.31,104.45,103.55,104.09,3661477,94.7132002894,94.84031991399999,94.0231223274,94.51344087940001,3661477,0.0,1.0\n1262,VTI,2015-01-08,106.15,106.24,105.11,105.21,2796199,96.3839153554,96.4656351141,95.4395981442,95.53039787610001,2796199,0.0,1.0\n1263,VTI,2015-01-09,105.27,106.35,104.92,106.32,3272516,95.58487771520001,96.5655148191,95.26707865370001,96.5382748996,3272516,0.0,1.0\n1264,VTI,2015-01-12,104.52,105.55,104.12,105.55,3705861,94.90387972629999,95.8391169643,94.5406807989,95.8391169643,3705861,0.0,1.0\n1265,VTI,2015-01-13,104.28,106.0,103.43,105.33,2936211,94.6859603699,96.2477157577,93.9141626492,95.6393575543,2936211,0.0,1.0\n1266,VTI,2015-01-14,103.71,103.79,102.5,103.04,3291596,94.1684018984,94.24104168379999,93.069725143,93.560043695,3291596,0.0,1.0\n1267,VTI,2015-01-15,102.66,104.26,102.57,104.05,5615205,93.215004714,94.6678004235,93.13328495530001,94.4771209866,5615205,0.0,1.0\n1268,VTI,2015-01-16,104.0199,104.11,102.42,102.61,2768919,94.4497902674,94.5316008257,92.9970853575,93.16960484799999,2768919,0.0,1.0\n1269,VTI,2015-01-20,104.17,104.53,103.2,104.42,3186869,94.5860806649,94.9129596995,93.7053232659,94.8130799945,3186869,0.0,1.0\n1270,VTI,2015-01-21,104.64,104.95,103.6004,103.93,3119439,95.01283940450001,95.2943185733,94.06888539229999,94.3681613084,3119439,0.0,1.0\n1271,VTI,2015-01-22,106.23,106.31,104.27,105.24,3583462,96.45655514090001,96.5291949264,94.6768803967,95.5576377956,3583462,0.0,1.0\n1272,VTI,2015-01-23,105.75,106.28,105.675,106.11,4571863,96.02071642799999,96.5019550068,95.9526166292,96.3475954627,4571863,0.0,1.0\n1273,VTI,2015-01-26,106.19,106.22,105.2,105.75,4106519,96.4202352482,96.4474751677,95.52131790290001,96.02071642799999,4106519,0.0,1.0\n1274,VTI,2015-01-27,104.95,105.61,104.3601,105.37,2709263,95.2943185733,95.8935968035,94.75869095510001,95.67567744700001,2709263,0.0,1.0\n1275,VTI,2015-01-28,103.53,105.72,103.41,105.64,2830435,94.004962381,95.99347650850001,93.8960027028,95.920836723,2830435,0.0,1.0\n1276,VTI,2015-01-29,104.52,104.6199,102.82,103.7,2607177,94.90387972629999,94.9945886584,93.3602842849,94.1593219252,2607177,0.0,1.0\n1277,VTI,2015-01-30,103.1,104.51,102.95,103.67,4486875,93.6145235341,94.89479975309999,93.47832393629999,94.1320820056,4486875,0.0,1.0\n1278,VTI,2015-02-02,104.26,104.33,102.2549,103.49,4083160,94.6678004235,94.7313602358,92.84717500030001,93.9686424883,4083160,0.0,1.0\n1279,VTI,2015-02-03,105.86,105.87,104.71,104.9,3321993,96.1205961331,96.1296761063,95.0763992168,95.24891870729999,3321993,0.0,1.0\n1280,VTI,2015-02-04,105.49,106.14,105.24,105.44,3141740,95.7846371252,96.37483538219999,95.5576377956,95.7392372593,3141740,0.0,1.0\n1281,VTI,2015-02-05,106.65,106.72,105.84,105.94,3163473,96.83791401469999,96.901473827,96.1024361867,96.19323591850001,3163473,0.0,1.0\n1282,VTI,2015-02-06,106.33,107.175,106.0212,106.88,2844451,96.5473548727,97.3146126069,96.2669653008,97.0467533979,2844451,0.0,1.0\n1283,VTI,2015-02-09,105.8,106.35,105.59,105.99,1773849,96.066116294,96.5655148191,95.87543685709998,96.2386357845,1773849,0.0,1.0\n1284,VTI,2015-02-10,106.88,107.035,105.8428,106.5,1866282,97.0467533979,97.1874929823,96.1049785792,96.7017144169,1866282,0.0,1.0\n1285,VTI,2015-02-11,106.93,107.19,106.37,106.67,2184558,97.09215326379999,97.3282325666,96.58367476549999,96.856073961,2184558,0.0,1.0\n1286,VTI,2015-02-12,107.96,107.99,107.27,107.47,4443350,98.0273905019,98.05463042139999,97.4008723521,97.5824718158,4443350,0.0,1.0\n1287,VTI,2015-02-13,108.47,108.48,107.92,108.0,3147665,98.4904691343,98.4995491075,97.99107060909999,98.0637103946,3147665,0.0,1.0\n1288,VTI,2015-02-17,108.64,108.76,108.13,108.37,4146990,98.6448286784,98.7537883566,98.181750046,98.3996694024,4146990,0.0,1.0\n1289,VTI,2015-02-18,108.7,108.749,108.27,108.42,2542592,98.6993085175,98.7438003861,98.30886967059999,98.4450692683,2542592,0.0,1.0\n1290,VTI,2015-02-19,108.67,108.88,108.2601,108.39,2349039,98.672068598,98.86274803479999,98.29988049709999,98.4178293488,2349039,0.0,1.0\n1291,VTI,2015-02-20,109.28,109.3201,108.01,108.47,2402621,99.2259469622,99.2623576547,98.07279036780001,98.4904691343,2402621,0.0,1.0\n1292,VTI,2015-02-23,109.27,109.27,108.86,109.17,3033984,99.216866989,99.216866989,98.8445880885,99.12606725719999,3033984,0.0,1.0\n1293,VTI,2015-02-24,109.52,109.63,109.04,109.28,2856494,99.4438663187,99.54374602370001,99.0080276058,99.2259469622,2856494,0.0,1.0\n1294,VTI,2015-02-25,109.48,109.78,109.26,109.49,2652084,99.40754642590001,99.6799456215,99.2077870159,99.4166263991,2652084,0.0,1.0\n1295,VTI,2015-02-26,109.41,109.53,109.015,109.45,1741959,99.34398661360001,99.4529462918,98.98532767280001,99.38030650639999,1741959,0.0,1.0\n1296,VTI,2015-02-27,109.02,109.48,108.97,109.35,1825415,98.9898676594,99.40754642590001,98.94446779350001,99.28950677450001,1825415,0.0,1.0\n1297,VTI,2015-03-02,109.69,109.72,109.02,109.11,2681108,99.5982258628,99.6254657824,98.9898676594,99.0715874181,2681108,0.0,1.0\n1298,VTI,2015-03-03,109.24,109.61,108.74,109.47,2615721,99.1896270695,99.52558607729999,98.73562841030001,99.3984664527,2615721,0.0,1.0\n1299,VTI,2015-03-04,108.8,108.96,108.2,108.9,1949922,98.7901082494,98.93538782030001,98.2453098583,98.88090798120001,1949922,0.0,1.0\n1300,VTI,2015-03-05,108.97,109.1,108.63,109.03,1752506,98.94446779350001,99.06250744489999,98.6357487052,98.99894763260001,1752506,0.0,1.0\n1301,VTI,2015-03-06,107.46,108.66,107.24,108.47,2798958,97.5733918426,98.6629886248,97.3736324326,98.4904691343,2798958,0.0,1.0\n1302,VTI,2015-03-09,107.86,108.05,107.5199,107.6,1827946,97.93659077,98.10911026049999,97.62778088200001,97.70051146719999,1827946,0.0,1.0\n1303,VTI,2015-03-10,106.22,107.1,106.21,107.0,3543754,96.4474751677,97.24651280799999,96.4383951945,97.1557130761,3543754,0.0,1.0\n1304,VTI,2015-03-11,106.14,106.4938,105.98,106.43,3071330,96.37483538219999,96.69608483350001,96.22955581129999,96.63815460459999,3071330,0.0,1.0\n1305,VTI,2015-03-12,107.47,107.505,106.42,106.47,3907553,97.5824718158,97.6142517219,96.62907463139999,96.6744744973,3907553,0.0,1.0\n1306,VTI,2015-03-13,106.87,107.3999,106.1699,107.37,2073039,97.03767342469999,97.51882120379999,96.40198450209999,97.491672084,2073039,0.0,1.0\n3762,VEU,2014-12-15,45.75,46.73,45.64,46.6,3680611,39.131624923400004,39.969854266,39.0375379563,39.8586605777,3680611,0.0,1.0\n3763,VEU,2014-12-16,45.98,46.6,45.61,45.62,3456715,39.3283522181,39.8586605777,39.0118778744,39.020431235100006,3456715,0.0,1.0\n3764,VEU,2014-12-17,46.68,47.04,46.1,46.19,2951393,39.9270874628,40.2350084458,39.4309925457,39.5079727915,2951393,0.0,1.0\n3765,VEU,2014-12-18,47.5,47.515,47.08,47.09,2021845,40.6284630352,40.6412930762,40.269221888400004,40.277775249,2021845,0.0,1.0\n3766,VEU,2014-12-19,47.57,47.735,47.3401,47.43,3133925,40.6883365597,40.8294670102,40.4916947986,40.5685895107,3133925,0.0,1.0\n3767,VEU,2014-12-22,47.43,47.5,47.31,47.48,4822540,40.8927618789,40.9531138362,40.7893013808,40.9358704198,4822540,0.379,1.0\n3768,VEU,2014-12-23,47.34,47.42,47.22,47.36,2831127,40.8151665054,40.8841401708,40.7117060072,40.8324099217,2831127,0.0,1.0\n3769,VEU,2014-12-24,47.46,47.52,47.34,47.41,1692039,40.9186270035,40.9703572525,40.8151665054,40.8755184626,1692039,0.0,1.0\n3770,VEU,2014-12-26,47.68,47.81,47.67,47.71,1644700,41.1083045833,41.2203867896,41.0996828752,41.1341697079,1644700,0.0,1.0\n3771,VEU,2014-12-29,47.41,47.57,47.38,47.5,2643581,40.8755184626,41.0134657934,40.8496533381,40.9531138362,2643581,0.0,1.0\n3772,VEU,2014-12-30,47.07,47.25,47.07,47.23,2553514,40.5823803846,40.737571131799996,40.5823803846,40.720327715399996,2553514,0.0,1.0\n3773,VEU,2014-12-31,46.86,47.3,46.82,47.27,2835058,40.4013245129,40.780679672699996,40.3668376802,40.7548145481,2835058,0.0,1.0\n3774,VEU,2015-01-02,46.68,47.03,46.568999999999996,47.0,2297260,40.2461337657,40.5478935519,40.150432805,40.522028427399995,2297260,0.0,1.0\n3775,VEU,2015-01-05,45.65,46.23,45.56,46.17,6456406,39.3580978236,39.8581568978,39.28050245,39.8064266488,6456406,0.0,1.0\n3776,VEU,2015-01-06,45.24,45.79,45.06,45.69,2411783,39.0046077884,39.478801738099996,38.8494170412,39.3925846563,2411783,0.0,1.0\n3777,VEU,2015-01-07,45.74,45.81,45.36,45.69,2357186,39.435693197199996,39.4960451544,39.1080682865,39.3925846563,2357186,0.0,1.0\n3778,VEU,2015-01-08,46.38,46.5098,46.0611,46.07,1533949,39.9874825205,40.0993922926,39.7125362467,39.720209567,1533949,0.0,1.0\n3779,VEU,2015-01-09,46.12,46.45,45.94,46.45,2144861,39.7633181079,40.0478344777,39.6081273607,40.0478344777,2144861,0.0,1.0\n3780,VEU,2015-01-12,45.93,46.17,45.75,46.16,2920221,39.5995056525,39.8064266488,39.4443149054,39.7978049406,2920221,0.0,1.0\n3781,VEU,2015-01-13,46.18,46.61,45.81,46.5,1966832,39.8150483569,40.185781808499996,39.4960451544,40.0909430186,1966832,0.0,1.0\n3782,VEU,2015-01-14,46.0,46.06,45.62,45.85,1984397,39.6598576098,39.7115878588,39.3322326991,39.5305319871,1984397,0.0,1.0\n3783,VEU,2015-01-15,46.29,46.61,46.2,46.5,4249705,39.9098871469,40.185781808499996,39.8322917733,40.0909430186,4249705,0.0,1.0\n3784,VEU,2015-01-16,46.8,46.81,46.22,46.27,2063039,40.3495942638,40.358215971999996,39.8495351896,39.892643730500005,2063039,0.0,1.0\n3785,VEU,2015-01-20,46.85,47.04,46.685,46.88,1923820,40.3927028047,40.5565152601,40.2504446198,40.418567929299996,1923820,0.0,1.0\n3786,VEU,2015-01-21,47.31,47.31,46.86,47.04,2704319,40.7893013808,40.7893013808,40.4013245129,40.5565152601,2704319,0.0,1.0\n3787,VEU,2015-01-22,47.65,47.7399,47.17,47.35,2568057,41.0824394588,41.1599486153,40.6685974664,40.8237882135,2568057,0.0,1.0\n3788,VEU,2015-01-23,47.37,47.6499,47.36,47.51,2251760,40.841031629899994,41.0823532417,40.8324099217,40.9617355443,2251760,0.0,1.0\n3789,VEU,2015-01-26,47.83,47.95,47.51,47.65,2156539,41.237630206,41.3410907041,40.9617355443,41.0824394588,2156539,0.0,1.0\n3790,VEU,2015-01-27,47.81,47.9214,47.55,47.6,1969483,41.2203867896,41.3164326187,40.9962223771,41.0393309179,1969483,0.0,1.0\n3791,VEU,2015-01-28,47.15,47.86,47.11,47.86,2415354,40.65135405,41.2634953305,40.6168672173,41.2634953305,2415354,0.0,1.0\n3792,VEU,2015-01-29,47.61,47.64,47.218,47.47,1434194,41.0479526261,41.0738177506,40.7099816656,40.9272487116,1434194,0.0,1.0\n3793,VEU,2015-01-30,46.85,47.29,46.83,47.11,2932261,40.3927028047,40.7720579645,40.3754593884,40.6168672173,2932261,0.0,1.0\n3794,VEU,2015-02-02,47.48,47.58,47.09,47.22,2199359,40.9358704198,41.0220875016,40.599623801,40.7117060072,2199359,0.0,1.0\n3795,VEU,2015-02-03,48.22,48.285,47.75,47.79,1656096,41.5738768248,41.629917928000005,41.1686565406,41.2031433733,1656096,0.0,1.0\n3796,VEU,2015-02-04,47.83,48.2,47.81,47.96,1529367,41.237630206,41.5566334085,41.2203867896,41.3497124123,1529367,0.0,1.0\n3797,VEU,2015-02-05,48.42,48.45,48.03,48.06,1155895,41.746310988400005,41.7721761129,41.4100643695,41.435929494,1155895,0.0,1.0\n3798,VEU,2015-02-06,47.78,48.12,47.6548,48.02,2463120,41.194521665100005,41.4876597431,41.0865778787,41.4014426613,2463120,0.0,1.0\n3799,VEU,2015-02-09,47.65,47.77,47.51,47.57,1266007,41.0824394588,41.1858999569,40.9617355443,41.0134657934,1266007,0.0,1.0\n3800,VEU,2015-02-10,47.95,47.99,47.635,47.91,1177008,41.3410907041,41.3755775368,41.0695068965,41.3066038714,1177008,0.0,1.0\n3801,VEU,2015-02-11,47.68,47.79,47.45,47.61,1200128,41.1083045833,41.2031433733,40.9100052953,41.0479526261,1200128,0.0,1.0\n3802,VEU,2015-02-12,48.47,48.47,48.0101,48.12,1431850,41.7894195292,41.7894195292,41.3929071702,41.4876597431,1431850,0.0,1.0\n3803,VEU,2015-02-13,48.81,48.81,48.66,48.68,1639321,42.082557607199995,42.082557607199995,41.9532319846,41.9704754009,1639321,0.0,1.0\n3804,VEU,2015-02-17,48.94,48.99,48.59,48.78,1772853,42.1946398135,42.2377483544,41.8928800274,42.0566924827,1772853,0.0,1.0\n3805,VEU,2015-02-18,49.14,49.23,48.891000000000005,49.01,1811374,42.367073977,42.4446693506,42.152393443499996,42.2549917708,1811374,0.0,1.0\n3806,VEU,2015-02-19,49.1,49.2799,48.9901,49.08,1182369,42.3325871443,42.4876916744,42.2378345715,42.315343728,1182369,0.0,1.0\n3807,VEU,2015-02-20,49.54,49.64,48.905,49.04,1668535,42.7119423041,42.7981593858,42.1644638349,42.280856895300005,1668535,0.0,1.0\n3808,VEU,2015-02-23,49.29,49.3495,49.17,49.28,1642937,42.4963995997,42.5476987633,42.3929391016,42.4877778915,1642937,0.0,1.0\n3809,VEU,2015-02-24,49.65,49.7,49.21,49.34,1442331,42.806781094,42.849889634899995,42.4274259343,42.5395081406,1442331,0.0,1.0\n3810,VEU,2015-02-25,49.68,49.76,49.51,49.6,1126180,42.8326462185,42.901619884,42.6860771796,42.7636725531,1126180,0.0,1.0\n3811,VEU,2015-02-26,49.57,49.69,49.481,49.61,1035442,42.7378074286,42.8412679267,42.6610742258,42.772294261300004,1035442,0.0,1.0\n3812,VEU,2015-02-27,49.59,49.7843,49.52,49.62,1430607,42.755050845,42.9225706348,42.6946988877,42.7809159695,1430607,0.0,1.0\n3813,VEU,2015-03-02,49.62,49.62,49.4562,49.61,1925080,42.7809159695,42.7809159695,42.6396923896,42.772294261300004,1925080,0.0,1.0\n3814,VEU,2015-03-03,49.32,49.51,49.23,49.49,1325237,42.5222647242,42.6860771796,42.4446693506,42.6688337632,1325237,0.0,1.0\n3815,VEU,2015-03-04,49.11,49.13,48.7501,49.13,1142217,42.3412088525,42.358452268899995,42.030913575300005,42.358452268899995,1142217,0.0,1.0\n3816,VEU,2015-03-05,49.13,49.3099,49.04,49.27,950618,42.358452268899995,42.513556799,42.280856895300005,42.4791561833,950618,0.0,1.0\n3817,VEU,2015-03-06,48.47,48.86,48.4147,48.86,1310280,41.7894195292,42.1256661481,41.741741483,42.1256661481,1310280,0.0,1.0\n3818,VEU,2015-03-09,48.49,48.54,48.3835,48.5,1328227,41.8066629456,41.8497714865,41.7148417535,41.8152846538,1328227,0.0,1.0\n3819,VEU,2015-03-10,47.45,47.8,47.44,47.78,1767650,40.9100052953,41.2117650815,40.9013835871,41.194521665100005,1767650,0.0,1.0\n3820,VEU,2015-03-11,47.59,47.6993,47.43,47.56,1162698,41.0307092098,41.124944480100005,40.8927618789,41.0048440852,1162698,0.0,1.0\n3821,VEU,2015-03-12,48.12,48.215,47.972,48.14,1032866,41.4876597431,41.5695659708,41.3600584621,41.5049031594,1032866,0.0,1.0\n3822,VEU,2015-03-13,47.79,47.9,47.5299,47.9,1107505,41.2031433733,41.297982163200004,40.9788927436,41.297982163200004,1107505,0.0,1.0\n6278,BND,2014-12-15,82.75,82.8899,82.71,82.84,5511390,72.086181072,72.2080524525,72.0513357881,72.1645829608,5511390,0.0,1.0\n6279,BND,2014-12-16,82.94,82.98,82.79,82.98,6532792,72.2516961706,72.2865414545,72.1210263559,72.2865414545,6532792,0.0,1.0\n6280,BND,2014-12-17,82.74,82.96,82.68,82.91,3942429,72.07746975100001,72.2691188125,72.0252018252,72.2255622076,3942429,0.0,1.0\n6281,BND,2014-12-18,82.61,82.66,82.54,82.61,1625539,71.9642225784,72.0077791832,71.9032433315,71.9642225784,1625539,0.0,1.0\n6282,BND,2014-12-19,82.79,82.79,82.5815,82.6,2204817,72.1210263559,72.1210263559,71.9393953136,71.95551125739999,2204817,0.0,1.0\n6283,BND,2014-12-22,82.75,82.79,82.68,82.7,2157058,72.086181072,72.1210263559,72.0252018252,72.0426244671,2157058,0.0,1.0\n6284,BND,2014-12-23,82.01,82.32,82.0,82.29,6121208,71.77369117939999,72.0449976574,71.7649393575,72.0187421918,6121208,0.381283,1.0\n6285,BND,2014-12-24,82.04,82.04,81.85,81.99,2280240,71.799946645,71.799946645,71.6336620294,71.7561875356,2280240,0.0,1.0\n6286,BND,2014-12-26,82.11,82.19,82.0523,82.11,1300767,71.8612093981,71.9312239731,71.8107113859,71.8612093981,1300767,0.0,1.0\n6287,BND,2014-12-29,82.25,82.35,82.1572,82.24,2730569,71.98373490430001,72.0712531231,71.9025179974,71.9749830825,2730569,0.0,1.0\n6288,BND,2014-12-30,82.31,82.48,82.29,82.44,5854687,72.0362458356,72.18502680739999,72.0187421918,72.1500195199,5854687,0.0,1.0\n6289,BND,2014-12-31,82.37,82.45,82.35,82.4,1708929,72.0887567668,72.1587713418,72.0712531231,72.11501223239999,1708929,0.0,1.0\n6290,BND,2015-01-02,82.65,82.69,82.42,82.43,2218842,72.3338077792,72.3688150667,72.1325158762,72.14126769800001,2218842,0.0,1.0\n6291,BND,2015-01-05,82.89,82.92,82.7,82.74,5820072,72.5438515042,72.5701069698,72.3775668886,72.4125741761,5820072,0.0,1.0\n6292,BND,2015-01-06,83.13,83.38,83.03,83.03,3887617,72.7538952291,72.97269077600001,72.6663770104,72.6663770104,3887617,0.0,1.0\n6293,BND,2015-01-07,83.18,83.28,83.05,83.14,2433442,72.79765433850001,72.88517255720001,72.6838806542,72.762647051,2433442,0.0,1.0\n6294,BND,2015-01-08,83.05,83.11,82.97,83.11,1873446,72.6838806542,72.7363915854,72.6138660792,72.7363915854,1873446,0.0,1.0\n6295,BND,2015-01-09,83.19,83.2899,83.0,83.01,1646137,72.8064061604,72.8938368609,72.6401215448,72.6488733667,1646137,0.0,1.0\n6296,BND,2015-01-12,83.3,83.33,83.2,83.25,4397917,72.902676201,72.9289316666,72.8151579823,72.85891709159999,4397917,0.0,1.0\n6297,BND,2015-01-13,83.38,83.47,83.23,83.31,2223410,72.97269077600001,73.0514571728,72.8414134479,72.9114280229,2223410,0.0,1.0\n6298,BND,2015-01-14,83.57,83.72,83.54899999999999,83.64,2481202,73.1389753915,73.2702527196,73.12059656560001,73.20023814470001,2481202,0.0,1.0\n6299,BND,2015-01-15,83.94,83.96,83.55,83.55,4214468,73.4627928008,73.4802964446,73.1214717478,73.1214717478,4214468,0.0,1.0\n6300,BND,2015-01-16,83.69,83.85,83.58,83.81,3631251,73.24399725399999,73.384026404,73.1477272134,73.3490191165,3631251,0.0,1.0\n6301,BND,2015-01-20,83.6,83.83,83.57,83.7,3029537,73.1652308572,73.3665227602,73.1389753915,73.2527490759,3029537,0.0,1.0\n6302,BND,2015-01-21,83.44,83.8,83.43,83.79,2473875,73.0252017072,73.3402672946,73.01644988529999,73.33151547279999,2473875,0.0,1.0\n6303,BND,2015-01-22,83.46,83.63,83.37,83.63,2157439,73.04270535090001,73.1914863228,72.9639389541,73.1914863228,2157439,0.0,1.0\n6304,BND,2015-01-23,83.73,83.77,83.61,83.64,6255015,73.2790045415,73.314011829,73.17398267899999,73.20023814470001,6255015,0.0,1.0\n6305,BND,2015-01-26,83.63,83.749,83.58,83.73,7810486,73.1914863228,73.29563300310001,73.1477272134,73.2790045415,7810486,0.0,1.0\n6306,BND,2015-01-27,83.68,83.89,83.6265,83.86,1154115,73.2352454322,73.41903369149999,73.1884231851,73.3927782259,1154115,0.0,1.0\n6307,BND,2015-01-28,83.98,84.09,83.62,83.64,1394640,73.4978000883,73.5940701289,73.1827345009,73.20023814470001,1394640,0.0,1.0\n6308,BND,2015-01-29,83.89,83.971,83.76,83.94,1551255,73.41903369149999,73.4899234487,73.3052600071,73.4627928008,1551255,0.0,1.0\n6309,BND,2015-01-30,84.35,84.35,84.11,84.2,10474364,73.8216174976,73.8216174976,73.6115737727,73.6903401695,10474364,0.0,1.0\n6310,BND,2015-02-02,84.08,84.18,83.91,83.95,6070478,73.7341579161,73.8218531563,73.5850760079,73.6201541039,6070478,0.170067,1.0\n6311,BND,2015-02-03,83.82,83.99,83.774,83.93,1237234,73.5061502917,73.6552322,73.4658104813,73.6026150559,1237234,0.0,1.0\n6312,BND,2015-02-04,83.85,83.86,83.5631,83.65,2945688,73.5324588638,73.5412283878,73.2808612198,73.3570683835,2945688,0.0,1.0\n6313,BND,2015-02-05,83.67,83.76,83.61,83.69,2128152,73.3746074315,73.4535331476,73.3219902874,73.3921464795,2128152,0.0,1.0\n6314,BND,2015-02-06,83.13,83.42,83.11,83.38,2907733,72.9010531347,73.15536933109999,72.8835140867,73.1202912351,2907733,0.0,1.0\n6315,BND,2015-02-09,83.1,83.29,83.06,83.27,1384559,72.8747445627,73.04136551890001,72.8396664666,73.0238264709,1384559,0.0,1.0\n6316,BND,2015-02-10,82.95,83.04,82.9,83.0,1447918,72.7432017024,72.82212741859999,72.69935408239999,72.7870493225,1447918,0.0,1.0\n6317,BND,2015-02-11,82.95,83.0499,82.89,83.0,1904004,72.7432017024,72.8308092474,72.6905845584,72.7870493225,1904004,0.0,1.0\n6318,BND,2015-02-12,82.99,83.1,82.96,82.99,4662889,72.77827979850001,72.8747445627,72.75197122649999,72.77827979850001,4662889,0.0,1.0\n6319,BND,2015-02-13,82.8,83.03,82.8,83.03,1882963,72.6116588422,72.8133578946,72.6116588422,72.8133578946,1882963,0.0,1.0\n6320,BND,2015-02-17,82.47,82.795,82.44,82.78,5281512,72.32226454970001,72.6072740802,72.2959559777,72.5941197942,5281512,0.0,1.0\n6321,BND,2015-02-18,82.7,82.815,82.49,82.59,3812988,72.5239636021,72.6248131282,72.3398035978,72.4274988379,3812988,0.0,1.0\n6322,BND,2015-02-19,82.63,82.81,82.6,82.74,1322735,72.462576934,72.6204283662,72.43626836189999,72.5590416981,1322735,0.0,1.0\n6323,BND,2015-02-20,82.65,82.88,82.5301,82.75,3145827,72.480115982,72.6818150343,72.3749693891,72.5678112221,3145827,0.0,1.0\n6324,BND,2015-02-23,82.82,82.89,82.75,82.8,3952746,72.62919789029999,72.6905845584,72.5678112221,72.6116588422,3952746,0.0,1.0\n6325,BND,2015-02-24,83.16,83.22,82.68,82.82,2542150,72.9273617068,72.9799788508,72.50642455399999,72.62919789029999,2542150,0.0,1.0\n6326,BND,2015-02-25,83.24,83.2599,83.08,83.15,4934645,72.9975178989,73.0149692517,72.8572055146,72.91859218270001,4934645,0.0,1.0\n6327,BND,2015-02-26,83.01,83.2399,83.0,83.14,2187546,72.7958188465,72.99743020359999,72.7870493225,72.9098226587,2187546,0.0,1.0\n6328,BND,2015-02-27,83.08,83.11,82.93,83.03,4094445,72.8572055146,72.8835140867,72.7256626544,72.8133578946,4094445,0.0,1.0\n6329,BND,2015-03-02,82.65,83.0,82.63,82.96,2476128,72.6221191305,72.9296538152,72.6045457199,72.8945069941,2476128,0.161928,1.0\n6330,BND,2015-03-03,82.55,82.71,82.53,82.63,2648060,72.5342520777,72.6748393621,72.5166786671,72.6045457199,2648060,0.0,1.0\n6331,BND,2015-03-04,82.55,82.66,82.51,82.6,1425410,72.5342520777,72.63090583569999,72.4991052566,72.5781856041,1425410,0.0,1.0\n6332,BND,2015-03-05,82.6,82.679,82.50200000000001,82.57,1340751,72.5781856041,72.6476005758,72.4920758923,72.5518254882,1340751,0.0,1.0\n6333,BND,2015-03-06,82.12,82.31,82.03,82.31,1580047,72.1564237507,72.323371151,72.0773434032,72.323371151,1580047,0.0,1.0\n6334,BND,2015-03-09,82.22,82.3,82.1846,82.28,1982326,72.24429080350001,72.31458444569999,72.2131858668,72.2970110351,1982326,0.0,1.0\n6335,BND,2015-03-10,82.54,82.55,82.43,82.45,3766143,72.52546537239999,72.5342520777,72.4288116143,72.4463850249,3766143,0.0,1.0\n6336,BND,2015-03-11,82.63,82.6899,82.51,82.54,4829734,72.6045457199,72.6571780845,72.4991052566,72.52546537239999,4829734,0.0,1.0\n6337,BND,2015-03-12,82.69,82.89,82.6474,82.87,8421475,72.6572659516,72.8330000572,72.6198345871,72.8154266466,8421475,0.0,1.0\n6338,BND,2015-03-13,82.61,82.77,82.58,82.61,2114506,72.5869723094,72.7275595938,72.5606121935,72.5869723094,2114506,0.0,1.0\n8794,VNQ,2014-12-15,79.78,81.29,79.63,81.28,5900900,63.8127119598,65.0204983105,63.6927331833,65.0124997254,5900900,0.0,1.0\n8795,VNQ,2014-12-16,79.67,80.385,79.17,79.78,7097840,63.7247275237,64.2966263586,63.324798268500004,63.8127119598,7097840,0.0,1.0\n8796,VNQ,2014-12-17,81.5,81.54,79.78,79.79,7227860,65.18846859770001,65.2204629381,63.8127119598,63.820710544899995,7227860,0.0,1.0\n8797,VNQ,2014-12-18,82.07,82.16,81.425,82.07,6041774,65.6443879487,65.7163752146,65.1284792094,65.6443879487,6041774,0.0,1.0\n8798,VNQ,2014-12-19,82.03,82.4372,81.8,82.24,5490340,65.6123936082,65.9380959937,65.4284261508,65.7803638954,5490340,0.0,1.0\n8799,VNQ,2014-12-22,82.28,82.34,81.11,81.15,3600222,66.6946021728,66.743237031,65.7462224385,65.7786456772,3600222,1.103,1.0\n8800,VNQ,2014-12-23,82.05,82.63,81.83,82.57,4804081,66.50816854979999,66.9783055121,66.3298407365,66.92967065399999,4804081,0.0,1.0\n8801,VNQ,2014-12-24,81.71,82.405,81.62,82.26,1306938,66.2325710202,66.795924794,66.1596187329,66.6783905534,1306938,0.0,1.0\n8802,VNQ,2014-12-26,82.0,82.25,81.72,82.12,2907652,66.4676395013,66.6702847437,66.2406768299,66.5649092177,2907652,0.0,1.0\n8803,VNQ,2014-12-29,82.45,82.77,81.8,81.9,2789066,66.8324009376,67.0917868479,66.3055233074,66.38658140439999,2789066,0.0,1.0\n8804,VNQ,2014-12-30,82.39,82.88,82.19,82.44,3292637,66.7837660795,67.1809507545,66.62164988560001,66.8242951279,3292637,0.0,1.0\n8805,VNQ,2014-12-31,81.0,83.0899,80.9249,82.77,4494515,65.6570585318,67.3510917,65.596183901,67.0917868479,4494515,0.0,1.0\n8806,VNQ,2015-01-02,82.22,82.2891,81.34,81.7,5570506,66.6459673146,66.7019784596,65.9326560615,66.2244652105,5570506,0.0,1.0\n8807,VNQ,2015-01-05,82.67,82.88,81.75,82.0,6073658,67.0107287509,67.1809507545,66.26499425899999,66.4676395013,6073658,0.0,1.0\n8808,VNQ,2015-01-06,83.49,83.75,82.75,82.75,7577096,67.6754051459,67.88615619800001,67.0755752285,67.0755752285,7577096,0.0,1.0\n8809,VNQ,2015-01-07,84.77,84.885,83.37,83.81,6920652,68.7129487869,68.8061655984,67.5781354296,67.9347910562,6920652,0.0,1.0\n8810,VNQ,2015-01-08,85.09,85.3599,84.41,85.35,5255006,68.9723346972,69.1911105009,68.4211396379,69.1830857493,5255006,0.0,1.0\n8811,VNQ,2015-01-09,85.13,85.5151,84.56,85.09,4643859,69.004757936,69.3169126673,68.5427267833,68.9723346972,4643859,0.0,1.0\n8812,VNQ,2015-01-12,85.78,85.87,85.18,85.34,5131767,69.5316355662,69.6045878534,69.0452869844,69.17497993960001,5131767,0.0,1.0\n8813,VNQ,2015-01-13,85.65,86.33,85.195,86.08,5859575,69.4262600401,69.97745509939999,69.057445699,69.774809857,5859575,0.0,1.0\n8814,VNQ,2015-01-14,86.39,86.43,85.15,85.6,6249130,70.0260899576,70.0585131964,69.0209695554,69.3857309916,6249130,0.0,1.0\n8815,VNQ,2015-01-15,86.59,86.81,86.035,86.57,5004104,70.1882061515,70.3665339648,69.7383337134,70.1719945321,5004104,0.0,1.0\n8816,VNQ,2015-01-16,87.34,87.45,86.28,86.59,4716986,70.7961418786,70.88530578529999,69.9369260509,70.1882061515,4716986,0.0,1.0\n8817,VNQ,2015-01-20,86.65,87.85,86.45,87.73,5012654,70.2368410097,71.2095381731,70.0747248157,71.11226845670001,5012654,0.0,1.0\n8818,VNQ,2015-01-21,86.59,86.69,86.18,86.69,4354549,70.1882061515,70.2692642484,69.85586795399999,70.2692642484,4354549,0.0,1.0\n8819,VNQ,2015-01-22,88.13,88.2165,86.71,86.93,4450578,71.4365008446,71.5066160984,70.2854758678,70.4638036811,4450578,0.0,1.0\n8820,VNQ,2015-01-23,87.88,88.35,87.68,88.11,3491761,71.23385560220001,71.6148286579,71.0717394083,71.42028922520001,3491761,0.0,1.0\n8821,VNQ,2015-01-26,88.62,88.69,87.64,88.0,2885066,71.8336855196,71.8904261875,71.0393161695,71.3311253185,2885066,0.0,1.0\n8822,VNQ,2015-01-27,88.65,89.0,88.3,88.4,3209431,71.8580029487,72.14170628800001,71.5742996094,71.6553577063,3209431,0.0,1.0\n8823,VNQ,2015-01-28,88.06,89.27,88.06,88.83,3948896,71.3797601767,72.3605631498,71.3797601767,72.0039075232,3948896,0.0,1.0\n8824,VNQ,2015-01-29,88.37,88.46,87.46,88.46,3337469,71.6310402772,71.7039925645,70.893411595,71.7039925645,3337469,0.0,1.0\n8825,VNQ,2015-01-30,86.55,88.25,86.54,88.21,4969805,70.1557829127,71.5337705609,70.147677103,71.5013473221,4969805,0.0,1.0\n8826,VNQ,2015-02-02,86.32,86.69,84.6851,86.53,7523640,69.9693492897,70.2692642484,68.6441304626,70.1395712933,7523640,0.0,1.0\n8827,VNQ,2015-02-03,87.07,87.1,85.66,86.33,4617437,70.57728501689999,70.6016024459,69.4343658498,69.97745509939999,4617437,0.0,1.0\n8828,VNQ,2015-02-04,86.71,87.09,86.2101,86.87,3332758,70.2854758678,70.5934966362,69.88026644119999,70.4151688229,3332758,0.0,1.0\n8829,VNQ,2015-02-05,87.75,87.79,86.67,86.92,2921645,71.12848007609999,71.1609033149,70.253052629,70.4556978714,2921645,0.0,1.0\n8830,VNQ,2015-02-06,85.2,87.66,84.8333,87.54,6323274,69.0614986038,71.05552778890001,68.7642585623,70.9582580725,6323274,0.0,1.0\n8831,VNQ,2015-02-09,84.6,85.61,84.58,85.12,4235744,68.5751500221,69.3938368013,68.55893840270001,68.9966521263,4235744,0.0,1.0\n8832,VNQ,2015-02-10,84.87,85.055,83.8537,84.75,6144475,68.79400688390001,68.9439643633,67.9702134446,68.6967371675,6144475,0.0,1.0\n8833,VNQ,2015-02-11,84.62,85.32,84.04,85.04,4027104,68.5913616415,69.15876832020001,68.1212246792,68.93180564869999,4027104,0.0,1.0\n8834,VNQ,2015-02-12,85.63,85.715,84.52,84.82,4523051,69.4100484207,69.4789478031,68.5103035446,68.7534778354,4523051,0.0,1.0\n8835,VNQ,2015-02-13,85.06,85.94,84.5632,85.9,3444518,68.9480172681,69.6613285213,68.5453206424,69.6289052825,3444518,0.0,1.0\n8836,VNQ,2015-02-17,84.85,85.86,84.65,85.0,6414844,68.7777952645,69.5964820437,68.61567907060001,68.8993824099,6414844,0.0,1.0\n8837,VNQ,2015-02-18,85.64,85.745,84.22,84.84,5487931,69.4181542304,69.50326523220001,68.2671292537,68.7696894548,5487931,0.0,1.0\n8838,VNQ,2015-02-19,83.8,85.69,83.61,85.69,5415568,67.9266852465,69.4586832789,67.7726748623,69.4586832789,5415568,0.0,1.0\n8839,VNQ,2015-02-20,84.61,84.74,83.69,83.82,4982687,68.5832558318,68.6886313579,67.83752133979999,67.9428968659,4982687,0.0,1.0\n8840,VNQ,2015-02-23,85.29,85.33,84.54899999999999,84.62,3883754,69.1344508911,69.1668741299,68.5338103927,68.5913616415,3883754,0.0,1.0\n8841,VNQ,2015-02-24,83.61,85.15,83.25,85.15,6027713,67.7726748623,69.0209695554,67.4808657133,69.0209695554,6027713,0.0,1.0\n8842,VNQ,2015-02-25,83.63,84.53,83.53,83.7,6699733,67.7888864817,68.5184093543,67.70782838470001,67.8456271495,6699733,0.0,1.0\n8843,VNQ,2015-02-26,82.72,83.66,82.53,83.62,4738676,67.05125779939999,67.8132039108,66.8972474152,67.78078067199999,4738676,0.0,1.0\n8844,VNQ,2015-02-27,83.37,83.58,82.43,82.98,4328243,67.5781354296,67.7483574332,66.8161893182,67.2620088515,4328243,0.0,1.0\n8845,VNQ,2015-03-02,83.85,84.77,83.46,83.58,5353509,67.967214295,68.7129487869,67.6510877169,67.7483574332,5353509,0.0,1.0\n8846,VNQ,2015-03-03,83.69,83.925,82.97,83.74,4311390,67.83752133979999,68.0280078677,67.2539030418,67.8780503883,4311390,0.0,1.0\n8847,VNQ,2015-03-04,82.89,83.8,82.7,83.69,3054194,67.1890565642,67.9266852465,67.03504618,67.83752133979999,3054194,0.0,1.0\n8848,VNQ,2015-03-05,83.13,83.95,83.05,83.1,3503868,67.3835959969,68.0482723919,67.3187495194,67.3592785678,3503868,0.0,1.0\n8849,VNQ,2015-03-06,80.37,82.09,80.22,82.06,7166543,65.14639252100001,66.5405917886,65.02480537560001,66.5162743595,7166543,0.0,1.0\n8850,VNQ,2015-03-09,81.01,81.19,80.65,80.65,6547982,65.6651643415,65.81106891600001,65.3733551925,65.3733551925,6547982,0.0,1.0\n8851,VNQ,2015-03-10,80.95,81.33,80.65,80.77,5985731,65.6165294833,65.9245502518,65.3733551925,65.4706249088,5985731,0.0,1.0\n8852,VNQ,2015-03-11,81.02,81.29,80.71,81.05,2833062,65.6732701512,65.892127013,65.4219900507,65.6975875803,2833062,0.0,1.0\n8853,VNQ,2015-03-12,82.43,82.54,81.29,81.32,4013186,66.8161893182,66.9053532249,65.892127013,65.9164444421,4013186,0.0,1.0\n8854,VNQ,2015-03-13,82.38,82.61,81.83,82.42,2863509,66.7756602698,66.9620938928,66.3298407365,66.8080835085,2863509,0.0,1.0\n11310,DBC,2014-12-15,19.0,19.38,18.99,19.35,6392963,18.4580782427,18.8272398076,18.4483634647,18.7980954735,6392963,0.0,1.0\n11311,DBC,2014-12-16,18.89,19.07,18.77,18.8,4297594,18.3512156845,18.5260816889,18.2346383482,18.2637826823,4297594,0.0,1.0\n11312,DBC,2014-12-17,19.0,19.31,18.79,18.85,7656491,18.4580782427,18.7592363614,18.2540679043,18.3123565724,7656491,0.0,1.0\n11313,DBC,2014-12-18,18.94,19.31,18.865,19.27,4960151,18.3997895746,18.7592363614,18.3269287394,18.7203772493,4960151,0.0,1.0\n11314,DBC,2014-12-19,19.22,19.28,18.94,19.02,3617523,18.671803359200002,18.7300920274,18.3997895746,18.4775077988,3617523,0.0,1.0\n11315,DBC,2014-12-22,18.87,19.05,18.84,19.05,5826748,18.331786128399997,18.5066521328,18.3026417944,18.5066521328,5826748,0.0,1.0\n11316,DBC,2014-12-23,19.06,19.13,18.86,18.86,5627203,18.5163669109,18.584370357,18.322071350399998,18.322071350399998,5627203,0.0,1.0\n11317,DBC,2014-12-24,18.81,18.92,18.76,18.89,2069586,18.2734974603,18.3803600185,18.2249235702,18.3512156845,2069586,0.0,1.0\n11318,DBC,2014-12-26,18.79,18.89,18.7228,18.87,2913105,18.2540679043,18.3512156845,18.1887845959,18.331786128399997,2913105,0.0,1.0\n11319,DBC,2014-12-29,18.61,18.94,18.505,18.89,4566265,18.0792018999,18.3997895746,17.9771967306,18.3512156845,4566265,0.0,1.0\n11320,DBC,2014-12-30,18.59,18.7147,18.55,18.58,4735769,18.059772343800002,18.1809156257,18.0209132317,18.0500575658,4735769,0.0,1.0\n11321,DBC,2014-12-31,18.45,18.5,18.22,18.39,21526492,17.9237654515,17.9723393416,17.700325557,17.8654767834,21526492,0.0,1.0\n11322,DBC,2015-01-02,18.23,18.38,18.175,18.27,1967294,17.710040335,17.8557620053,17.6566090559,17.7488994471,1967294,0.0,1.0\n11323,DBC,2015-01-05,17.97,18.15,17.945,18.15,1827461,17.457456106400002,17.6323221108,17.433169161400002,17.6323221108,1827461,0.0,1.0\n11324,DBC,2015-01-06,17.8,18.03,17.75,17.95,1843060,17.29230488,17.5157447745,17.2437309899,17.4380265504,1843060,0.0,1.0\n11325,DBC,2015-01-07,17.69,17.8632,17.6001,17.78,1868404,17.1854423218,17.353702277100002,17.0981064674,17.272875324,1868404,0.0,1.0\n11326,DBC,2015-01-08,17.76,17.76,17.58,17.66,1409351,17.2534457679,17.2534457679,17.0785797635,17.1562979877,1409351,0.0,1.0\n11327,DBC,2015-01-09,17.73,17.78,17.5444,17.71,3463555,17.224301433900003,17.272875324,17.0439951538,17.2048718778,3463555,0.0,1.0\n11328,DBC,2015-01-12,17.34,17.51,17.33,17.51,4567392,16.845425091,17.0105763174,16.835710313,17.0105763174,4567392,0.0,1.0\n11329,DBC,2015-01-13,17.27,17.3,17.07,17.28,2898573,16.7774216448,16.8065659789,16.5831260844,16.787136422899998,2898573,0.0,1.0\n11330,DBC,2015-01-14,17.46,17.52,17.06,17.15,2774281,16.9620024273,17.0202910954,16.5734113064,16.660844308599998,2774281,0.0,1.0\n11331,DBC,2015-01-15,17.22,17.63,17.21,17.6,1827752,16.7288477547,17.1271536536,16.7191329767,17.0980093196,1827752,0.0,1.0\n11332,DBC,2015-01-16,17.55,17.5944,17.32,17.35,1726216,17.0494354295,17.0925690439,16.825995535,16.855139869000002,1726216,0.0,1.0\n11333,DBC,2015-01-20,17.31,17.3876,17.21,17.31,2409030,16.8162807569,16.891667434400002,16.7191329767,16.8162807569,2409030,0.0,1.0\n11334,DBC,2015-01-21,17.42,17.5,17.345,17.42,2282249,16.9231433152,17.0008615394,16.85028248,16.9231433152,2282249,0.0,1.0\n11335,DBC,2015-01-22,17.4,17.53,17.315,17.51,3736663,16.9037137591,17.0300058734,16.8211381459,17.0105763174,3736663,0.0,1.0\n11336,DBC,2015-01-23,17.29,17.4,17.25,17.3,1889052,16.7968512009,16.9037137591,16.757992088800002,16.8065659789,1889052,0.0,1.0\n11337,DBC,2015-01-26,17.23,17.395,17.23,17.28,1718333,16.7385625327,16.8988563701,16.7385625327,16.787136422899998,1718333,0.0,1.0\n11338,DBC,2015-01-27,17.32,17.4,17.19,17.21,3261661,16.825995535,16.9037137591,16.6997034207,16.7191329767,3261661,0.0,1.0\n11339,DBC,2015-01-28,17.07,17.2789,17.06,17.21,3647794,16.5831260844,16.7860677973,16.5734113064,16.7191329767,3647794,0.0,1.0\n11340,DBC,2015-01-29,16.99,17.09,16.84,17.09,2221661,16.5054078602,16.6025556404,16.3596861899,16.6025556404,2221661,0.0,1.0\n11341,DBC,2015-01-30,17.4,17.49,16.93,16.95,3535320,16.9037137591,16.9911467613,16.4471191921,16.4665487481,3535320,0.0,1.0\n11342,DBC,2015-02-02,17.64,17.67,17.4,17.57,2878421,17.1368684317,17.1660127657,16.9037137591,17.0688649855,2878421,0.0,1.0\n11343,DBC,2015-02-03,18.13,18.385,17.86,17.91,5713105,17.612892554800002,17.860619394300002,17.3505935482,17.3991674383,5713105,0.0,1.0\n11344,DBC,2015-02-04,17.73,18.03,17.6152,18.03,4597875,17.224301433900003,17.5157447745,17.1127757822,17.5157447745,4597875,0.0,1.0\n11345,DBC,2015-02-05,17.97,18.0758,17.75,17.75,2395461,17.457456106400002,17.5602384579,17.2437309899,17.2437309899,2395461,0.0,1.0\n11346,DBC,2015-02-06,18.02,18.12,17.9201,18.02,2735081,17.5060299965,17.6031777768,17.408979364100002,17.5060299965,2735081,0.0,1.0\n11347,DBC,2015-02-09,18.2,18.3,18.085,18.14,2060712,17.6808960009,17.778043781199997,17.569176053699998,17.6226073328,2060712,0.0,1.0\n11348,DBC,2015-02-10,17.97,18.19,17.88,18.18,1172491,17.457456106400002,17.6711812229,17.3700231042,17.6614664449,1172491,0.0,1.0\n11349,DBC,2015-02-11,17.83,17.899,17.65,17.85,3233401,17.3214492141,17.3884811825,17.1465832097,17.3408787701,3233401,0.0,1.0\n11350,DBC,2015-02-12,18.1,18.15,17.9251,18.0,11000201,17.5837482207,17.6323221108,17.4138367531,17.4866004405,11000201,0.0,1.0\n11351,DBC,2015-02-13,18.3,18.37,18.24,18.29,2173808,17.778043781199997,17.8460472273,17.719755112999998,17.7683290031,2173808,0.0,1.0\n11352,DBC,2015-02-17,18.31,18.41,18.075,18.23,1631714,17.7877585592,17.8849063394,17.5594612756,17.710040335,1631714,0.0,1.0\n11353,DBC,2015-02-18,18.09,18.3,18.05,18.24,2454494,17.5740334427,17.778043781199997,17.5351743306,17.719755112999998,2454494,0.0,1.0\n11354,DBC,2015-02-19,18.03,18.12,17.81,17.85,1131782,17.5157447745,17.6031777768,17.3020196581,17.3408787701,1131782,0.0,1.0\n11355,DBC,2015-02-20,17.96,18.104,17.929000000000002,18.09,1674645,17.4477413284,17.5876341319,17.4176255165,17.5740334427,1674645,0.0,1.0\n11356,DBC,2015-02-23,17.84,17.991,17.79,17.86,1583806,17.3311639921,17.4778571403,17.282590102,17.3505935482,1583806,0.0,1.0\n11357,DBC,2015-02-24,17.85,18.07,17.84,17.94,1183066,17.3408787701,17.554603886600002,17.3311639921,17.4283117723,1183066,0.0,1.0\n11358,DBC,2015-02-25,18.12,18.13,17.91,17.95,1861529,17.6031777768,17.612892554800002,17.3991674383,17.4380265504,1861529,0.0,1.0\n11359,DBC,2015-02-26,17.97,18.11,17.88,18.06,952427,17.457456106400002,17.593462998699998,17.3700231042,17.5448891086,952427,0.0,1.0\n11360,DBC,2015-02-27,18.17,18.24,18.0301,18.1,964103,17.6517516669,17.719755112999998,17.5158419223,17.5837482207,964103,0.0,1.0\n11361,DBC,2015-03-02,17.91,18.1151,17.85,18.07,1449380,17.3991674383,17.5984175355,17.3408787701,17.554603886600002,1449380,0.0,1.0\n11362,DBC,2015-03-03,18.02,18.06,17.928,17.98,1588336,17.5060299965,17.5448891086,17.4166540387,17.4671708844,1588336,0.0,1.0\n11363,DBC,2015-03-04,17.86,17.95,17.775,17.93,1476945,17.3505935482,17.4380265504,17.268017935,17.4185969943,1476945,0.0,1.0\n11364,DBC,2015-03-05,17.79,17.94,17.74,17.9,1244011,17.282590102,17.4283117723,17.234016211900002,17.389452660299998,1244011,0.0,1.0\n11365,DBC,2015-03-06,17.57,17.73,17.53,17.72,7454737,17.0688649855,17.224301433900003,17.0300058734,17.2145866559,7454737,0.0,1.0\n11366,DBC,2015-03-09,17.52,17.67,17.49,17.61,1280703,17.0202910954,17.1660127657,16.9911467613,17.1077240976,1280703,0.0,1.0\n11367,DBC,2015-03-10,17.29,17.35,17.21,17.3,1580756,16.7968512009,16.855139869000002,16.7191329767,16.8065659789,1580756,0.0,1.0\n11368,DBC,2015-03-11,17.38,17.4,17.2,17.32,1390622,16.884284203099998,16.9037137591,16.7094181987,16.825995535,1390622,0.0,1.0\n11369,DBC,2015-03-12,17.3,17.5,17.2399,17.49,1146164,16.8065659789,17.0008615394,16.748180163,16.9911467613,1146164,0.0,1.0\n11370,DBC,2015-03-13,16.95,17.23,16.95,17.23,1924610,16.4665487481,16.7385625327,16.4665487481,16.7385625327,1924610,0.0,1.0\n"
  },
  {
    "path": "tests/test_data/ivy_portfolio.csv",
    "content": "symbol,date,close,high,low,open,volume,adjClose,adjHigh,adjLow,adjOpen,adjVolume,divCash,splitFactor\nVTI,2017-01-03,116.2,116.55,115.49,116.15,2731646,109.81434579209999,110.1451118939,109.1433631285,109.7670934918,2731646,0.0,1.0\nVTI,2017-01-04,117.09,117.19,116.4151,116.47,3228738,110.6554367366,110.7499413371,110.01762518780001,110.0695082135,3228738,0.0,1.0\nVTI,2017-01-05,116.86,117.0886,116.4,116.95,2604038,110.43807615540001,110.6541136722,110.00335499309999,110.5231302959,2604038,0.0,1.0\nVTI,2017-01-06,117.23,117.515,116.64,117.02,2317806,110.7877431773,111.05708128879999,110.2301660343,110.58928351629999,2317806,0.0,1.0\nVTI,2017-01-09,116.78,117.1357,116.735,117.1,2461680,110.362472475,110.698625339,110.3199454048,110.66488719670001,2461680,0.0,1.0\nVTI,2017-01-10,116.87,117.36,116.62,116.76,2055428,110.4475266155,110.910599158,110.2112651142,110.3435715549,2055428,0.0,1.0\nVTI,2017-01-11,117.24,117.24,116.4898,116.91,2751838,110.7971936374,110.7971936374,110.08822012440001,110.4853284557,2751838,0.0,1.0\nVTI,2017-01-12,116.93,117.04,116.01,117.04,2342402,110.5042293758,110.6081844364,109.6347870511,110.6081844364,2342402,0.0,1.0\nVTI,2017-01-13,117.22,117.4,116.98,116.99,2031780,110.77829271729999,110.9484009982,110.55148167610001,110.5609321361,2031780,0.0,1.0\nVTI,2017-01-17,116.7,117.06,116.4741,116.86,2583709,110.2868687946,110.62708535649999,110.0733829021,110.43807615540001,2583709,0.0,1.0\nVTI,2017-01-18,116.97,117.0,116.48,116.8,1901752,110.542031216,110.5703825962,110.0789586735,110.3813733951,1901752,0.0,1.0\nVTI,2017-01-19,116.51,117.14,116.24,117.03,1899267,110.1073100537,110.70268903690001,109.85214763229999,110.5987339763,1899267,0.0,1.0\nVTI,2017-01-20,116.91,117.23,116.59,116.84,2636114,110.4853284557,110.7877431773,110.1829137341,110.4191752353,2636114,0.0,1.0\nVTI,2017-01-23,116.6,116.97,116.17,116.75,1938950,110.1923641941,110.542031216,109.7859944119,110.3341210949,1938950,0.0,1.0\nVTI,2017-01-24,117.51,117.74,116.74,116.82,2165876,111.05235605879999,111.2697166399,110.3246706348,110.40027431520001,2165876,0.0,1.0\nVTI,2017-01-25,118.52,118.56,117.94,118.02,2427847,112.0068525239,112.0446543641,111.4587258409,111.5343295214,2427847,0.0,1.0\nVTI,2017-01-26,118.35,118.62,118.2299,118.56,2178512,111.846194703,112.10135712440001,111.73269467780001,112.0446543641,2178512,0.0,1.0\nVTI,2017-01-27,118.16,118.48,118.0236,118.45,1479541,111.66663596209999,111.9690506837,111.537731687,111.9406993035,1479541,0.0,1.0\nVTI,2017-01-30,117.36,117.8,116.68,117.73,3006131,110.910599158,111.32641940020001,110.2679678745,111.2602661799,3006131,0.0,1.0\nVTI,2017-01-31,117.46,117.46,116.73,117.0,3309743,111.0051037585,111.0051037585,110.31522017479999,110.5703825962,3309743,0.0,1.0\nVTI,2017-02-01,117.45,118.0401,117.07,117.86,2614514,110.9956532985,111.5533249461,110.6365358165,111.3831221605,2614514,0.0,1.0\nVTI,2017-02-02,117.53,117.72,117.02,117.21,1745302,111.0712569789,111.2508157198,110.58928351629999,110.76884225719999,1745302,0.0,1.0\nVTI,2017-02-03,118.42,118.5199,117.92,117.99,1837627,111.9123479234,112.00675801930001,111.43982492079999,111.5059781412,1837627,0.0,1.0\nVTI,2017-02-06,118.14,118.41,117.94,118.26,1598846,111.64773504200001,111.9028974633,111.4587258409,111.7611405626,1598846,0.0,1.0\nVTI,2017-02-07,118.13,118.53,117.96,118.43,3366070,111.63828458190001,112.01630298399999,111.47762676100001,111.9217983834,3366070,0.0,1.0\nVTI,2017-02-08,118.26,118.32,117.69,117.92,1835984,111.7611405626,111.8178433229,111.2224643397,111.43982492079999,1835984,0.0,1.0\nVTI,2017-02-09,119.04,119.2063,118.38,118.42,1635319,112.4982764466,112.65543759719999,111.8745460832,111.9123479234,1635319,0.0,1.0\nVTI,2017-02-10,119.54,119.7,119.21,119.29,1837821,112.9707994491,113.1220068099,112.6589342674,112.7345379478,1837821,0.0,1.0\nVTI,2017-02-13,120.14,120.33,119.87,119.92,1985570,113.53782705219999,113.7173857931,113.2826646308,113.329916931,1985570,0.0,1.0\nVTI,2017-02-14,120.58,120.63,119.8501,120.1,2051179,113.9536472944,114.00089959469999,113.2638582153,113.50002521200001,2051179,0.0,1.0\nVTI,2017-02-15,121.24,121.35,120.45,120.57,1998213,114.57737765780001,114.6813327183,113.8307913137,113.9441968343,1998213,0.0,1.0\nVTI,2017-02-16,121.13,121.37,120.64,121.25,2034805,114.4734225972,114.70023363840001,114.0103500547,114.58682811780001,2034805,0.0,1.0\nVTI,2017-02-17,121.31,121.31,120.66,120.66,1948471,114.6435308781,114.6435308781,114.0292509748,114.0292509748,1948471,0.0,1.0\nVTI,2017-02-21,122.03,122.13,121.39,121.55,2423039,115.32396400180001,115.4184686023,114.7191345585,114.8703419193,2423039,0.0,1.0\nVTI,2017-02-22,121.87,122.03,121.6807,121.82,1973959,115.172756641,115.32396400180001,114.9938594322,115.12550434069999,1973959,0.0,1.0\nVTI,2017-02-23,121.78,122.23,121.32,122.21,2024945,115.08770250049999,115.51297320280001,114.6529813382,115.4940722827,2024945,0.0,1.0\nVTI,2017-02-24,121.98,121.99,121.24,121.38,2962944,115.27671170149999,115.2861621616,114.57737765780001,114.7096840985,2962944,0.0,1.0\nVTI,2017-02-27,122.25,122.3554,121.74,121.92,2625148,115.5318741229,115.6314819718,115.04990066030001,115.2200089412,2625148,0.0,1.0\nVTI,2017-02-28,121.8,122.18,121.61,122.18,2231827,115.1066034206,115.4657209026,114.92704467959999,115.4657209026,2231827,0.0,1.0\nVTI,2017-03-01,123.44,123.727,122.65,122.81,2874677,116.656478869,116.9277070724,115.9098925249,116.0610998858,2874677,0.0,1.0\nVTI,2017-03-02,122.65,123.42,122.61,123.42,2421598,115.9098925249,116.63757794889999,115.8720906847,116.63757794889999,2421598,0.0,1.0\nVTI,2017-03-03,122.75,122.8,122.33,122.63,1651644,116.00439712549999,116.05164942569999,115.6074778033,115.89099160479999,1651644,0.0,1.0\nVTI,2017-03-06,122.27,122.48,121.9,122.48,1716343,115.55077504299999,115.7492347041,115.20110802110001,115.7492347041,1716343,0.0,1.0\nVTI,2017-03-07,121.88,122.29,121.75,122.21,1612658,115.182207101,115.5696759631,115.05935112040001,115.4940722827,1612658,0.0,1.0\nVTI,2017-03-08,121.54,122.18,121.47,121.99,2374579,114.8608914593,115.4657209026,114.7947382389,115.2861621616,2374579,0.0,1.0\nVTI,2017-03-09,121.6,121.8899,121.02,121.6,3852842,114.91759421959999,115.1915630565,114.3694675366,114.91759421959999,3852842,0.0,1.0\nVTI,2017-03-10,122.04,122.25,121.4551,122.19,2987473,115.3334144618,115.5318741229,114.78065705350001,115.47517136260001,2987473,0.0,1.0\nVTI,2017-03-13,122.13,122.16,121.86,122.06,2003857,115.4184686023,115.4468199824,115.16330618090001,115.3523153819,2003857,0.0,1.0\nVTI,2017-03-14,121.69,121.87,121.24,121.87,2269587,115.0026483601,115.172756641,114.57737765780001,115.172756641,2269587,0.0,1.0\nVTI,2017-03-15,122.79,123.04,121.8914,121.98,1892226,116.0421989657,116.2784604669,115.1929806255,115.27671170149999,1892226,0.0,1.0\nVTI,2017-03-16,122.64,123.0,122.43,122.96,2050110,115.90044206489999,116.2406586267,115.70198240379999,116.20285678649999,2050110,0.0,1.0\nVTI,2017-03-17,122.57,122.8848,122.45,122.84,1973987,115.83428884450001,116.1317893269,115.7208833239,116.0894512659,1973987,0.0,1.0\nVTI,2017-03-20,122.2717,122.62,122.0708,122.51,3239871,115.55238162120001,115.8815411448,115.3625218788,115.7775860842,3239871,0.0,1.0\nVTI,2017-03-21,120.57,122.72,120.444,122.69,3355145,113.9441968343,115.97604574530001,113.8251210377,115.9476943651,3355145,0.0,1.0\nVTI,2017-03-22,120.73,120.91,120.1,120.48,2896603,114.0954041952,114.2655124761,113.50002521200001,113.8591426939,2896603,0.0,1.0\nVTI,2017-03-23,120.69,121.39,120.4667,120.66,2257114,114.057602355,114.7191345585,113.846573582,114.0292509748,2257114,0.0,1.0\nVTI,2017-03-24,120.16,120.74,119.66,120.42,2305692,114.06894290700001,114.61954199889999,113.5942885174,114.31576318959999,2305692,0.542,1.0\nVTI,2017-03-27,120.02,120.19,118.89,119.18,2927702,113.93603967790001,114.09742217040001,112.8633207575,113.1386203034,2927702,0.0,1.0\nVTI,2017-03-28,120.86,121.14,119.78,119.96,2699241,114.7334590525,114.9992655106,113.7082055709,113.87908115120001,2699241,0.0,1.0\nVTI,2017-03-29,121.08,121.23,120.58,120.88,3196549,114.9423069839,115.0847033007,114.4676525943,114.752445228,3196549,0.0,1.0\nVTI,2017-03-30,121.53,121.62,120.99,121.14,2076852,115.36949593450001,115.4549337246,114.8568691937,114.9992655106,2076852,0.0,1.0\nVTI,2017-03-31,121.32,121.7155,121.26,121.42,1815860,115.1701410909,115.54559271299999,115.1131825641,115.26507196879999,1815860,0.0,1.0\nVTI,2017-04-03,121.02,121.5,120.33,121.45,3103060,114.8853484571,115.34101667110001,114.2303253995,115.29355123219999,3103060,0.0,1.0\nVTI,2017-04-04,121.08,121.105,120.64,120.89,2462645,114.9423069839,114.9660397034,114.524611121,114.7619383158,2462645,0.0,1.0\nVTI,2017-04-05,120.59,122.0199,120.441,121.49,3905750,114.47714568209999,115.83456230540001,114.335698674,115.3315235833,3905750,0.0,1.0\nVTI,2017-04-06,120.97,121.26,120.38,120.69,2056068,114.8378830182,115.1131825641,114.27779083840001,114.57207656,2056068,0.0,1.0\nVTI,2017-04-07,120.88,121.29,120.59,120.84,1997240,114.752445228,115.1416618275,114.47714568209999,114.7144728769,1997240,0.0,1.0\nVTI,2017-04-10,121.03,121.51,120.7,121.04,1669475,114.8948415449,115.35050975889999,114.58156964780001,114.9043346327,1669475,0.0,1.0\nVTI,2017-04-11,120.99,121.0,120.0591,120.87,2895455,114.8568691937,114.8663622815,113.97315765120001,114.7429521402,2895455,0.0,1.0\nVTI,2017-04-12,120.37,120.99,120.23,120.88,1967393,114.2682977507,114.8568691937,114.13539452159999,114.752445228,1967393,0.0,1.0\nVTI,2017-04-13,119.55,120.56,119.55,120.15,2852219,113.4898645517,114.44866641870001,113.4898645517,114.0594498192,2852219,0.0,1.0\nVTI,2017-04-17,120.59,120.61,119.68,119.88,3198909,114.47714568209999,114.49613185770001,113.613274693,113.80313644879999,3198909,0.0,1.0\nVTI,2017-04-18,120.33,120.565,119.85,120.2,1865766,114.2303253995,114.4534129626,113.77465718549999,114.10691525819999,1865766,0.0,1.0\nVTI,2017-04-19,120.23,120.92,120.0784,120.72,1807017,114.13539452159999,114.7904175792,113.9914793106,114.6005558234,1807017,0.0,1.0\nVTI,2017-04-20,121.22,121.4299,120.3748,120.59,1783429,115.07521021299999,115.27447012569999,114.2728544328,114.47714568209999,1783429,0.0,1.0\nVTI,2017-04-21,120.83,121.21,120.61,121.21,2006549,114.7049797891,115.06571712520001,114.49613185770001,115.06571712520001,2006549,0.0,1.0\nVTI,2017-04-24,122.11,122.2755,121.88,122.17,2178763,115.92009502639999,116.07720562940001,115.7017540072,115.9770535532,2178763,0.0,1.0\nVTI,2017-04-25,122.87,123.09,122.52,122.56,2145193,116.64156969860001,116.85041763,116.30931162590001,116.34728397709999,2145193,0.0,1.0\nVTI,2017-04-26,122.9,123.45,122.8546,122.9,3080062,116.67004896200001,117.19216879049999,116.6269503434,116.67004896200001,3080062,0.0,1.0\nVTI,2017-04-27,123.02,123.21,122.69,123.12,1868179,116.7839660155,116.9643346835,116.4706941184,116.8788968934,1868179,0.0,1.0\nVTI,2017-04-28,122.61,123.21,122.55,123.16,1795827,116.39474941600001,116.9643346835,116.33779088930001,116.9168692446,1795827,0.0,1.0\nVTI,2017-05-01,122.93,123.1942,122.62,122.93,1983507,116.69852822540001,116.9493356048,116.40424250379999,116.69852822540001,1983507,0.0,1.0\nVTI,2017-05-02,122.93,123.25,122.7,123.09,1756917,116.69852822540001,117.0023070347,116.48018720620001,116.85041763,1756917,0.0,1.0\nVTI,2017-05-03,122.63,122.78,122.28,122.59,3690976,116.4137355916,116.5561319085,116.08147751889999,116.3757632404,3690976,0.0,1.0\nVTI,2017-05-04,122.72,122.83,122.1601,122.83,1564459,116.4991733817,116.6035973474,115.96765539629999,116.6035973474,1564459,0.0,1.0\nVTI,2017-05-05,123.33,123.33,122.68,122.95,2057663,117.078251737,117.078251737,116.4612010306,116.7175144009,2057663,0.0,1.0\nVTI,2017-05-08,123.14,123.45,122.9,123.36,2150873,116.897883069,117.19216879049999,116.67004896200001,117.1067310004,2150873,0.0,1.0\nVTI,2017-05-09,123.1,123.4261,122.85,123.36,1846216,116.8599107178,117.1694803107,116.62258352299999,117.1067310004,1846216,0.0,1.0\nVTI,2017-05-10,123.37,123.38,122.93,123.09,1468633,117.1162240882,117.12571717600001,116.69852822540001,116.85041763,1468633,0.0,1.0\nVTI,2017-05-11,123.01,123.129,122.33,123.02,1500018,116.7744729277,116.8874406724,116.12894295790001,116.7839660155,1500018,0.0,1.0\nVTI,2017-05-12,122.77,122.91,122.595,122.91,1235304,116.5466388207,116.67954204979999,116.3805097843,116.67954204979999,1235304,0.0,1.0\nVTI,2017-05-15,123.43,123.59,123.04,123.07,3097149,117.173182615,117.32507201959999,116.8029521911,116.8314314545,3097149,0.0,1.0\nVTI,2017-05-16,123.36,123.65,123.0699,123.63,2128403,117.1067310004,117.38203054639999,116.8313365236,117.3630443708,2128403,0.0,1.0\nVTI,2017-05-17,121.14,122.56,121.07,122.31,3703517,114.9992655106,116.34728397709999,114.9328138961,116.10995678229999,3703517,0.0,1.0\nVTI,2017-05-18,121.5,122.02,120.85,120.94,2883455,115.34101667110001,115.83465723629999,114.7239659647,114.8094037548,2883455,0.0,1.0\nVTI,2017-05-19,122.37,122.77,121.8159,121.91,2144173,116.16691530899999,116.5466388207,115.64090331450001,115.7302332706,2144173,0.0,1.0\nVEU,2017-01-03,44.5,44.54,44.39,44.47,3659944,40.6522153181,40.6887566352,40.551726695999996,40.6248093302,3659944,0.0,1.0\nVEU,2017-01-04,45.01,45.01,44.7501,44.77,2614490,41.1181171116,41.1181171116,40.8806899035,40.8988692088,2614490,0.0,1.0\nVEU,2017-01-05,45.43,45.46,45.16,45.16,1850756,41.501800941599996,41.5292069295,41.2551470509,41.2551470509,1850756,0.0,1.0\nVEU,2017-01-06,45.26,45.31,45.19,45.27,2030140,41.3465003438,41.3921769902,41.2825530388,41.355635673,2030140,0.0,1.0\nVEU,2017-01-09,45.21,45.24,45.06,45.14,2213759,41.3008236973,41.3282296852,41.1637937581,41.2368763923,2213759,0.0,1.0\nVEU,2017-01-10,45.23,45.3867,45.23,45.26,1620113,41.3190943559,41.4622449658,41.3190943559,41.3465003438,1620113,0.0,1.0\nVEU,2017-01-11,45.6,45.6,45.14,45.24,2437833,41.6571015394,41.6571015394,41.2368763923,41.3282296852,2437833,0.0,1.0\nVEU,2017-01-12,45.62,45.69,45.46,45.69,2443289,41.675372198000005,41.739319503000004,41.5292069295,41.739319503000004,2443289,0.0,1.0\nVEU,2017-01-13,45.77,45.77,45.61,45.67,2576150,41.8124021373,41.8124021373,41.6662368687,41.7210488444,2576150,0.0,1.0\nVEU,2017-01-17,45.69,45.72,45.585,45.7,2435023,41.739319503000004,41.7667254909,41.6433985455,41.7484548323,2435023,0.0,1.0\nVEU,2017-01-18,45.48,45.64,45.385,45.58,1427893,41.547477588,41.6936428566,41.4606919598,41.6388308809,1427893,0.0,1.0\nVEU,2017-01-19,45.38,45.48,45.23,45.44,2017702,41.4561242952,41.547477588,41.3190943559,41.510936270900004,2017702,0.0,1.0\nVEU,2017-01-20,45.6,45.6,45.44,45.5,1382883,41.6571015394,41.6571015394,41.510936270900004,41.5657482466,1382883,0.0,1.0\nVEU,2017-01-23,45.73,45.78,45.535,45.62,2017851,41.7758608201,41.8215374666,41.5977218991,41.675372198000005,2017851,0.0,1.0\nVEU,2017-01-24,45.97,46.01,45.75,45.77,2133320,41.995108723,42.031650040100004,41.794131478699995,41.8124021373,2133320,0.0,1.0\nVEU,2017-01-25,46.4,46.41,46.18,46.23,1996364,42.387927882199996,42.397063211500004,42.186950638,42.2326272844,1996364,0.0,1.0\nVEU,2017-01-26,46.25,46.37,46.2,46.35,1802778,42.250897943000005,42.3605218944,42.2052212965,42.3422512358,1802778,0.0,1.0\nVEU,2017-01-27,46.17,46.25,46.09,46.23,1729930,42.1778153087,42.250897943000005,42.1047326744,42.2326272844,1729930,0.0,1.0\nVEU,2017-01-30,45.88,45.89,45.68,45.84,1991675,41.9128907594,41.9220260887,41.730184173699996,41.8763494423,1991675,0.0,1.0\nVEU,2017-01-31,46.01,46.03,45.79,45.94,3433359,42.031650040100004,42.0499206987,41.8306727959,41.967702735100005,3433359,0.0,1.0\nVEU,2017-02-01,46.12,46.26,46.0,46.21,2878028,42.1321386623,42.2600332722,42.022514710799996,42.2143566258,2878028,0.0,1.0\nVEU,2017-02-02,46.18,46.2565,46.09,46.21,2262766,42.186950638,42.256835906999996,42.1047326744,42.2143566258,2262766,0.0,1.0\nVEU,2017-02-03,46.41,46.46,46.24,46.33,1471660,42.397063211500004,42.4427398579,42.2417626137,42.3239805772,1471660,0.0,1.0\nVEU,2017-02-06,46.12,46.13,45.9801,46.06,1255117,42.1321386623,42.1412739915,42.0043354056,42.077326686599996,1255117,0.0,1.0\nVEU,2017-02-07,46.02,46.07,45.975,46.04,1220938,42.0407853694,42.0864620158,41.999676387600005,42.059056028,1220938,0.0,1.0\nVEU,2017-02-08,46.18,46.2,45.96,46.05,1151922,42.186950638,42.2052212965,41.9859733937,42.0681913573,1151922,0.0,1.0\nVEU,2017-02-09,46.33,46.39,46.22,46.27,1354424,42.3239805772,42.378792553000004,42.2234919551,42.2691686015,1354424,0.0,1.0\nVEU,2017-02-10,46.53,46.58,46.35,46.37,1445026,42.5066871629,42.5523638094,42.3422512358,42.3605218944,1445026,0.0,1.0\nVEU,2017-02-13,46.75,46.82,46.6801,46.71,1780130,42.7076644072,42.7716117122,42.6438084555,42.671123090100004,1780130,0.0,1.0\nVEU,2017-02-14,46.7,46.7199,46.4311,46.67,1857832,42.6619877608,42.680167066100005,42.4163387563,42.6345817729,1857832,0.0,1.0\nVEU,2017-02-15,46.94,46.96,46.57,46.57,3694444,42.8812356636,42.8995063222,42.543228480100005,42.543228480100005,3694444,0.0,1.0\nVEU,2017-02-16,46.99,47.01,46.9,46.98,1870974,42.9269123101,42.9451829686,42.8446943465,42.9177769808,1870974,0.0,1.0\nVEU,2017-02-17,46.87,46.87,46.69,46.73,2025551,42.8172883586,42.8172883586,42.6528524315,42.689393748600004,2025551,0.0,1.0\nVEU,2017-02-21,47.09,47.1,46.87,46.9,2401986,43.0182656029,43.0274009322,42.8172883586,42.8446943465,2401986,0.0,1.0\nVEU,2017-02-22,47.1,47.12,46.885,46.92,1978209,43.0274009322,43.0456715908,42.8309913526,42.8629650051,1978209,0.0,1.0\nVEU,2017-02-23,47.17,47.3,47.09,47.25,1727952,43.0913482372,43.2101075179,43.0182656029,43.164430871499995,1727952,0.0,1.0\nVEU,2017-02-24,46.76,46.8399,46.66,46.7,1628183,42.7167997365,42.7897910175,42.6254464436,42.6619877608,1628183,0.0,1.0\nVEU,2017-02-27,46.73,46.785,46.6,46.6,1769078,42.689393748600004,42.7396380597,42.5706344679,42.5706344679,1769078,0.0,1.0\nVEU,2017-02-28,46.59,46.7584,46.522,46.68,1705772,42.5614991387,42.7153380838,42.499378899499995,42.6437171022,1705772,0.0,1.0\nVEU,2017-03-01,47.1,47.18899999999999,46.89,46.9,1729098,43.0274009322,43.108705362799995,42.8355590172,42.8446943465,1729098,0.0,1.0\nVEU,2017-03-02,46.71,46.87,46.68,46.87,1771382,42.671123090100004,42.8172883586,42.6437171022,42.8172883586,1771382,0.0,1.0\nVEU,2017-03-03,46.96,46.99,46.695,46.75,2168516,42.8995063222,42.9269123101,42.657420096100005,42.7076644072,2168516,0.0,1.0\nVEU,2017-03-06,46.85,46.86,46.735,46.85,1474415,42.7990177001,42.8081530293,42.6939614133,42.7990177001,1474415,0.0,1.0\nVEU,2017-03-07,46.74,46.84,46.6628,46.76,1284590,42.6985290779,42.7898823708,42.6280043358,42.7167997365,1284590,0.0,1.0\nVEU,2017-03-08,46.5,46.7472,46.48,46.72,2458292,42.4792811751,42.705106515,42.461010516500004,42.6802584194,2458292,0.0,1.0\nVEU,2017-03-09,46.55,46.59,46.4,46.55,2591914,42.5249578215,42.5614991387,42.387927882199996,42.5249578215,2591914,0.0,1.0\nVEU,2017-03-10,46.93,46.96,46.77,46.88,1811419,42.8721003343,42.8995063222,42.7259350658,42.8264236879,1811419,0.0,1.0\nVEU,2017-03-13,47.23,47.23,47.1,47.1,1757000,43.1461602129,43.1461602129,43.0274009322,43.0274009322,1757000,0.0,1.0\nVEU,2017-03-14,46.88,46.97,46.83,46.93,942164,42.8264236879,42.9086416515,42.7807470415,42.8721003343,942164,0.0,1.0\nVEU,2017-03-15,47.65,47.69,47.0,47.02,1749764,43.5298440429,43.56638536,42.9360476393,42.9543182979,1749764,0.0,1.0\nVEU,2017-03-16,47.95,47.99,47.8401,47.95,1670615,43.8039039214,43.8404452385,43.70350665260001,43.8039039214,1670615,0.0,1.0\nVEU,2017-03-17,47.98,48.07,47.89,48.0,1411025,43.8313099093,43.913527872799996,43.749091945699995,43.8495805678,1411025,0.0,1.0\nVEU,2017-03-20,48.0193,48.15,47.95,48.0,1639510,43.8672117534,43.9866105071,43.8039039214,43.8495805678,1639510,0.0,1.0\nVEU,2017-03-21,47.69,48.3973,47.685,48.36,1920712,43.56638536,44.212527200299995,43.5618176954,44.1784524221,1920712,0.0,1.0\nVEU,2017-03-22,47.66,47.6689,47.38,47.45,2242054,43.678749910200004,43.6869064539,43.422139545600004,43.4862921368,2242054,0.153,1.0\nVEU,2017-03-23,47.73,47.8365,47.53,47.55,1767117,43.7429025013,43.840506086400005,43.5596093838,43.577938695600004,1767117,0.0,1.0\nVEU,2017-03-24,47.86,47.94,47.771,47.81,1524520,43.862043027700004,43.9353602748,43.7804775904,43.8162197484,1524520,0.0,1.0\nVEU,2017-03-27,47.95,47.9785,47.6562,47.73,1497092,43.944524930600004,43.9706441999,43.675267341,43.7429025013,1497092,0.0,1.0\nVEU,2017-03-28,48.1,48.18,47.95,47.97,1902183,44.0819947688,44.1553120158,43.944524930600004,43.9628542424,1902183,0.0,1.0\nVEU,2017-03-29,48.18,48.18,47.94,47.98,1531746,44.1553120158,44.1553120158,43.9353602748,43.9720188983,1531746,0.0,1.0\nVEU,2017-03-30,47.99,48.1477,47.96,48.04,1306761,43.9811835541,44.12571017729999,43.9536895865,44.0270068335,1306761,0.0,1.0\nVEU,2017-03-31,47.83,47.94,47.725,47.77,1193233,43.8345490601,43.9353602748,43.7383201734,43.7795611249,1193233,0.0,1.0\nVEU,2017-04-03,47.84,47.87,47.51,47.84,2729192,43.843713716,43.8712076836,43.541280072,43.843713716,2729192,0.0,1.0\nVEU,2017-04-04,47.88,47.89,47.62,47.69,2006765,43.8803723395,43.8895369954,43.6420912867,43.7062438778,2006765,0.0,1.0\nVEU,2017-04-05,47.64,48.026,47.64,47.89,2306961,43.660420598500004,44.0141763153,43.660420598500004,43.8895369954,2306961,0.0,1.0\nVEU,2017-04-06,47.65,47.7299,47.5633,47.65,2522960,43.6695852543,43.7428108548,43.5901276879,43.6695852543,2522960,0.0,1.0\nVEU,2017-04-07,47.59,47.7,47.54,47.56,1580473,43.6145973191,43.7154085337,43.5687740397,43.5871033514,1580473,0.0,1.0\nVEU,2017-04-10,47.52,47.6,47.46,47.53,1042868,43.5504447279,43.6237619749,43.495456792700004,43.5596093838,1042868,0.0,1.0\nVEU,2017-04-11,47.67,47.7004,47.36,47.68,1884299,43.6879145661,43.715775119899995,43.4038102339,43.697079222,1884299,0.0,1.0\nVEU,2017-04-12,47.73,47.74,47.51,47.67,1694025,43.7429025013,43.7520671572,43.541280072,43.6879145661,1694025,0.0,1.0\nVEU,2017-04-13,47.39,47.6299,47.39,47.57,1067794,43.4313042015,43.651164296000005,43.4313042015,43.5962680073,1067794,0.0,1.0\nVEU,2017-04-17,47.79,47.8,47.61,47.63,1278098,43.7978904366,43.8070550925,43.6329266308,43.6512559426,1278098,0.0,1.0\nVEU,2017-04-18,47.45,47.505,47.2635,47.41,1597393,43.4862921368,43.536697744099996,43.3153713047,43.4496335133,1597393,0.0,1.0\nVEU,2017-04-19,47.21,47.53,47.18,47.52,1188674,43.2663403957,43.5596093838,43.238846428100004,43.5504447279,1188674,0.0,1.0\nVEU,2017-04-20,47.64,47.72,47.6,47.62,1202625,43.660420598500004,43.7337378455,43.6237619749,43.6420912867,1202625,0.0,1.0\nVEU,2017-04-21,47.66,47.6757,47.5557,47.63,1060130,43.678749910200004,43.693138419899995,43.583162549399994,43.6512559426,1060130,0.0,1.0\nVEU,2017-04-24,48.66,48.7064,48.54,48.54,1763796,44.5952154979,44.6377395012,44.4852396274,44.4852396274,1763796,0.0,1.0\nVEU,2017-04-25,49.01,49.06,48.8736,48.91,1406827,44.9159784536,44.961801733,44.790972547399996,44.8243318948,1406827,0.0,1.0\nVEU,2017-04-26,48.85,49.04,48.85,48.91,3264074,44.7693439596,44.9434724212,44.7693439596,44.8243318948,3264074,0.0,1.0\nVEU,2017-04-27,48.83,48.93,48.72,48.93,1220193,44.7510146478,44.8426612066,44.6502034332,44.8426612066,1220193,0.0,1.0\nVEU,2017-04-28,48.83,48.88,48.801,48.87,1245121,44.7510146478,44.7968379272,44.724437145799996,44.7876732713,1245121,0.0,1.0\nVEU,2017-05-01,48.96,49.09,48.93,49.04,1676487,44.8701551742,44.9892957006,44.8426612066,44.9434724212,1676487,0.0,1.0\nVEU,2017-05-02,49.27,49.28,49.11,49.15,1277386,45.1542595064,45.1634241623,45.0076250124,45.0442836359,1277386,0.0,1.0\nVEU,2017-05-03,49.1,49.19,49.01,49.12,1223836,44.9984603565,45.0809422594,44.9159784536,45.0167896683,1223836,0.0,1.0\nVEU,2017-05-04,49.3,49.31,49.095,49.21,1127750,45.181753474,45.1909181299,44.993878028599994,45.0992715712,1127750,0.0,1.0\nVEU,2017-05-05,49.78,49.78,49.3,49.31,1275385,45.6216569561,45.6216569561,45.181753474,45.1909181299,1275385,0.0,1.0\nVEU,2017-05-08,49.52,49.6,49.4701,49.56,1707045,45.3833759033,45.4566931504,45.3376442705,45.4200345269,1707045,0.0,1.0\nVEU,2017-05-09,49.54,49.61,49.45,49.55,2404019,45.4017052151,45.4658578062,45.3192233122,45.410869871,2404019,0.0,1.0\nVEU,2017-05-10,49.64,49.66,49.53,49.59,1283289,45.4933517739,45.5116810856,45.3925405592,45.4475284945,1283289,0.0,1.0\nVEU,2017-05-11,49.59,49.61,49.4,49.52,1548461,45.4475284945,45.4658578062,45.2734000328,45.3833759033,1548461,0.0,1.0\nVEU,2017-05-12,49.84,49.84,49.64,49.64,1174404,45.6766448914,45.6766448914,45.4933517739,45.4933517739,1174404,0.0,1.0\nVEU,2017-05-15,50.16,50.16,50.01,50.01,1830096,45.9699138795,45.9699138795,45.8324440413,45.8324440413,1830096,0.0,1.0\nVEU,2017-05-16,50.36,50.41,50.3,50.39,2110313,46.153206997,46.1990302764,46.0982190618,46.1807009647,2110313,0.0,1.0\nVEU,2017-05-17,49.76,50.14,49.74,50.13,2262366,45.6033276444,45.9515845677,45.5849983326,45.9424199118,2262366,0.0,1.0\nVEU,2017-05-18,49.6,49.7267,49.35,49.45,3195011,45.4566931504,45.5728093403,45.2275767534,45.3192233122,3195011,0.0,1.0\nVEU,2017-05-19,50.28,50.32,50.03,50.05,1554436,46.07988975,46.1165483735,45.8507733531,45.869102664799996,1554436,0.0,1.0\nBND,2017-01-03,80.8,80.84,80.5,80.55,3312581,74.1847965619,74.2215217087,73.9093579608,73.9552643943,3312581,0.0,1.0\nBND,2017-01-04,80.86,80.8801,80.75,80.84,3095025,74.23988428210002,74.2583386684,74.1388901284,74.2215217087,3095025,0.0,1.0\nBND,2017-01-05,81.27,81.28,80.9,80.98,3890365,74.6163170369,74.6254983236,74.2766094289,74.3500597225,3890365,0.0,1.0\nBND,2017-01-06,80.95,81.13,80.95,81.1,2287987,74.3225158624,74.4877790231,74.3225158624,74.46023516300001,2287987,0.0,1.0\nBND,2017-01-09,81.15,81.18,81.09,81.17,1785910,74.5061415965,74.5336854566,74.4510538763,74.5245041699,1785910,0.0,1.0\nBND,2017-01-10,81.13,81.18,81.06,81.14,2250692,74.4877790231,74.5336854566,74.4235100162,74.4969603098,2250692,0.0,1.0\nBND,2017-01-11,81.17,81.43,81.06,81.14,2327647,74.5245041699,74.7632176242,74.4235100162,74.4969603098,2327647,0.0,1.0\nBND,2017-01-12,81.19,81.42,81.15,81.37,2981241,74.5428667433,74.7540363375,74.5061415965,74.70812990399999,2981241,0.0,1.0\nBND,2017-01-13,81.06,81.15,80.9135,81.12,2424983,74.4235100162,74.5061415965,74.289004166,74.4785977364,2424983,0.0,1.0\nBND,2017-01-17,81.31,81.4,81.21,81.34,2188598,74.6530421837,74.7356737641,74.56122931670002,74.6805860439,2188598,0.0,1.0\nBND,2017-01-18,80.89,81.21,80.875,81.21,1981696,74.2674281422,74.56122931670002,74.2536562122,74.56122931670002,1981696,0.0,1.0\nBND,2017-01-19,80.75,80.84,80.64,80.78,2121301,74.1388901284,74.2215217087,74.0378959746,74.1664339885,2121301,0.0,1.0\nBND,2017-01-20,80.81,80.85,80.62,80.67,1836282,74.1939778486,74.2307029954,74.0195334012,74.0654398347,1836282,0.0,1.0\nBND,2017-01-23,81.03,81.19,80.82,80.82,1490164,74.3959661561,74.5428667433,74.2031591353,74.2031591353,1490164,0.0,1.0\nBND,2017-01-24,80.91,81.05,80.8165,80.97,2136042,74.2857907156,74.4143287295,74.1999456849,74.3408784358,2136042,0.0,1.0\nBND,2017-01-25,80.67,80.7865,80.62,80.74,1897825,74.0654398347,74.17240182479999,74.0195334012,74.1297088417,1897825,0.0,1.0\nBND,2017-01-26,80.7,80.73,80.52,80.62,2381630,74.0929836949,74.120527555,73.9277205342,74.0195334012,2381630,0.0,1.0\nBND,2017-01-27,80.79,80.84,80.74,80.74,1768665,74.1756152752,74.2215217087,74.1297088417,74.1297088417,1768665,0.0,1.0\nBND,2017-01-30,80.77,80.885,80.75,80.78,2699524,74.1572527018,74.2628374989,74.1388901284,74.1664339885,2699524,0.0,1.0\nBND,2017-01-31,80.94,81.02,80.8,80.8,2857626,74.3133345757,74.38678486939999,74.1847965619,74.1847965619,2857626,0.0,1.0\nBND,2017-02-01,80.63,80.745,80.5,80.56,2313445,74.1829814215,74.2887862443,74.0633759696,74.1185784859,2313445,0.168023,1.0\nBND,2017-02-02,80.68,80.85,80.67,80.79,1745432,74.2289835184,74.3853906478,74.219783099,74.3301881315,1745432,0.0,1.0\nBND,2017-02-03,80.7,80.94,80.61,80.86,2398917,74.2473843571,74.4681944221,74.1645805828,74.3945910671,2398917,0.0,1.0\nBND,2017-02-06,80.95,81.01,80.8,80.96,3238073,74.4773948415,74.5325973578,74.3393885509,74.4865952609,3238073,0.0,1.0\nBND,2017-02-07,81.08,81.15,80.9,80.96,5239327,74.5970002934,74.661403229,74.4313927446,74.4865952609,5239327,0.0,1.0\nBND,2017-02-08,81.3,81.35,81.16,81.17,2172725,74.7994095196,74.8454116165,74.6706036484,74.67980406779999,2172725,0.0,1.0\nBND,2017-02-09,81.08,81.22,81.03,81.14,3410760,74.5970002934,74.7258061646,74.55099819649999,74.6522028096,3410760,0.0,1.0\nBND,2017-02-10,81.06,81.089,80.94,80.95,2011917,74.5785994546,74.6052806708,74.4681944221,74.4773948415,2011917,0.0,1.0\nBND,2017-02-13,80.97,81.01,80.8801,80.98,1863017,74.49579568029999,74.5325973578,74.4130839101,74.5049960996,1863017,0.0,1.0\nBND,2017-02-14,80.8,80.97,80.66,80.94,2178328,74.3393885509,74.49579568029999,74.2105826796,74.4681944221,2178328,0.0,1.0\nBND,2017-02-15,80.67,80.72,80.6,80.61,1503728,74.219783099,74.26578519590001,74.1553801634,74.1645805828,1503728,0.0,1.0\nBND,2017-02-16,80.82,80.94,80.73,80.74,3290696,74.3577893896,74.4681944221,74.2749856153,74.2841860346,3290696,0.0,1.0\nBND,2017-02-17,81.01,81.05,80.96,81.0,1520021,74.5325973578,74.5693990353,74.4865952609,74.5233969384,1520021,0.0,1.0\nBND,2017-02-21,81.0,81.08,80.9,80.94,1811284,74.5233969384,74.5970002934,74.4313927446,74.4681944221,1811284,0.0,1.0\nBND,2017-02-22,81.05,81.17,80.88,81.17,1965822,74.5693990353,74.67980406779999,74.4129919059,74.67980406779999,1965822,0.0,1.0\nBND,2017-02-23,81.2,81.2,81.13,81.17,1590520,74.7074053259,74.7074053259,74.6430023903,74.67980406779999,1590520,0.0,1.0\nBND,2017-02-24,81.5,81.52,81.32,81.36,1703834,74.9834179071,75.0018187459,74.8178103584,74.8546120359,1703834,0.0,1.0\nBND,2017-02-27,81.33,81.465,81.29,81.46,2005647,74.8270107778,74.9512164393,74.7902091003,74.9466162296,2005647,0.0,1.0\nBND,2017-02-28,81.27,81.405,81.26,81.33,1975726,74.7718082615,74.8960139231,74.7626078421,74.8270107778,1975726,0.0,1.0\nBND,2017-03-01,80.78,80.81,80.71,80.78,2954358,74.46670027399999,74.4943556467,74.402171071,74.46670027399999,2954358,0.158376,1.0\nBND,2017-03-02,80.63,80.715,80.54,80.71,2077305,74.3284234104,74.40678029979999,74.24545729229999,74.402171071,2077305,0.0,1.0\nBND,2017-03-03,80.69,80.69,80.53,80.64,2242758,74.3837341559,74.3837341559,74.2362388347,74.337641868,2242758,0.0,1.0\nBND,2017-03-06,80.64,80.72,80.57,80.72,1880936,74.337641868,74.4113895286,74.273112665,74.4113895286,1880936,0.0,1.0\nBND,2017-03-07,80.49,80.63,80.49,80.6,1644855,74.1993650044,74.3284234104,74.1993650044,74.30076803770001,1644855,0.0,1.0\nBND,2017-03-08,80.3,80.35,80.21,80.24,2173044,74.0242143105,74.0703065984,73.9412481924,73.9689035651,2173044,0.0,1.0\nBND,2017-03-09,80.05,80.18,80.02,80.16,2761256,73.7937528712,73.9135928196,73.7660974985,73.8951559045,2761256,0.0,1.0\nBND,2017-03-10,80.21,80.23,80.1,80.14,3430937,73.9412481924,73.9596851075,73.8398451591,73.87671898939999,3430937,0.0,1.0\nBND,2017-03-13,80.05,80.21,80.03,80.12,2164621,73.7937528712,73.9412481924,73.7753159561,73.8582820742,2164621,0.0,1.0\nBND,2017-03-14,80.04,80.14,80.02,80.03,3620704,73.7845344136,73.87671898939999,73.7660974985,73.7753159561,3620704,0.0,1.0\nBND,2017-03-15,80.54,80.55,80.13,80.16,2281368,74.24545729229999,74.2546757498,73.8675005318,73.8951559045,2281368,0.0,1.0\nBND,2017-03-16,80.42,80.53,80.41,80.48,2617769,74.1348358014,74.2362388347,74.1256173438,74.19014654680001,2617769,0.0,1.0\nBND,2017-03-17,80.58,80.61,80.47,80.47,1292991,74.2823311226,74.3099864953,74.1809280893,74.1809280893,1292991,0.0,1.0\nBND,2017-03-20,80.744,80.75,80.57,80.59,1510931,74.43351382680001,74.43904490130002,74.273112665,74.2915495801,1510931,0.0,1.0\nBND,2017-03-21,80.93,80.93,80.66,80.7,2078954,74.6049771376,74.6049771376,74.3560787831,74.3929526134,2078954,0.0,1.0\nBND,2017-03-22,81.04,81.107,80.9532,81.0,1339572,74.7063801709,74.7681438367,74.6263639592,74.66950634060001,1339572,0.0,1.0\nBND,2017-03-23,81.0,81.1,80.8934,81.07,1790362,74.66950634060001,74.7616909164,74.5712375829,74.7340355436,1790362,0.0,1.0\nBND,2017-03-24,80.98,81.07,80.93,80.94,3736682,74.6510694255,74.7340355436,74.6049771376,74.6141955952,3736682,0.0,1.0\nBND,2017-03-27,81.15,81.22,81.09,81.2,1870514,74.8077832042,74.8723124072,74.7524724588,74.8538754921,1870514,0.0,1.0\nBND,2017-03-28,81.0,81.23,80.96,81.23,1990824,74.66950634060001,74.8815308648,74.6326325103,74.8815308648,1990824,0.0,1.0\nBND,2017-03-29,81.16,81.16,81.025,81.05,1356818,74.8170016618,74.8170016618,74.6925524846,74.7155986285,1356818,0.0,1.0\nBND,2017-03-30,81.03,81.11,80.97,81.1,1403541,74.6971617133,74.7709093739,74.6418509679,74.7616909164,1403541,0.0,1.0\nBND,2017-03-31,81.08,81.1,81.01,81.01,2399459,74.7432540012,74.7616909164,74.6787247982,74.6787247982,2399459,0.0,1.0\nBND,2017-04-03,81.2,81.2,80.91,80.97,2142715,75.0139097592,75.0139097592,74.7460029387,74.801431936,2142715,0.173602,1.0\nBND,2017-04-04,81.11,81.18,81.08,81.18,1983228,74.9307662632,74.9954334268,74.90305176449999,74.9954334268,1983228,0.0,1.0\nBND,2017-04-05,81.18,81.24,81.015,81.07,2050810,74.9954334268,75.0508624242,74.8430036841,74.8938135983,2050810,0.0,1.0\nBND,2017-04-06,81.21,81.23,81.0601,81.18,1855272,75.0231479255,75.0416242579,74.8846678137,74.9954334268,1855272,0.0,1.0\nBND,2017-04-07,81.03,81.36,81.01,81.3,1608178,74.8568609334,75.1617204189,74.8383846009,75.10629142149999,1608178,0.0,1.0\nBND,2017-04-10,81.11,81.19,81.07,81.12,1481751,74.9307662632,75.004671593,74.8938135983,74.94000442939999,1481751,0.0,1.0\nBND,2017-04-11,81.37,81.41,81.23,81.24,1340521,75.1709585851,75.20791125,75.0416242579,75.0508624242,1340521,0.0,1.0\nBND,2017-04-12,81.56,81.56,81.3312,81.36,1801558,75.3464837434,75.3464837434,75.1351145001,75.1617204189,1801558,0.0,1.0\nBND,2017-04-13,81.68,81.729,81.52,81.61,1393521,75.4573417381,75.5026087526,75.3095310785,75.39267457449999,1393521,0.0,1.0\nBND,2017-04-17,81.63,81.73,81.54,81.69,1494596,75.41115090699999,75.5035325692,75.3280074109,75.4665799043,1494596,0.0,1.0\nBND,2017-04-18,81.92,81.9666,81.718,81.72,1783017,75.6790577276,75.7221075822,75.4924467698,75.494294403,1783017,0.0,1.0\nBND,2017-04-19,81.77,81.83,81.7201,81.83,1437089,75.5404852342,75.59591423149999,75.4943867847,75.59591423149999,1437089,0.0,1.0\nBND,2017-04-20,81.66,81.74,81.5834,81.74,1425716,75.4388654057,75.5127707355,75.3681010524,75.5127707355,1425716,0.0,1.0\nBND,2017-04-21,81.68,81.79,81.655,81.73,1241682,75.4573417381,75.5589615666,75.4342463226,75.5035325692,1241682,0.0,1.0\nBND,2017-04-24,81.57,81.59,81.43,81.48,2665681,75.3557219096,75.37419824210002,75.2263875825,75.2725784136,2665681,0.0,1.0\nBND,2017-04-25,81.32,81.4571,81.27,81.41,1670922,75.12476775399999,75.2514230129,75.0785769228,75.20791125,1670922,0.0,1.0\nBND,2017-04-26,81.4,81.42,81.26,81.29,1876229,75.1986730838,75.2171494162,75.0693387566,75.0970532553,1876229,0.0,1.0\nBND,2017-04-27,81.46,81.525,81.3746,81.42,1543129,75.2541020811,75.3141501616,75.1752081416,75.2171494162,1543129,0.0,1.0\nBND,2017-04-28,81.55,81.59,81.36,81.4,2100002,75.33724557720001,75.37419824210002,75.1617204189,75.1986730838,2100002,0.0,1.0\nBND,2017-05-01,81.23,81.4087,81.14,81.34,1478157,75.1967386118,75.36216588239999,75.11342325439999,75.2985684929,1478157,0.167906,1.0\nBND,2017-05-02,81.37,81.38,81.2,81.24,1664291,75.3263402787,75.3355975406,75.168966826,75.2059958737,1664291,0.0,1.0\nBND,2017-05-03,81.28,81.45,81.215,81.4,2948426,75.2430249214,75.40039837409999,75.18285271890001,75.3541120645,2948426,0.0,1.0\nBND,2017-05-04,81.19,81.2,81.09,81.14,1216082,75.1597095641,75.168966826,75.0671369448,75.11342325439999,1216082,0.0,1.0\nBND,2017-05-05,81.24,81.24,81.1047,81.2,1658920,75.2059958737,75.2059958737,75.0807451199,75.168966826,1658920,0.0,1.0\nBND,2017-05-08,81.1,81.21,81.06,81.19,1832262,75.0763942068,75.1782240879,75.0393651591,75.1597095641,1832262,0.0,1.0\nBND,2017-05-09,81.06,81.1,81.0,81.05,2532184,75.0393651591,75.0763942068,74.9838215875,75.0301078971,2532184,0.0,1.0\nBND,2017-05-10,81.05,81.2,81.01,81.14,1845536,75.0301078971,75.168966826,74.99307884939999,75.11342325439999,1845536,0.0,1.0\nBND,2017-05-11,81.11,81.11,80.93,81.0,1421523,75.0856514687,75.0856514687,74.919020754,74.9838215875,1421523,0.0,1.0\nBND,2017-05-12,81.35,81.38,81.25,81.29,1510314,75.3078257549,75.3355975406,75.21525313560001,75.2522821833,1510314,0.0,1.0\nBND,2017-05-15,81.33,81.39,81.3,81.3,1823465,75.289311231,75.3448548026,75.2615394452,75.2615394452,1823465,0.0,1.0\nBND,2017-05-16,81.41,81.51,81.36,81.36,1760573,75.3633693264,75.4559419456,75.3170830168,75.3170830168,1760573,0.0,1.0\nBND,2017-05-17,81.85,81.87,81.63,81.63,1987109,75.77068885109999,75.7892033749,75.5670290887,75.5670290887,1987109,0.0,1.0\nBND,2017-05-18,81.78,81.892,81.74,81.87,1881841,75.7058880176,75.8095693512,75.6688589699,75.7892033749,1881841,0.0,1.0\nBND,2017-05-19,81.83,81.8343,81.7,81.81,1394681,75.75217432720001,75.7561549499,75.6318299222,75.7336598034,1394681,0.0,1.0\nVNQ,2017-01-03,82.8,83.0,82.14,82.98,9380344,72.7227670446,72.8984259022,72.1430928146,72.8808600165,9380344,0.0,1.0\nVNQ,2017-01-04,84.01,84.19,82.83,82.91,6598743,73.78550313310001,73.9435961049,72.7491158733,72.8193794163,6598743,0.0,1.0\nVNQ,2017-01-05,84.28,84.38,82.92,83.7,8555674,74.0226425908,74.1104720196,72.82816235920002,73.5132319038,8555674,0.0,1.0\nVNQ,2017-01-06,84.29,84.7,83.82,84.02,3901163,74.0314255337,74.3915261918,73.61862721840001,73.794286076,3901163,0.0,1.0\nVNQ,2017-01-09,83.53,84.53,83.495,84.53,3546074,73.3639218749,74.24221616279999,73.3331815748,74.24221616279999,3546074,0.0,1.0\nVNQ,2017-01-10,82.82,83.57,82.78,83.57,3959633,72.7403329304,73.3990536464,72.70520115890001,73.3990536464,3959633,0.0,1.0\nVNQ,2017-01-11,82.31,82.96,82.23,82.87,4498021,72.2924028435,72.8632941307,72.2221393005,72.78424764479999,4498021,0.0,1.0\nVNQ,2017-01-12,82.73,82.77,81.6,82.4,4670739,72.66128644449999,72.696418216,71.6688138991,72.3714493294,4670739,0.0,1.0\nVNQ,2017-01-13,82.61,83.02,82.33,82.72,3658131,72.5558911299,72.915991788,72.3099687293,72.6525035016,3658131,0.0,1.0\nVNQ,2017-01-17,83.25,83.32,82.76,82.96,4139242,73.1179994742,73.17948007439999,72.6876352731,72.8632941307,4139242,0.0,1.0\nVNQ,2017-01-18,83.39,83.665,83.03,83.22,3317394,73.2409606745,73.4824916037,72.9247747309,73.0916506456,3317394,0.0,1.0\nVNQ,2017-01-19,82.54,83.25,82.3361,83.08,3169244,72.49441052979999,73.1179994742,72.31532632439999,72.9686894453,3169244,0.0,1.0\nVNQ,2017-01-20,83.14,83.1873,82.38,82.57,3285567,73.0213871026,73.0629304224,72.3538834437,72.5207593584,3285567,0.0,1.0\nVNQ,2017-01-23,83.84,83.97,83.0,83.26,3165576,73.6361931041,73.7503713616,72.8984259022,73.1267824171,3165576,0.0,1.0\nVNQ,2017-01-24,83.86,84.21,83.5,83.84,4204177,73.6537589899,73.96116199069999,73.33757304619998,73.6361931041,4204177,0.0,1.0\nVNQ,2017-01-25,83.31,84.16,83.055,83.81,3461494,73.17069713149999,73.9172472763,72.9467320881,73.6098442755,3461494,0.0,1.0\nVNQ,2017-01-26,83.15,83.72,83.0341,83.32,4093711,73.0301700454,73.5307977896,72.9283757375,73.17948007439999,4093711,0.0,1.0\nVNQ,2017-01-27,82.34,83.5,82.02,83.39,2996199,72.31875167220001,73.33757304619998,72.0376975,73.2409606745,2996199,0.0,1.0\nVNQ,2017-01-30,81.78,82.27,81.57,82.1,3182619,71.8269068709,72.257271072,71.6424650704,72.1079610431,3182619,0.0,1.0\nVNQ,2017-01-31,82.37,82.95,81.8,81.8,11091498,72.3451005008,72.8545111878,71.8444727567,71.8444727567,11091498,0.0,1.0\nVNQ,2017-02-01,81.38,82.89,81.32,82.28,4555854,71.47558915569999,72.8018135306,71.4228914984,72.26605401489999,4555854,0.0,1.0\nVNQ,2017-02-02,82.35,82.43,81.42,81.43,4754825,72.327534615,72.3977981581,71.5107209272,71.5195038701,4754825,0.0,1.0\nVNQ,2017-02-03,82.8,83.23,82.46,82.96,4165139,72.7227670446,73.10043358850001,72.4241469867,72.8632941307,4165139,0.0,1.0\nVNQ,2017-02-06,82.66,83.06,82.48899999999998,82.9,2985824,72.59980584430001,72.9511235595,72.4496175211,72.8105964734,2985824,0.0,1.0\nVNQ,2017-02-07,82.37,83.03,82.235,82.73,3250658,72.3451005008,72.9247747309,72.2265307719,72.66128644449999,3250658,0.0,1.0\nVNQ,2017-02-08,83.02,83.2,82.3,82.62,3316789,72.915991788,73.0740847598,72.2836199007,72.5646740728,3316789,0.0,1.0\nVNQ,2017-02-09,83.22,83.38,82.92,83.0,2806990,73.0916506456,73.2321777317,72.82816235920002,72.8984259022,2806990,0.0,1.0\nVNQ,2017-02-10,83.81,83.86,83.03,83.19,2983820,73.6098442755,73.6537589899,72.9247747309,73.06530181699999,2983820,0.0,1.0\nVNQ,2017-02-13,83.84,84.2,83.38,83.88,3968165,73.6361931041,73.9523790478,73.2321777317,73.6713248757,3968165,0.0,1.0\nVNQ,2017-02-14,83.4,83.69,82.75,83.64,2688061,73.2497436174,73.5044489609,72.6788523302,73.46053424649999,2688061,0.0,1.0\nVNQ,2017-02-15,83.12,83.25,82.34,82.88,3687277,73.0038212168,73.1179994742,72.31875167220001,72.7930305877,3687277,0.0,1.0\nVNQ,2017-02-16,83.61,84.17,83.12,83.21,3097815,73.4341854179,73.9260302192,73.0038212168,73.0828677027,3097815,0.0,1.0\nVNQ,2017-02-17,83.77,83.9,83.069,83.81,2624200,73.57471250399999,73.6888907614,72.95902820810001,73.6098442755,2624200,0.0,1.0\nVNQ,2017-02-21,84.84,84.93,83.57,83.68,5646684,74.5144873921,74.593533878,73.3990536464,73.49566601810001,5646684,0.0,1.0\nVNQ,2017-02-22,84.56,85.25,84.13,84.96,3464404,74.2685649915,74.8745880502,73.89089844770001,74.6198827067,3464404,0.0,1.0\nVNQ,2017-02-23,85.0,85.07,84.22,84.79,3430911,74.6550144782,74.7164950784,73.96994493359999,74.4705726777,3430911,0.0,1.0\nVNQ,2017-02-24,85.4,85.43,84.52,84.98,3667437,75.0063321934,75.03268102199999,74.23343322,74.63744859239999,3667437,0.0,1.0\nVNQ,2017-02-27,85.85,86.16,85.32,85.39,3326666,75.401564623,75.6738358522,74.9360686503,74.9975492505,3326666,0.0,1.0\nVNQ,2017-02-28,85.26,85.85,85.06,85.85,5778083,74.8833709931,75.401564623,74.7077121355,75.401564623,5778083,0.0,1.0\nVNQ,2017-03-01,85.0,85.49,84.7,85.0,6282779,74.6550144782,75.0853786793,74.3915261918,74.6550144782,6282779,0.0,1.0\nVNQ,2017-03-02,84.54,84.95,84.25,84.88,3060437,74.2509991057,74.6110997638,73.9962937622,74.5496191636,3060437,0.0,1.0\nVNQ,2017-03-03,84.21,84.55,83.35,84.47,4177733,73.96116199069999,74.2597820486,73.205828903,74.1895185056,4177733,0.0,1.0\nVNQ,2017-03-06,83.83,84.1,83.43,84.06,3237738,73.6274101613,73.864549619,73.2760924461,73.8294178475,3237738,0.0,1.0\nVNQ,2017-03-07,83.43,83.79,83.03,83.65,4658035,73.2760924461,73.5922783897,72.9247747309,73.4693171894,4658035,0.0,1.0\nVNQ,2017-03-08,81.89,83.04,81.83,82.94,4814124,71.92351924260001,72.9335576738,71.87082158529999,72.845728245,4814124,0.0,1.0\nVNQ,2017-03-09,80.66,82.22,80.48,81.73,5604251,70.8432172684,72.2133563576,70.68512429649999,71.7829921565,5604251,0.0,1.0\nVNQ,2017-03-10,80.41,81.71,79.98,81.3,6278799,70.62364369640001,71.76542627069999,70.2459771525,71.40532561270001,6278799,0.0,1.0\nVNQ,2017-03-13,80.61,81.11,80.34,80.5,4691167,70.799302554,71.238449698,70.5621630962,70.70269018229999,4691167,0.0,1.0\nVNQ,2017-03-14,80.59,80.79,80.07,80.53,4582403,70.7817366682,70.9573955258,70.3250236385,70.7290390109,4582403,0.0,1.0\nVNQ,2017-03-15,82.22,82.635,80.78,80.83,7177832,72.2133563576,72.5778484871,70.9486125829,70.9925272973,7177832,0.0,1.0\nVNQ,2017-03-16,82.06,82.62,81.91,82.13,4456696,72.0728292715,72.5646740728,71.9410851283,72.1343098717,4456696,0.0,1.0\nVNQ,2017-03-17,82.44,82.7,82.0,82.4,4380626,72.406581101,72.6349376158,72.0201316143,72.3714493294,4380626,0.0,1.0\nVNQ,2017-03-20,82.2991,82.74,82.16,82.5,2748459,72.2828294358,72.67006938739999,72.1606587003,72.4592787582,2748459,0.0,1.0\nVNQ,2017-03-21,82.01,82.72,81.88,82.48,6969660,72.0289145571,72.6525035016,71.9147362997,72.4417128725,6969660,0.0,1.0\nVNQ,2017-03-22,81.37,81.56,80.55,81.51,4618023,71.9893913142,72.1574874718,71.2639236863,72.1132516409,4618023,0.595,1.0\nVNQ,2017-03-23,81.97,82.63,81.26,81.38,7610221,72.5202212858,73.1041342545,71.89207248609999,71.9982384804,7610221,0.0,1.0\nVNQ,2017-03-24,81.88,82.45,81.78,82.09,4399217,72.44059679,72.944885263,72.3521251281,72.6263872801,4399217,0.0,1.0\nVNQ,2017-03-27,81.14,82.12,80.885,81.76,4555963,71.7859064917,72.6529287787,71.5603037538,72.3344307957,4555963,0.0,1.0\nVNQ,2017-03-28,81.49,81.61,80.57,81.33,4231891,72.0955573085,72.20172330279999,71.2816180187,71.9540026494,4231891,0.0,1.0\nVNQ,2017-03-29,81.95,81.96,81.21,81.46,3123293,72.50252695340001,72.5113741196,71.8478366551,72.0690158099,3123293,0.0,1.0\nVNQ,2017-03-30,82.1,82.225,81.275,81.82,4593334,72.6352344463,72.7458240237,71.9053432353,72.3875137929,4593334,0.0,1.0\nVNQ,2017-03-31,82.59,82.8864,81.99,82.13,7167920,73.0687455897,73.3309755957,72.5379156182,72.6617759449,7167920,0.0,1.0\nVNQ,2017-04-03,82.83,82.985,82.32,82.36,7468996,73.2810775784,73.41820865439999,72.8298721025,72.8652607673,7468996,0.0,1.0\nVNQ,2017-04-04,82.83,83.37,82.5995,82.71,5359286,73.2810775784,73.7588245528,73.07715039760001,73.1749115841,5359286,0.0,1.0\nVNQ,2017-04-05,82.95,83.41,82.82,82.91,4738723,73.3872435727,73.7942132176,73.2722304122,73.3518549079,4738723,0.0,1.0\nVNQ,2017-04-06,83.39,83.535,82.4335,82.77,4711527,73.7765188852,73.904802795,72.93028743880002,73.2279945812,4711527,0.0,1.0\nVNQ,2017-04-07,83.51,83.88,83.27,83.59,4483834,73.8826848795,74.2100300287,73.6703528909,73.9534622091,4483834,0.0,1.0\nVNQ,2017-04-10,84.03,84.16,83.36,83.56,2547820,74.3427375216,74.4577506821,73.7499773866,73.9269207105,2547820,0.0,1.0\nVNQ,2017-04-11,84.61,84.77,83.94,84.06,3864186,74.8558731608,74.9974278199,74.2631130258,74.36927902020001,3864186,0.0,1.0\nVNQ,2017-04-12,84.46,84.9,84.31,84.58,3526946,74.7231656679,75.1124409804,74.590458175,74.8293316622,3526946,0.0,1.0\nVNQ,2017-04-13,84.27,84.65,84.16,84.43,2759502,74.5550695102,74.8912618256,74.4577506821,74.69662416930001,2759502,0.0,1.0\nVNQ,2017-04-17,85.29,85.31,84.31,84.4,3875010,75.45748046189999,75.4751747943,74.590458175,74.6700826707,3875010,0.0,1.0\nVNQ,2017-04-18,85.52,85.6,85.16,85.22,2807318,75.66096528439999,75.73174261390001,75.3424673014,75.3955502986,2807318,0.0,1.0\nVNQ,2017-04-19,85.4,85.72,85.25,85.45,3622934,75.55479929,75.8379086082,75.4220917971,75.599035121,3622934,0.0,1.0\nVNQ,2017-04-20,85.41,85.47,84.8,85.24,3109054,75.5636464562,75.6167294534,75.0239693184,75.413244631,3109054,0.0,1.0\nVNQ,2017-04-21,85.1,85.46,84.955,85.36,3261924,75.2893843042,75.6078822872,75.1611003944,75.5194106253,3261924,0.0,1.0\nVNQ,2017-04-24,84.16,85.55,83.39,85.5,6595301,74.4577506821,75.6875067829,73.7765188852,75.643270952,6595301,0.0,1.0\nVNQ,2017-04-25,84.51,84.64,84.02,84.16,3412276,74.7674014988,74.8824146594,74.3338903554,74.4577506821,3412276,0.0,1.0\nVNQ,2017-04-26,83.88,84.62,83.67,84.45,4185489,74.2100300287,74.864720327,74.0242395386,74.7143185017,4185489,0.0,1.0\nVNQ,2017-04-27,83.66,84.21,83.495,84.01,4822219,74.0153923724,74.50198651310001,73.8694141302,74.3250431892,4822219,0.0,1.0\nVNQ,2017-04-28,82.79,83.57,82.49,83.44,5983748,73.24568891359999,73.9357678767,72.9802739278,73.8207547162,5983748,0.0,1.0\nVNQ,2017-05-01,83.28,83.53,82.46,83.15,6003561,73.6792000571,73.9003792119,72.9537324292,73.56418689659999,6003561,0.0,1.0\nVNQ,2017-05-02,83.14,83.59,82.83,83.38,4930021,73.5553397304,73.9534622091,73.2810775784,73.76767171899999,4930021,0.0,1.0\nVNQ,2017-05-03,82.03,83.2,81.76100000000002,83.1,6207577,72.5733042829,73.6084227275,72.3353155123,73.5199510656,6207577,0.0,1.0\nVNQ,2017-05-04,81.63,81.76,80.7,81.52,8025099,72.2194176352,72.3344307957,71.3966311792,72.1220988071,8025099,0.0,1.0\nVNQ,2017-05-05,82.4,82.4,81.77,81.81,5458059,72.9006494321,72.9006494321,72.3432779619,72.3786666267,5458059,0.0,1.0\nVNQ,2017-05-08,81.82,82.58,81.365,82.58,3940834,72.3875137929,73.0598984236,71.9849677311,73.0598984236,3940834,0.0,1.0\nVNQ,2017-05-09,81.38,81.92,81.06,81.81,5790894,71.9982384804,72.4759854548,71.7151291622,72.3786666267,5790894,0.0,1.0\nVNQ,2017-05-10,82.02,82.3,81.17,81.37,3801407,72.5644571167,72.8121777701,71.8124479903,71.9893913142,3801407,0.0,1.0\nVNQ,2017-05-11,81.61,81.85,80.9,81.72,3542814,72.20172330279999,72.41405529149999,71.5735745031,72.2990421309,3542814,0.0,1.0\nVNQ,2017-05-12,81.28,81.78,81.21,81.75,3115409,71.9097668184,72.3521251281,71.8478366551,72.3255836295,3115409,0.0,1.0\nVNQ,2017-05-15,81.52,82.2158,81.3,81.35,3263946,72.1220988071,72.7376846308,71.9274611508,71.97169698180001,3263946,0.0,1.0\nVNQ,2017-05-16,80.97,81.69,80.71,81.52,4338574,71.63550466640001,72.2725006324,71.4054783454,72.1220988071,4338574,0.0,1.0\nVNQ,2017-05-17,81.3,81.61,80.75,80.92,7243679,71.9274611508,72.20172330279999,71.44086701020001,71.5912688355,7243679,0.0,1.0\nVNQ,2017-05-18,81.75,81.96,80.73,81.17,4128222,72.3255836295,72.5113741196,71.4231726778,71.8124479903,4128222,0.0,1.0\nVNQ,2017-05-19,82.19,82.6799,81.345,81.69,6390302,72.71485894199999,73.1482816138,71.9672733987,72.2725006324,6390302,0.0,1.0\nDBC,2017-01-03,15.65,15.98,15.585,15.97,5935170,15.2036276052,15.5242152799,15.140481548,15.5145005019,5935170,0.0,1.0\nDBC,2017-01-04,15.77,15.8,15.61,15.66,3281478,15.3202049415,15.3493492755,15.164768493099999,15.213342383199999,3281478,0.0,1.0\nDBC,2017-01-05,15.85,15.92,15.7,15.84,28475953,15.3979231656,15.4659266118,15.2522014953,15.3882083876,28475953,0.0,1.0\nDBC,2017-01-06,15.83,15.89,15.75,15.85,2782087,15.3784936096,15.436782277699999,15.3007753854,15.3979231656,2782087,0.0,1.0\nDBC,2017-01-09,15.59,15.71,15.56,15.71,3365965,15.1453389371,15.261916273299999,15.116194603,15.261916273299999,3365965,0.0,1.0\nDBC,2017-01-10,15.52,15.7,15.51,15.66,1915147,15.0773354909,15.2522014953,15.0676207129,15.213342383199999,1915147,0.0,1.0\nDBC,2017-01-11,15.69,15.77,15.49,15.63,2711922,15.2424867173,15.3202049415,15.048191156800003,15.1841980492,2711922,0.0,1.0\nDBC,2017-01-12,15.92,15.989,15.83,15.83,1752715,15.4659266118,15.532958580199999,15.3784936096,15.3784936096,1752715,0.0,1.0\nDBC,2017-01-13,15.89,15.91,15.8201,15.85,1240969,15.436782277699999,15.456211833800001,15.3688759794,15.3979231656,1240969,0.0,1.0\nDBC,2017-01-17,15.88,16.08,15.88,16.06,2168075,15.4270674997,15.6213630602,15.4270674997,15.601933504100002,2168075,0.0,1.0\nDBC,2017-01-18,15.74,15.88,15.67,15.8,1570600,15.2910606074,15.4270674997,15.223057161199998,15.3493492755,1570600,0.0,1.0\nDBC,2017-01-19,15.69,15.7999,15.69,15.77,2495886,15.2424867173,15.3492521278,15.2424867173,15.3202049415,2495886,0.0,1.0\nDBC,2017-01-20,15.87,15.9377,15.71,15.89,1780932,15.4173527217,15.4831217689,15.261916273299999,15.436782277699999,1780932,0.0,1.0\nDBC,2017-01-23,15.87,15.91,15.76,15.77,1390745,15.4173527217,15.456211833800001,15.310490163399999,15.3202049415,1390745,0.0,1.0\nDBC,2017-01-24,15.91,15.98,15.89,15.95,2219182,15.456211833800001,15.5242152799,15.436782277699999,15.495070945899998,2219182,0.0,1.0\nDBC,2017-01-25,15.81,15.88,15.765,15.8,1724204,15.3590640536,15.4270674997,15.3153475525,15.3493492755,1724204,0.0,1.0\nDBC,2017-01-26,15.88,15.96,15.87,15.89,2201365,15.4270674997,15.504785723900001,15.4173527217,15.436782277699999,2201365,0.0,1.0\nDBC,2017-01-27,15.75,15.9,15.69,15.9,1835637,15.3007753854,15.4464970558,15.2424867173,15.4464970558,1835637,0.0,1.0\nDBC,2017-01-30,15.66,15.69,15.62,15.68,4275017,15.213342383199999,15.2424867173,15.1744832711,15.232771939300001,4275017,0.0,1.0\nDBC,2017-01-31,15.75,15.84,15.7,15.78,14620186,15.3007753854,15.3882083876,15.2522014953,15.329919719500001,14620186,0.0,1.0\nDBC,2017-02-01,15.92,15.95,15.76,15.8,4732012,15.4659266118,15.495070945899998,15.310490163399999,15.3493492755,4732012,0.0,1.0\nDBC,2017-02-02,15.92,15.98,15.87,15.97,3617149,15.4659266118,15.5242152799,15.4173527217,15.5145005019,3617149,0.0,1.0\nDBC,2017-02-03,15.91,15.95,15.82,15.85,2433534,15.456211833800001,15.495070945899998,15.3687788316,15.3979231656,2433534,0.0,1.0\nDBC,2017-02-06,15.81,15.935,15.78,15.91,2332827,15.3590640536,15.480498778800001,15.329919719500001,15.456211833800001,2332827,0.0,1.0\nDBC,2017-02-07,15.74,15.75,15.67,15.69,2026529,15.2910606074,15.3007753854,15.223057161199998,15.2424867173,2026529,0.0,1.0\nDBC,2017-02-08,15.8,15.8354,15.685,15.75,2314612,15.3493492755,15.383739589700001,15.237629328299999,15.3007753854,2314612,0.0,1.0\nDBC,2017-02-09,15.88,15.91,15.82,15.91,1669716,15.4270674997,15.456211833800001,15.3687788316,15.456211833800001,1669716,0.0,1.0\nDBC,2017-02-10,16.05,16.07,15.98,16.01,2768614,15.592218726099999,15.6116482821,15.5242152799,15.553359614000001,2768614,0.0,1.0\nDBC,2017-02-13,15.87,15.91,15.84,15.9,1122521,15.4173527217,15.456211833800001,15.3882083876,15.4464970558,1122521,0.0,1.0\nDBC,2017-02-14,15.89,15.99,15.84,15.97,1679740,15.436782277699999,15.533930058,15.3882083876,15.5145005019,1679740,0.0,1.0\nDBC,2017-02-15,15.91,15.9599,15.84,15.87,1350379,15.456211833800001,15.5046885761,15.3882083876,15.4173527217,1350379,0.0,1.0\nDBC,2017-02-16,15.81,15.94,15.79,15.91,1300268,15.3590640536,15.4853561678,15.3396344975,15.456211833800001,1300268,0.0,1.0\nDBC,2017-02-17,15.78,15.785,15.69,15.71,2265544,15.329919719500001,15.334777108499999,15.2424867173,15.261916273299999,2265544,0.0,1.0\nDBC,2017-02-21,15.76,15.88,15.73,15.85,1838728,15.310490163399999,15.4270674997,15.281345829400001,15.3979231656,1838728,0.0,1.0\nDBC,2017-02-22,15.68,15.7,15.65,15.69,2480686,15.232771939300001,15.2522014953,15.2036276052,15.2424867173,2480686,0.0,1.0\nDBC,2017-02-23,15.73,15.84,15.69,15.83,2209653,15.281345829400001,15.3882083876,15.2424867173,15.3784936096,2209653,0.0,1.0\nDBC,2017-02-24,15.7,15.74,15.655,15.74,1371642,15.2522014953,15.2910606074,15.2084849942,15.2910606074,1371642,0.0,1.0\nDBC,2017-02-27,15.66,15.74,15.65,15.72,1914255,15.213342383199999,15.2910606074,15.2036276052,15.271631051400002,1914255,0.0,1.0\nDBC,2017-02-28,15.72,15.73,15.6,15.63,2321025,15.271631051400002,15.281345829400001,15.1550537151,15.1841980492,2321025,0.0,1.0\nDBC,2017-03-01,15.79,15.85,15.75,15.81,2324044,15.3396344975,15.3979231656,15.3007753854,15.3590640536,2324044,0.0,1.0\nDBC,2017-03-02,15.54,15.65,15.53,15.62,1381236,15.0967650469,15.2036276052,15.0870502689,15.1744832711,1381236,0.0,1.0\nDBC,2017-03-03,15.62,15.63,15.53,15.56,1805581,15.1744832711,15.1841980492,15.0870502689,15.116194603,1805581,0.0,1.0\nDBC,2017-03-06,15.6,15.6966,15.6,15.69,1297766,15.1550537151,15.248898470799999,15.1550537151,15.2424867173,1297766,0.0,1.0\nDBC,2017-03-07,15.53,15.64,15.52,15.64,1468045,15.0870502689,15.1939128272,15.0773354909,15.1939128272,1468045,0.0,1.0\nDBC,2017-03-08,15.18,15.545,15.15,15.48,4012228,14.7470330381,15.101622436,14.717888704100002,15.0384763788,4012228,0.0,1.0\nDBC,2017-03-09,15.05,15.1499,14.93,15.13,4657754,14.620740923800001,14.717791556300002,14.504163587599999,14.698459148,4657754,0.0,1.0\nDBC,2017-03-10,14.91,15.08,14.87,15.05,4163741,14.4847340315,14.6498852579,14.445874919400001,14.620740923800001,4163741,0.0,1.0\nDBC,2017-03-13,14.89,14.965,14.86,14.92,2701206,14.4653044755,14.5381653107,14.4361601414,14.4944488096,2701206,0.0,1.0\nDBC,2017-03-14,14.83,14.85,14.7,14.78,2946350,14.4070158074,14.4264453634,14.280723693099999,14.3584419172,2946350,0.0,1.0\nDBC,2017-03-15,14.96,14.99,14.87,14.92,3442653,14.5333079216,14.562452255699997,14.445874919400001,14.4944488096,3442653,0.0,1.0\nDBC,2017-03-16,14.94,15.02,14.93,15.02,2088325,14.5138783656,14.5915965898,14.504163587599999,14.5915965898,2088325,0.0,1.0\nDBC,2017-03-17,14.99,15.02,14.94,15.0,1161788,14.562452255699997,14.5915965898,14.5138783656,14.5721670337,1161788,0.0,1.0\nDBC,2017-03-20,14.9817,15.04,14.94,14.96,2166405,14.55438899,14.611026145799999,14.5138783656,14.5333079216,2166405,0.0,1.0\nDBC,2017-03-21,14.9,15.06,14.89,15.02,1882036,14.4750192535,14.6304557019,14.4653044755,14.5915965898,1882036,0.0,1.0\nDBC,2017-03-22,14.89,14.935,14.77,14.86,1921486,14.4653044755,14.5090209766,14.348727139200001,14.4361601414,1921486,0.0,1.0\nDBC,2017-03-23,14.86,14.89,14.82,14.87,1511520,14.4361601414,14.4653044755,14.397301029300001,14.445874919400001,1511520,0.0,1.0\nDBC,2017-03-24,14.91,14.92,14.86,14.9,1372420,14.4847340315,14.4944488096,14.4361601414,14.4750192535,1372420,0.0,1.0\nDBC,2017-03-27,14.89,14.9,14.77,14.81,2123961,14.4653044755,14.4750192535,14.348727139200001,14.387586251300002,2123961,0.0,1.0\nDBC,2017-03-28,14.98,15.06,14.95,14.96,1713273,14.5527374777,14.6304557019,14.5235931436,14.5333079216,1713273,0.0,1.0\nDBC,2017-03-29,15.12,15.14,15.02,15.04,2552967,14.68874437,14.708173925999999,14.5915965898,14.611026145799999,2552967,0.0,1.0\nDBC,2017-03-30,15.18,15.22,15.1202,15.16,1325058,14.7470330381,14.7858921502,14.6889386656,14.727603482100001,1325058,0.0,1.0\nDBC,2017-03-31,15.21,15.25,15.14,15.15,3000072,14.776177372200001,14.8150364843,14.708173925999999,14.717888704100002,3000072,0.0,1.0\nDBC,2017-04-03,15.15,15.27,15.14,15.25,4604322,14.717888704100002,14.8344660403,14.708173925999999,14.8150364843,4604322,0.0,1.0\nDBC,2017-04-04,15.3,15.32,15.15,15.23,4604387,14.8636103744,14.883039930499999,14.717888704100002,14.7956069283,4604387,0.0,1.0\nDBC,2017-04-05,15.33,15.47,15.31,15.47,4604593,14.892754708499998,15.0287616008,14.8733251524,15.0287616008,4604593,0.0,1.0\nDBC,2017-04-06,15.37,15.44,15.37,15.4,2856183,14.931613820599999,14.999617266700001,14.931613820599999,14.9607581546,2856183,0.0,1.0\nDBC,2017-04-07,15.41,15.47,15.39,15.42,6227130,14.9704729327,15.0287616008,14.951043376600001,14.9801877107,6227130,0.0,1.0\nDBC,2017-04-10,15.53,15.53,15.45,15.48,945755,15.0870502689,15.0870502689,15.0093320447,15.0384763788,945755,0.0,1.0\nDBC,2017-04-11,15.56,15.59,15.4401,15.52,1391664,15.116194603,15.1453389371,14.9997144145,15.0773354909,1391664,0.0,1.0\nDBC,2017-04-12,15.56,15.64,15.52,15.56,1429337,15.116194603,15.1939128272,15.0773354909,15.116194603,1429337,0.0,1.0\nDBC,2017-04-13,15.61,15.65,15.56,15.63,2122972,15.164768493099999,15.2036276052,15.116194603,15.1841980492,2122972,0.0,1.0\nDBC,2017-04-17,15.52,15.63,15.52,15.61,1866010,15.0773354909,15.1841980492,15.0773354909,15.164768493099999,1866010,0.0,1.0\nDBC,2017-04-18,15.46,15.4899,15.35,15.44,1462969,15.0190468228,15.048094009100002,14.9121842645,14.999617266700001,1462969,0.0,1.0\nDBC,2017-04-19,15.19,15.48,15.13,15.47,2094492,14.756747816199999,15.0384763788,14.698459148,15.0287616008,2094492,0.0,1.0\nDBC,2017-04-20,15.13,15.235,15.11,15.22,1847358,14.698459148,14.8004643173,14.679029592000001,14.7858921502,1847358,0.0,1.0\nDBC,2017-04-21,14.98,15.15,14.95,15.15,1585084,14.5527374777,14.717888704100002,14.5235931436,14.717888704100002,1585084,0.0,1.0\nDBC,2017-04-24,14.93,14.97,14.89,14.97,1767648,14.504163587599999,14.5430226997,14.4653044755,14.5430226997,1767648,0.0,1.0\nDBC,2017-04-25,15.0,15.01,14.855,14.89,1586409,14.5721670337,14.5818818118,14.4313027524,14.4653044755,1586409,0.0,1.0\nDBC,2017-04-26,14.92,15.046,14.9,14.94,5213778,14.4944488096,14.6168550126,14.4750192535,14.5138783656,5213778,0.0,1.0\nDBC,2017-04-27,14.84,14.84,14.71,14.8,919028,14.4167305854,14.4167305854,14.290438471099998,14.377871473299999,919028,0.0,1.0\nDBC,2017-04-28,14.8,14.905,14.78,14.87,2065889,14.377871473299999,14.4798766425,14.3584419172,14.445874919400001,2065889,0.0,1.0\nDBC,2017-05-01,14.83,14.89,14.8025,14.81,3598660,14.4070158074,14.4653044755,14.3803001678,14.387586251300002,3598660,0.0,1.0\nDBC,2017-05-02,14.67,14.8399,14.63,14.82,3161977,14.251579359,14.4166334376,14.2127202469,14.397301029300001,3161977,0.0,1.0\nDBC,2017-05-03,14.61,14.7,14.57,14.7,2146443,14.1932906909,14.280723693099999,14.1544315788,14.280723693099999,2146443,0.0,1.0\nDBC,2017-05-04,14.26,14.4727,14.24,14.46,3352873,13.8532734601,14.0599067886,13.833843904,14.0475690205,3352873,0.0,1.0\nDBC,2017-05-05,14.46,14.49,14.31,14.32,2436220,14.0475690205,14.0767133546,13.9018473502,13.9115621282,2436220,0.0,1.0\nDBC,2017-05-08,14.4,14.455,14.32,14.39,1583092,13.9892803524,14.042711631500001,13.9115621282,13.9795655744,1583092,0.0,1.0\nDBC,2017-05-09,14.33,14.4399,14.3065,14.43,1472964,13.9212769062,14.0280423167,13.8984471779,14.018424686500001,1472964,0.0,1.0\nDBC,2017-05-10,14.56,14.6,14.42,14.43,2232071,14.1447168007,14.1835759128,14.008709908399998,14.018424686500001,2232071,0.0,1.0\nDBC,2017-05-11,14.62,14.6693,14.5887,14.63,1312198,14.2030054689,14.2508993245,14.172598213699999,14.2127202469,1312198,0.0,1.0\nDBC,2017-05-12,14.62,14.66,14.58,14.66,788714,14.2030054689,14.241864581,14.1641463568,14.241864581,788714,0.0,1.0\nDBC,2017-05-15,14.7,14.83,14.69,14.83,893761,14.280723693099999,14.4070158074,14.271008915,14.4070158074,893761,0.0,1.0\nDBC,2017-05-16,14.72,14.79,14.72,14.77,755684,14.300153249100001,14.3681566953,14.300153249100001,14.348727139200001,755684,0.0,1.0\nDBC,2017-05-17,14.82,14.88,14.75,14.81,3215022,14.397301029300001,14.4555896975,14.329297583199999,14.387586251300002,3215022,0.0,1.0\nDBC,2017-05-18,14.78,14.85,14.6801,14.72,841738,14.3584419172,14.4264453634,14.2613912848,14.300153249100001,841738,0.0,1.0\nDBC,2017-05-19,15.07,15.07,14.95,14.96,736627,14.6401704799,14.6401704799,14.5235931436,14.5333079216,736627,0.0,1.0\nTLT,2017-01-03,119.64,119.99,118.18,118.41,13217317,110.63378819729999,110.9574410381,109.2836934901,109.4963796426,13217317,0.0,1.0\nTLT,2017-01-04,120.1,120.24,119.45,119.76,6699562,111.05916050229999,111.18862163860001,110.45809094090001,110.74475488549999,6699562,0.0,1.0\nTLT,2017-01-05,121.98,122.03,120.17,120.45,13265820,112.7976386184,112.8438747385,111.1238910704,111.3828133431,13265820,0.0,1.0\nTLT,2017-01-06,120.86,121.54,120.75,121.13,8369334,111.76194952799999,112.39076076139999,111.66023006370001,112.0116245765,8369334,0.0,1.0\nTLT,2017-01-09,121.83,122.0,121.46,121.88,8839108,112.6589302581,112.8161330664,112.31678296930001,112.70516637819999,8839108,0.0,1.0\nTLT,2017-01-10,121.75,121.91,121.28,121.55,8417941,112.5849524659,112.73290805020001,112.1503329369,112.4000079855,8417941,0.0,1.0\nTLT,2017-01-11,122.16,122.69,121.41,121.94,9402523,112.9640886508,113.4541915239,112.2705468492,112.7606497223,9402523,0.0,1.0\nTLT,2017-01-12,121.89,123.14,121.83,122.8,9977612,112.7144136022,113.8703166049,112.6589302581,113.5559109882,9977612,0.0,1.0\nTLT,2017-01-13,121.31,121.75,120.62,121.33,9820003,112.17807460889999,112.5849524659,111.5400161514,112.196569057,9820003,0.0,1.0\nTLT,2017-01-17,122.58,122.94,121.99,122.81,7853301,113.35247205969999,113.6853721245,112.80688584239999,113.56515821219999,7853301,0.0,1.0\nTLT,2017-01-18,121.01,121.96,120.91,121.77,9064689,111.9006578883,112.7791441704,111.8081856481,112.6034469139,9064689,0.0,1.0\nTLT,2017-01-19,120.18,120.58,119.55,120.49,11795591,111.13313829450001,111.50302725530001,110.5505631811,111.4198022391,11795591,0.0,1.0\nTLT,2017-01-20,119.94,120.31,119.29,119.84,16953217,110.91120491790001,111.2533522067,110.3101353565,110.8187326777,16953217,0.0,1.0\nTLT,2017-01-23,121.14,121.87,120.02,120.34,13118414,112.02087180059999,112.6959191542,110.9851827101,111.2810938788,13118414,0.0,1.0\nTLT,2017-01-24,120.31,121.13,119.8,120.77,8394978,111.2533522067,112.0116245765,110.7817437816,111.6787245118,8394978,0.0,1.0\nTLT,2017-01-25,118.8,119.6,118.56,119.28,11115069,109.85702137950001,110.59679930120001,109.6350880029,110.3008881325,11115069,0.0,1.0\nTLT,2017-01-26,119.2,119.26,118.33,118.91,7827932,110.22691034030001,110.28239368450001,109.42240185040001,109.95874084370001,7827932,0.0,1.0\nTLT,2017-01-27,119.63,119.83,119.25,119.4,7197762,110.6245409733,110.8094854537,110.27314646040001,110.4118548208,7197762,0.0,1.0\nTLT,2017-01-30,119.27,119.88,119.2,119.44,6569509,110.2916409085,110.8557215738,110.22691034030001,110.4488437169,6569509,0.0,1.0\nTLT,2017-01-31,120.1,120.4,119.25,119.33,13298521,111.05916050229999,111.3365772229,110.27314646040001,110.3471242526,13298521,0.0,1.0\nTLT,2017-02-01,119.1,119.53,118.58,119.1,10975521,110.3740882331,110.7725841017,109.8921862526,110.3740882331,10975521,0.25915900000000003,1.0\nTLT,2017-02-02,119.05,120.14,119.0,119.93,6978567,110.3277515042,111.3378921942,110.28141477530001,111.1432779328,6978567,0.0,1.0\nTLT,2017-02-03,119.0,119.87,118.47,119.44,10275574,110.28141477530001,111.08767385819999,109.790245449,110.6891779896,10275574,0.0,1.0\nTLT,2017-02-06,119.72,120.13,119.12,119.76,8433772,110.9486636715,111.3286248484,110.39262292469999,110.98573305459999,8433772,0.0,1.0\nTLT,2017-02-07,120.6,121.01,119.47,119.78,8418420,111.76419010010001,112.144151277,110.716980027,111.00426774610001,8418420,0.0,1.0\nTLT,2017-02-08,122.24,122.27,121.41,121.42,15734201,113.28403480790001,113.3118368452,112.51484510819999,112.52411245399999,15734201,0.0,1.0\nTLT,2017-02-09,120.83,121.59,120.66,121.41,16890586,111.977339053,112.68165733219999,111.8197941747,112.51484510819999,16890586,0.0,1.0\nTLT,2017-02-10,120.76,120.95,120.11,120.11,7978462,111.9124676325,112.08854720229999,111.3100901569,111.3100901569,7978462,0.0,1.0\nTLT,2017-02-13,120.38,120.41,119.88,120.25,11536780,111.5603084929,111.5881105302,111.09694120389999,111.43983299780001,11536780,0.0,1.0\nTLT,2017-02-14,119.51,120.3,118.83,120.24,13210451,110.75404941010001,111.4861697267,110.1238698971,111.43056565200001,13210451,0.0,1.0\nTLT,2017-02-15,118.96,119.22,118.65,118.76,8481015,110.24434539219999,110.4852963825,109.9570576731,110.0589984766,8481015,0.0,1.0\nTLT,2017-02-16,119.61,120.21,119.12,119.22,10005829,110.84672286790001,111.40276361469999,110.39262292469999,110.4852963825,10005829,0.0,1.0\nTLT,2017-02-17,120.32,120.7,120.13,120.61,7886448,111.50470441819999,111.85686355780001,111.3286248484,111.7734574458,7886448,0.0,1.0\nTLT,2017-02-21,120.11,120.61,119.6,119.68,8703755,111.3100901569,111.7734574458,110.83745552209999,110.9115942883,8703755,0.0,1.0\nTLT,2017-02-22,120.31,120.82,119.59,120.8,8101522,111.4954370724,111.9680717072,110.82818817629999,111.9495370156,8101522,0.0,1.0\nTLT,2017-02-23,120.67,120.74,120.33,120.57,5330784,111.82906152049999,111.893932941,111.51397176399999,111.7363880627,5330784,0.0,1.0\nTLT,2017-02-24,122.01,122.14,121.26,121.35,11335989,113.070885855,113.1913613501,112.3758349215,112.4592410335,11335989,0.0,1.0\nTLT,2017-02-27,121.29,121.9,121.23,121.81,11039831,112.40363695879999,112.9689450514,112.3480328842,112.8855389394,11039831,0.0,1.0\nTLT,2017-02-28,121.74,122.08,121.32,121.5,8410508,112.8206675189,113.13575727540001,112.43143899620001,112.5982512202,8410508,0.0,1.0\nTLT,2017-03-01,119.47,119.55,118.95,119.44,10717113,110.9337219299,111.0080058317,110.45087656780001,110.9058654667,10717113,0.233877,1.0\nTLT,2017-03-02,119.04,119.21,118.62,119.01,8201247,110.5344459574,110.6922992488,110.1444554727,110.5065894942,8201247,0.0,1.0\nTLT,2017-03-03,119.35,119.35,118.55,119.23,9515219,110.82229607709999,110.82229607709999,110.0794570586,110.71087022430001,9515219,0.0,1.0\nTLT,2017-03-06,118.78,119.12,118.52,119.12,4489229,110.29302327639999,110.6087298593,110.0516005954,110.6087298593,4489229,0.0,1.0\nTLT,2017-03-07,118.42,118.68,118.25,118.48,7080088,109.95874571799999,110.2001683991,109.8008924266,110.01445864440001,7080088,0.0,1.0\nTLT,2017-03-08,117.78,117.95,117.23,117.31,11336336,109.3644745032,109.52232779469999,108.853772678,108.9280565798,11336336,0.0,1.0\nTLT,2017-03-09,116.84,117.48,116.77,117.34,10575439,108.4916386564,109.08590987129999,108.4266402423,108.955913043,10575439,0.0,1.0\nTLT,2017-03-10,117.25,117.32,116.68,117.14,9218505,108.8723436534,108.9373420676,108.34307085270001,108.7702032884,9218505,0.0,1.0\nTLT,2017-03-13,116.51,117.09,116.49,116.83,6993071,108.18521756129999,108.72377584969999,108.1666465858,108.4823531687,6993071,0.0,1.0\nTLT,2017-03-14,117.07,117.35,116.71,116.77,9869230,108.70520487430001,108.9651985308,108.3709273159,108.4266402423,9869230,0.0,1.0\nTLT,2017-03-15,118.5,118.83,117.43,117.56,14600488,110.03302961989999,110.339450715,109.0394824326,109.16019377309999,14600488,0.0,1.0\nTLT,2017-03-16,117.9,118.15,117.63,117.99,8018165,109.475900356,109.7080375493,109.2251921872,109.5594697456,8018165,0.0,1.0\nTLT,2017-03-17,118.64,118.75,118.03,118.11,7266766,110.1630264481,110.2651668132,109.59661169649999,109.67089559840001,7266766,0.0,1.0\nTLT,2017-03-20,119.15,119.23,118.5,118.55,5497756,110.63658632239999,110.71087022430001,110.03302961989999,110.0794570586,5497756,0.0,1.0\nTLT,2017-03-21,120.14,120.3,119.02,119.05,12540662,111.55584960790002,111.7044174116,110.51587498190001,110.54373144510001,12540662,0.0,1.0\nTLT,2017-03-22,120.62,121.17,120.43,120.72,11661374,112.001553019,112.5122548442,111.82512875209999,112.0944078963,11661374,0.0,1.0\nTLT,2017-03-23,120.45,121.02,120.06,120.83,6746461,111.8436997276,112.3729725283,111.481565706,112.1965482614,6746461,0.0,1.0\nTLT,2017-03-24,120.88,121.1,120.4,120.53,5819630,112.2429757,112.4472564301,111.79727228889999,111.9179836294,5819630,0.0,1.0\nTLT,2017-03-27,121.43,121.97,121.19,121.83,6878473,112.7536775253,113.25509386280001,112.5308258197,113.1250970345,6878473,0.0,1.0\nTLT,2017-03-28,120.62,121.82,120.49,121.79,6811244,112.001553019,113.1158115468,111.8808416785,113.08795508360001,6811244,0.0,1.0\nTLT,2017-03-29,121.34,121.38,120.88,120.94,6291020,112.67010813569999,112.7072500866,112.2429757,112.2986886264,6291020,0.0,1.0\nTLT,2017-03-30,120.36,121.09,120.32,121.07,7042448,111.760130338,112.4379709424,111.722988387,112.4193999669,7042448,0.0,1.0\nTLT,2017-03-31,120.71,120.81,120.21,120.28,5144760,112.0851224086,112.1779772859,111.620848022,111.68584643610001,5144760,0.0,1.0\nTLT,2017-04-03,121.67,121.87,120.4,120.45,12996495,113.21289874940001,113.398997046,112.0311745659,112.07769914,12996495,0.254558,1.0\nTLT,2017-04-04,121.01,121.59,120.95,121.37,6976660,112.5987743706,113.1384594308,112.5429448816,112.9337513045,6976660,0.0,1.0\nTLT,2017-04-05,121.38,121.52,120.36,120.56,8555235,112.9430562193,113.07332502700001,111.9939549066,112.1800532032,8555235,0.0,1.0\nTLT,2017-04-06,121.2,121.41,120.52,121.24,6496995,112.7755677524,112.97097096379999,112.1428335438,112.8127874117,6496995,0.0,1.0\nTLT,2017-04-07,120.71,122.25,120.69,121.84,10264226,112.31962692559999,113.7525838096,112.30101709600001,113.3710823015,10264226,0.0,1.0\nTLT,2017-04-10,121.27,121.62,121.0,121.14,5314493,112.84070215620001,113.1663741753,112.5894694557,112.7197382634,5314493,0.0,1.0\nTLT,2017-04-11,122.42,122.65,121.72,121.8,11297362,113.9107673617,114.12478040290002,113.2594233236,113.33386264219999,11297362,0.0,1.0\nTLT,2017-04-12,123.09,123.19,122.29,122.51,11532463,114.53419665540001,114.6272458037,113.78980346889999,113.99451159520001,11532463,0.0,1.0\nTLT,2017-04-13,123.47,123.74,122.91,123.45,8396661,114.887783419,115.13901611940001,114.3667081885,114.8691735893,8396661,0.0,1.0\nTLT,2017-04-17,123.09,123.55,122.86,123.46,8015015,114.53419665540001,114.9622227377,114.3201836143,114.8784785042,8015015,0.0,1.0\nTLT,2017-04-18,124.7,124.98,123.55,123.86,11529704,116.0322879432,116.2928255585,114.9622227377,115.25067509739999,11529704,0.0,1.0\nTLT,2017-04-19,124.02,124.17,123.7,124.1,7343991,115.39955373469999,115.5391274572,115.1017964601,115.47399305340001,7343991,0.0,1.0\nTLT,2017-04-20,123.54,123.92,123.14,123.52,7932918,114.9529178228,115.3065045864,114.58072122959999,114.9343079932,7932918,0.0,1.0\nTLT,2017-04-21,123.54,124.24,123.49,123.82,9798520,114.9529178228,115.604261861,114.90639324870001,115.2134554381,9798520,0.0,1.0\nTLT,2017-04-24,122.93,123.16,122.46,122.55,8066371,114.3853180181,114.5993310592,113.9479870211,114.03173125459999,8066371,0.0,1.0\nTLT,2017-04-25,121.45,122.46,121.38,122.21,8122253,113.00819062309999,113.9479870211,112.9430562193,113.7153641503,8122253,0.0,1.0\nTLT,2017-04-26,122.12,122.14,121.43,121.52,5766213,113.6316199168,113.6502297465,112.98958079350001,113.07332502700001,5766213,0.0,1.0\nTLT,2017-04-27,122.08,122.35,121.58,121.75,4978328,113.5944002575,113.84563295790001,113.1291545159,113.2873380681,4978328,0.0,1.0\nTLT,2017-04-28,122.35,122.44,121.55,121.6,8166295,113.84563295790001,113.9293771914,113.10123977139999,113.1477643456,8166295,0.0,1.0\nTLT,2017-05-01,121.08,122.14,120.71,121.69,8795929,112.9010073092,113.8894039705,112.55600092739999,113.4698016143,8795929,0.25481,1.0\nTLT,2017-05-02,121.7,121.78,120.94,120.97,6727813,113.4791261111,113.5537220855,112.7704643539,112.7984378443,6727813,0.0,1.0\nTLT,2017-05-03,121.78,122.39,121.55,122.22,8932912,113.5537220855,114.1225163906,113.339258659,113.9639999449,8932912,0.0,1.0\nTLT,2017-05-04,121.18,121.22,120.67,121.01,9707211,112.99425227719999,113.03155026450001,112.5187029402,112.8357358316,9707211,0.0,1.0\nTLT,2017-05-05,121.29,121.47,120.89,121.38,5605412,113.09682174209999,113.2646626846,112.7238418699,113.1807422133,5605412,0.0,1.0\nTLT,2017-05-08,120.63,121.14,120.53,121.11,8057780,112.481404953,112.95695429,112.3881599849,112.9289807996,8057780,0.0,1.0\nTLT,2017-05-09,120.62,120.63,120.14,120.38,5536746,112.4720804562,112.481404953,112.02450460959999,112.24829253290001,5536746,0.0,1.0\nTLT,2017-05-10,120.48,121.06,120.16,120.91,6517131,112.3415375009,112.8823583156,112.0431536032,112.74249086350001,6517131,0.0,1.0\nTLT,2017-05-11,120.48,120.57,119.91,120.0,7479834,112.3415375009,112.4254579722,111.8100411831,111.8939616543,7479834,0.0,1.0\nTLT,2017-05-12,121.39,121.49,120.98,121.01,7464184,113.1900667101,113.2833116782,112.8077623412,112.8357358316,7464184,0.0,1.0\nTLT,2017-05-15,121.06,121.21,120.75,121.06,5173188,112.8823583156,113.02222576770001,112.59329891459998,112.8823583156,5173188,0.0,1.0\nTLT,2017-05-16,121.51,121.89,121.11,121.11,6722728,113.3019606718,113.65629155040001,112.9289807996,112.9289807996,6722728,0.0,1.0\nTLT,2017-05-17,123.28,123.54,122.39,122.63,11236435,114.95239660620001,115.1948335231,114.1225163906,114.34630431389999,11236435,0.0,1.0\nTLT,2017-05-18,123.42,123.94,123.19,123.75,8350484,115.0829395615,115.5678133953,114.868476135,115.39064795600001,8350484,0.0,1.0\nTLT,2017-05-19,123.71,123.78,123.01,123.36,11277436,115.35334996879999,115.4186214464,114.7006351925,115.0269925806,11277436,0.0,1.0\nIEF,2017-01-03,104.77,104.8856,104.29,104.35,3230548,98.21476239399999,98.32312954620001,97.76479498020001,97.82104090690001,3230548,0.0,1.0\nIEF,2017-01-04,104.89,104.935,104.615,104.73,1522962,98.3272542475,98.3694386925,98.0694604166,98.17726510959999,1522962,0.0,1.0\nIEF,2017-01-05,105.57,105.68,105.03,105.1,3344376,98.96470808379999,99.06782561610001,98.45849474319999,98.5241149911,3344376,0.0,1.0\nIEF,2017-01-06,105.09,105.375,105.0441,105.18,1601433,98.5147406699,98.7819088219,98.471712536,98.59910956,1601433,0.0,1.0\nIEF,2017-01-09,105.49,105.53,105.355,105.47,1275042,98.8897135148,98.9272107993,98.7631601797,98.8709648726,1275042,0.0,1.0\nIEF,2017-01-10,105.44,105.6,105.385,105.44,1352734,98.8428419092,98.9928310472,98.79128314299999,98.8428419092,1352734,0.0,1.0\nIEF,2017-01-11,105.56,105.93,105.31,105.5,1833735,98.95533376270001,99.3021836442,98.72097573459999,98.8990878359,1833735,0.0,1.0\nIEF,2017-01-12,105.62,106.025,105.59,105.82,1544660,99.01157968940001,99.39123969479999,98.983456726,99.19906611180001,1544660,0.0,1.0\nIEF,2017-01-13,105.39,105.5576,105.05,105.37,1833203,98.7959703036,98.9530839256,98.47724338549999,98.77722166139999,1833203,0.0,1.0\nIEF,2017-01-17,105.92,106.0301,105.7343,105.97,1656782,99.29280932309999,99.3960205986,99.1187281798,99.3396809287,1656782,0.0,1.0\nIEF,2017-01-18,105.17,105.69,105.13,105.6,3640392,98.58973523889999,99.0771999373,98.5522379544,98.9928310472,3640392,0.0,1.0\nIEF,2017-01-19,104.77,104.885,104.5644,104.83,1839952,98.21476239399999,98.32256708690001,98.02202635180001,98.2710083208,1839952,0.0,1.0\nIEF,2017-01-20,104.82,104.9159,104.48,104.64,1306421,98.2616339996,98.35153373920001,97.9429070815,98.0928962195,1306421,0.0,1.0\nIEF,2017-01-23,105.37,105.57,104.84,105.01,1720212,98.77722166139999,98.96470808379999,98.2803826419,98.439746101,1720212,0.0,1.0\nIEF,2017-01-24,104.97,105.28,104.825,105.14,1343970,98.40224881649999,98.6928527713,98.26632116020001,98.5616122755,1343970,0.0,1.0\nIEF,2017-01-25,104.45,104.675,104.31,104.55,1815965,97.91478411809999,98.1257063434,97.7835436224,98.00852732940001,1815965,0.0,1.0\nIEF,2017-01-26,104.59,104.62,104.18,104.41,2212910,98.04602461379999,98.0741475772,97.6616774478,97.87728683370001,2212910,0.0,1.0\nIEF,2017-01-27,104.72,104.8,104.64,104.65,2285159,98.1678907884,98.2428853574,98.0928962195,98.10227054059999,2285159,0.0,1.0\nIEF,2017-01-30,104.69,104.945,104.69,104.74,1909335,98.13976782510001,98.37881301370001,98.13976782510001,98.1866394307,1909335,0.0,1.0\nIEF,2017-01-31,105.05,105.22,104.79,104.79,1411700,98.47724338549999,98.63660684450001,98.23351103629999,98.23351103629999,1411700,0.0,1.0\nIEF,2017-02-01,104.69,104.85,104.385,104.56,2055086,98.2890838868,98.4393012277,98.0027320806,98.1670322973,2055086,0.159282,1.0\nIEF,2017-02-02,104.73,105.1,104.72,105.03,1423492,98.32663822200001,98.6740158229,98.3172496382,98.6082957362,1423492,0.0,1.0\nIEF,2017-02-03,104.81,105.22,104.62,105.0,1711147,98.4017468925,98.7866788286,98.2233638001,98.5801299848,1711147,0.0,1.0\nIEF,2017-02-06,105.31,105.4062,104.9961,105.24,1500484,98.8711760829,98.9614942591,98.57646843709999,98.80545599620001,1500484,0.0,1.0\nIEF,2017-02-07,105.5,105.69,105.18,105.25,1540579,99.0495591752,99.2279422676,98.7491244934,98.81484458,1540579,0.0,1.0\nIEF,2017-02-08,105.93,106.0,105.66,105.74,3182186,99.453268279,99.5189883656,99.1997765161,99.2748851866,3182186,0.0,1.0\nIEF,2017-02-09,105.3,105.73,105.28,105.71,1530153,98.861787499,99.26549660280001,98.8430103314,99.2467194352,1530153,0.0,1.0\nIEF,2017-02-10,105.23,105.31,105.07,105.07,1646365,98.79606741239999,98.8711760829,98.64585007149999,98.64585007149999,1646365,0.0,1.0\nIEF,2017-02-13,105.07,105.08,104.88,105.0,1045207,98.64585007149999,98.65523865530001,98.4674669791,98.5801299848,1045207,0.0,1.0\nIEF,2017-02-14,104.69,105.1,104.4615,105.02,2318128,98.2890838868,98.6740158229,98.0745547467,98.5989071524,2318128,0.0,1.0\nIEF,2017-02-15,104.49,104.56,104.32,104.33,2750913,98.1013122106,98.1670322973,97.9417062859,97.95109486969999,2750913,0.0,1.0\nIEF,2017-02-16,104.91,105.01,104.6,104.6,2459817,98.49563273049999,98.5895185686,98.2045866325,98.2045866325,2459817,0.0,1.0\nIEF,2017-02-17,105.19,105.315,105.12,105.2,2237724,98.75851307719999,98.8758703748,98.6927929905,98.76790166100001,2237724,0.0,1.0\nIEF,2017-02-21,105.14,105.2799,104.92,104.94,2832683,98.71157015809999,98.8429164456,98.50502131430001,98.523798482,2832683,0.0,1.0\nIEF,2017-02-22,105.31,105.42,104.96,105.36,2812755,98.8711760829,98.97445050469999,98.5425756496,98.91811900190001,2812755,0.0,1.0\nIEF,2017-02-23,105.59,105.59,105.4334,105.5,1842686,99.1340564295,99.1340564295,98.987031207,99.0495591752,1842686,0.0,1.0\nIEF,2017-02-24,106.08,106.16,105.82,105.94,2676989,99.59409703610001,99.6692057065,99.34999385709999,99.4626568628,2676989,0.0,1.0\nIEF,2017-02-27,105.7,106.0,105.64,105.99,3292633,99.23733085139999,99.5189883656,99.18099934850001,99.5095997818,3292633,0.0,1.0\nIEF,2017-02-28,105.65,105.91,105.63,105.75,2482544,99.1903879323,99.43449111129999,99.17161076469999,99.2842737704,2482544,0.0,1.0\nIEF,2017-03-01,104.77,104.795,104.65,104.71,2249019,98.5001711719,98.5236750784,98.387352421,98.4437617965,2249019,0.144834,1.0\nIEF,2017-03-02,104.47,104.6,104.3316,104.56,2023327,98.2181242945,98.3403446081,98.0880066684,98.30273835770001,2023327,0.0,1.0\nIEF,2017-03-03,104.57,104.58,104.26,104.5,2509000,98.31213992030001,98.3215414829,98.0206914803,98.2463289822,2509000,0.0,1.0\nIEF,2017-03-06,104.55,104.6,104.4349,104.59,1401721,98.2933367951,98.3403446081,98.1851248098,98.33094304549999,1401721,0.0,1.0\nIEF,2017-03-07,104.4,104.48,104.3533,104.43,1596283,98.1523133564,98.22752585709999,98.1084080592,98.1805180442,1596283,0.0,1.0\nIEF,2017-03-08,104.05,104.15,103.9,103.92,2392776,97.823258666,97.9172742919,97.6822352273,97.7010383525,2392776,0.0,1.0\nIEF,2017-03-09,103.76,103.975,103.7,103.94,2322915,97.55061335120001,97.7527469467,97.4942039757,97.7198414776,2322915,0.0,1.0\nIEF,2017-03-10,103.98,104.01,103.7537,103.98,1332989,97.75744772799999,97.7856524157,97.5446903667,97.75744772799999,1332989,0.0,1.0\nIEF,2017-03-13,103.72,103.92,103.692,103.81,1342568,97.5130071008,97.7010383525,97.48668272559999,97.5976211641,1342568,0.0,1.0\nIEF,2017-03-14,103.83,103.9386,103.75,103.77,864637,97.6164242892,97.71852525889999,97.54121178860001,97.5600149137,864637,0.0,1.0\nIEF,2017-03-15,104.74,104.74,103.92,103.99,3701856,98.4719664842,98.4719664842,97.7010383525,97.76684929049999,3701856,0.0,1.0\nIEF,2017-03-16,104.45,104.62,104.42,104.49,2749919,98.1993211693,98.3591477332,98.1711164816,98.2369274197,2749919,0.0,1.0\nIEF,2017-03-17,104.73,104.82,104.56,104.58,1186685,98.46256492159999,98.5471789849,98.30273835770001,98.3215414829,1186685,0.0,1.0\nIEF,2017-03-20,104.9877,105.01,104.75,104.75,1362724,98.70484318940001,98.72580867389999,98.4813680468,98.4813680468,1362724,0.0,1.0\nIEF,2017-03-21,105.33,105.368,104.91,104.91,1406989,99.02665867649999,99.0623846144,98.6317930481,98.6317930481,1406989,0.0,1.0\nIEF,2017-03-22,105.53,105.73,105.445,105.53,2231348,99.2146899282,99.4027211798,99.13477664620001,99.2146899282,2231348,0.0,1.0\nIEF,2017-03-23,105.42,105.64,105.239,105.59,2584684,99.1112727398,99.3181071166,98.94110445700001,99.27109930370001,2584684,0.0,1.0\nIEF,2017-03-24,105.48,105.61,105.35,105.39,2540044,99.16768211530001,99.28990242879999,99.0454618017,99.083068052,2540044,0.0,1.0\nIEF,2017-03-27,105.74,105.96,105.67,105.92,2614005,99.4121227424,99.6189571192,99.3463118043,99.58135086889999,2614005,0.0,1.0\nIEF,2017-03-28,105.38,105.86,105.3735,105.86,1356809,99.07366648950001,99.52494149340001,99.06755547379998,99.52494149340001,1356809,0.0,1.0\nIEF,2017-03-29,105.68,105.72,105.56,105.56,1860381,99.3557133669,99.39331961719999,99.2428946159,99.2428946159,1860381,0.0,1.0\nIEF,2017-03-30,105.38,105.64,105.36,105.63,1487784,99.07366648950001,99.3181071166,99.0548633643,99.308705554,1487784,0.0,1.0\nIEF,2017-03-31,105.59,105.63,105.45,105.53,1698488,99.27109930370001,99.308705554,99.1394774275,99.2146899282,1698488,0.0,1.0\nIEF,2017-04-03,105.95,106.0,105.53,105.56,4175518,99.7587545942,99.8058328172,99.3632975208,99.3915444546,4175518,0.158696,1.0\nIEF,2017-04-04,105.8,106.015,105.8,105.92,1319595,99.6175199251,99.8199562841,99.6175199251,99.73050766040001,1319595,0.0,1.0\nIEF,2017-04-05,106.0,106.1022,105.6201,105.67,2551140,99.8058328172,99.9020607051,99.4481324786,99.4951165452,2551140,0.0,1.0\nIEF,2017-04-06,105.93,106.1,105.7967,106.03,1496344,99.739923305,99.8999892633,99.6144127624,99.834079751,1496344,0.0,1.0\nIEF,2017-04-07,105.61,106.315,105.61,106.19,1185411,99.43862267760001,100.10242562229999,99.43862267760001,99.98473006469999,1185411,0.0,1.0\nIEF,2017-04-10,105.8,105.9184,105.7,105.75,1752459,99.6175199251,99.72900115719999,99.523363479,99.57044170209998,1752459,0.0,1.0\nIEF,2017-04-11,106.29,106.395,106.03,106.05,2219221,100.0788865108,100.1777507791,99.834079751,99.8529110402,2219221,0.0,1.0\nIEF,2017-04-12,106.62,106.68,106.29,106.37,2273640,100.38960278270001,100.44609665040001,100.0788865108,100.15421166760001,2273640,0.0,1.0\nIEF,2017-04-13,106.94,107.01,106.61,106.8,1868187,100.6909034101,100.7568129224,100.3801871381,100.55908438559999,1868187,0.0,1.0\nIEF,2017-04-17,106.77,107.04,106.675,106.95,1110547,100.5308374518,100.78505985620001,100.44138882809999,100.70031905469999,1110547,0.0,1.0\nIEF,2017-04-18,107.38,107.47,107.0,107.06,1678639,101.10519177280001,101.1899325742,100.7473972777,100.80389114540002,1678639,0.0,1.0\nIEF,2017-04-19,107.1,107.17,106.985,107.12,1749970,100.84155372379999,100.907463236,100.7332738108,100.860385013,1749970,0.0,1.0\nIEF,2017-04-20,106.86,107.01,106.72,106.93,1695070,100.6155782533,100.7568129224,100.4837592288,100.6814877655,1695070,0.0,1.0\nIEF,2017-04-21,106.91,107.12,106.8757,106.96,2792479,100.66265647629999,100.860385013,100.63036081530001,100.7097346993,2792479,0.0,1.0\nIEF,2017-04-24,106.69,106.69,106.37,106.43,3557972,100.455512295,100.455512295,100.15421166760001,100.2107055352,3557972,0.0,1.0\nIEF,2017-04-25,106.14,106.465,106.09,106.35,1985745,99.9376518417,100.24366029139999,99.8905736187,100.1353803784,1985745,0.0,1.0\nIEF,2017-04-26,106.42,106.42,106.12,106.14,1314388,100.2012898906,100.2012898906,99.9188205525,99.9376518417,1314388,0.0,1.0\nIEF,2017-04-27,106.49,106.62,106.33,106.38,1988199,100.2671994029,100.38960278270001,100.1165490892,100.16362731219999,1988199,0.0,1.0\nIEF,2017-04-28,106.6,106.65,106.27,106.32,5490008,100.3707714935,100.4178497166,100.0600552216,100.1071334446,5490008,0.0,1.0\nIEF,2017-05-01,106.21,106.4874,106.05,106.32,2044444,100.15448377969999,100.41606794129999,100.003606109,100.2582121783,2044444,0.160289,1.0\nIEF,2017-05-02,106.43,106.4885,106.14,106.16,1585337,100.36194057690001,100.4171052253,100.0884747988,100.1073345076,1585337,0.0,1.0\nIEF,2017-05-03,106.21,106.49,106.145,106.45,1861314,100.15448377969999,100.41851970350001,100.093189726,100.38080028579999,1861314,0.0,1.0\nIEF,2017-05-04,105.97,105.99,105.82,105.85,1237107,99.92816727370001,99.9470269825,99.7867194574,99.8150090207,1237107,0.0,1.0\nIEF,2017-05-05,105.99,106.02,105.8,105.98,980046,99.9470269825,99.9753165458,99.7678597486,99.9375971281,980046,0.0,1.0\nIEF,2017-05-08,105.73,105.89,105.67,105.89,4887359,99.7018507676,99.8527284383,99.64527164110001,99.8527284383,4887359,0.0,1.0\nIEF,2017-05-09,105.61,105.66,105.4701,105.59,1457894,99.58869251459998,99.6358417867,99.4567688513,99.5698328058,1457894,0.0,1.0\nIEF,2017-05-10,105.57,105.859,105.5,105.84,995492,99.55097309690001,99.8234958896,99.484964116,99.8055791662,995492,0.0,1.0\nIEF,2017-05-11,105.65,105.7,105.41,105.41,1088840,99.6264119323,99.6735612044,99.40009542620001,99.40009542620001,1088840,0.0,1.0\nIEF,2017-05-12,106.19,106.2776,106.06,106.06,2005629,100.13562407090001,100.2182295956,100.0130359635,100.0130359635,2005629,0.0,1.0\nIEF,2017-05-15,106.12,106.17,106.05,106.14,672599,100.06961509,100.11676436209999,100.003606109,100.0884747988,672599,0.0,1.0\nIEF,2017-05-16,106.26,106.41,106.11,106.11,1314453,100.2016330518,100.34308086809999,100.06018523549999,100.06018523549999,1314453,0.0,1.0\nIEF,2017-05-17,107.11,107.17,106.73,106.8,5644708,101.00317067739999,101.05974980389999,100.64483620950001,100.71084519040001,5644708,0.0,1.0\nIEF,2017-05-18,107.08,107.25,106.97,107.2,1715184,100.9748811142,101.1351886393,100.87115271549999,101.0880393672,1715184,0.0,1.0\nIEF,2017-05-19,107.05,107.065,106.8,106.97,3503125,100.9465915509,100.9607363325,100.71084519040001,100.87115271549999,3503125,0.0,1.0\nGLD,2017-01-03,110.47,111.0,109.37,109.62,7527353,110.47,111.0,109.37,109.62,7527353,0.0,1.0\nGLD,2017-01-04,110.86,111.22,110.61,111.06,4904119,110.86,111.22,110.61,111.06,4904119,0.0,1.0\nGLD,2017-01-05,112.58,112.94,112.07,112.16,9606761,112.58,112.94,112.07,112.16,9606761,0.0,1.0\nGLD,2017-01-06,111.75,112.38,111.57,111.81,7686070,111.75,112.38,111.57,111.81,7686070,0.0,1.0\nGLD,2017-01-09,112.67,113.0399,112.18,112.39,5674636,112.67,113.0399,112.18,112.39,5674636,0.0,1.0\nGLD,2017-01-10,113.15,113.45,112.635,112.94,6093404,113.15,113.45,112.635,112.94,6093404,0.0,1.0\nGLD,2017-01-11,113.5,114.19,112.17,112.87,9918689,113.5,114.19,112.17,112.87,9918689,0.0,1.0\nGLD,2017-01-12,113.91,114.93,113.805,114.52,8457319,113.91,114.93,113.805,114.52,8457319,0.0,1.0\nGLD,2017-01-13,114.21,114.31,113.19,113.65,7261454,114.21,114.31,113.19,113.65,7261454,0.0,1.0\nGLD,2017-01-17,115.85,115.96,115.495,115.89,9142370,115.85,115.96,115.495,115.89,9142370,0.0,1.0\nGLD,2017-01-18,114.87,115.92,114.56,115.74,6498109,114.87,115.92,114.56,115.74,6498109,0.0,1.0\nGLD,2017-01-19,114.77,114.96,113.935,114.32,6437008,114.77,114.96,113.935,114.32,6437008,0.0,1.0\nGLD,2017-01-20,115.05,115.76,114.32,114.65,12261510,115.05,115.76,114.32,114.65,12261510,0.0,1.0\nGLD,2017-01-23,115.79,116.17,115.2,115.51,6373102,115.79,116.17,115.2,115.51,6373102,0.0,1.0\nGLD,2017-01-24,115.27,116.02,114.94,115.69,5798840,115.27,116.02,114.94,115.69,5798840,0.0,1.0\nGLD,2017-01-25,114.32,114.44,113.68,114.14,6697523,114.32,114.44,113.68,114.14,6697523,0.0,1.0\nGLD,2017-01-26,113.26,113.505,112.83,113.24,5390463,113.26,113.505,112.83,113.24,5390463,0.0,1.0\nGLD,2017-01-27,113.49,113.545,112.81,112.93,6706135,113.49,113.545,112.81,112.93,6706135,0.0,1.0\nGLD,2017-01-30,113.97,114.3,113.535,113.61,7687782,113.97,114.3,113.535,113.61,7687782,0.0,1.0\nGLD,2017-01-31,115.55,115.8173,115.18,115.27,10421882,115.55,115.8173,115.18,115.27,10421882,0.0,1.0\nGLD,2017-02-01,115.2,115.45,114.14,114.66,6932889,115.2,115.45,114.14,114.66,6932889,0.0,1.0\nGLD,2017-02-02,115.84,116.57,115.625,116.26,7189461,115.84,116.57,115.625,116.26,7189461,0.0,1.0\nGLD,2017-02-03,116.13,116.365,115.71,115.73,9455464,116.13,116.365,115.71,115.73,9455464,0.0,1.0\nGLD,2017-02-06,117.7,117.74,116.74,117.07,8547843,117.7,117.74,116.74,117.07,8547843,0.0,1.0\nGLD,2017-02-07,117.46,117.74,117.2,117.31,8621039,117.46,117.74,117.2,117.31,8621039,0.0,1.0\nGLD,2017-02-08,118.19,118.59,117.81,118.09,9633443,118.19,118.59,117.81,118.09,9633443,0.0,1.0\nGLD,2017-02-09,117.29,118.58,117.2,118.32,8177210,117.29,118.58,117.2,118.32,8177210,0.0,1.0\nGLD,2017-02-10,117.6,117.86,116.67,116.68,9072146,117.6,117.86,116.67,116.68,9072146,0.0,1.0\nGLD,2017-02-13,116.8,116.95,116.15,116.73,7354924,116.8,116.95,116.15,116.73,7354924,0.0,1.0\nGLD,2017-02-14,116.93,117.56,116.38,117.51,6927842,116.93,117.56,116.38,117.51,6927842,0.0,1.0\nGLD,2017-02-15,117.45,117.48,116.2466,116.33,7088419,117.45,117.48,116.2466,116.33,7088419,0.0,1.0\nGLD,2017-02-16,118.08,118.35,117.83,117.93,6378650,118.08,118.35,117.83,117.93,6378650,0.0,1.0\nGLD,2017-02-17,117.68,118.4,117.6201,118.19,7338065,117.68,118.4,117.6201,118.19,7338065,0.0,1.0\nGLD,2017-02-21,117.75,118.0,116.77,117.04,6166875,117.75,118.0,116.77,117.04,6166875,0.0,1.0\nGLD,2017-02-22,117.91,118.02,117.24,117.86,6057179,117.91,118.02,117.24,117.86,6057179,0.0,1.0\nGLD,2017-02-23,118.94,119.155,118.675,118.76,7523304,118.94,119.155,118.675,118.76,7523304,0.0,1.0\nGLD,2017-02-24,119.7,119.88,119.25,119.74,9808957,119.7,119.88,119.25,119.74,9808957,0.0,1.0\nGLD,2017-02-27,119.12,120.4,119.12,119.73,9263499,119.12,120.4,119.12,119.73,9263499,0.0,1.0\nGLD,2017-02-28,119.23,119.84,118.82,119.71,8727832,119.23,119.84,118.82,119.71,8727832,0.0,1.0\nGLD,2017-03-01,119.06,119.12,117.95,117.98,8679637,119.06,119.12,117.95,117.98,8679637,0.0,1.0\nGLD,2017-03-02,117.58,118.34,117.225,117.76,10986262,117.58,118.34,117.225,117.76,10986262,0.0,1.0\nGLD,2017-03-03,117.51,117.73,116.44,116.95,10665818,117.51,117.73,116.44,116.95,10665818,0.0,1.0\nGLD,2017-03-06,116.72,117.35,116.63,117.35,4883547,116.72,117.35,116.63,117.35,4883547,0.0,1.0\nGLD,2017-03-07,115.78,116.25,115.615,116.13,6846277,115.78,116.25,115.615,116.13,6846277,0.0,1.0\nGLD,2017-03-08,115.06,115.36,114.945,114.99,7620924,115.06,115.36,114.945,114.99,7620924,0.0,1.0\nGLD,2017-03-09,114.47,115.025,114.405,114.78,6410916,114.47,115.025,114.405,114.78,6410916,0.0,1.0\nGLD,2017-03-10,114.72,114.73,114.13,114.45,7929500,114.72,114.73,114.13,114.45,7929500,0.0,1.0\nGLD,2017-03-13,114.74,114.91,114.505,114.63,5807391,114.74,114.91,114.505,114.63,5807391,0.0,1.0\nGLD,2017-03-14,114.12,115.01,114.025,114.54,5301810,114.12,115.01,114.025,114.54,5301810,0.0,1.0\nGLD,2017-03-15,116.25,116.25,114.02,114.29,13524082,116.25,116.25,114.02,114.29,13524082,0.0,1.0\nGLD,2017-03-16,116.73,117.29,116.69,117.27,9322018,116.73,117.29,116.69,117.27,9322018,0.0,1.0\nGLD,2017-03-17,116.99,117.27,116.91,117.04,4683039,116.99,117.27,116.91,117.04,4683039,0.0,1.0\nGLD,2017-03-20,117.5645,117.595,117.205,117.31,4000202,117.5645,117.595,117.205,117.31,4000202,0.0,1.0\nGLD,2017-03-21,118.54,118.8,117.76,117.77,9567932,118.54,118.8,117.76,117.77,9567932,0.0,1.0\nGLD,2017-03-22,118.83,119.15,118.68,118.87,7287350,118.83,119.15,118.68,118.87,7287350,0.0,1.0\nGLD,2017-03-23,118.67,119.25,118.32,119.15,6201129,118.67,119.25,118.32,119.15,6201129,0.0,1.0\nGLD,2017-03-24,118.86,119.21,118.39,118.5,6879634,118.86,119.21,118.39,118.5,6879634,0.0,1.0\nGLD,2017-03-27,119.53,120.08,119.27,119.93,8519573,119.53,120.08,119.27,119.93,8519573,0.0,1.0\nGLD,2017-03-28,119.04,119.83,118.78,119.74,7112787,119.04,119.83,118.78,119.74,7112787,0.0,1.0\nGLD,2017-03-29,119.33,119.45,119.047,119.22,4911042,119.33,119.45,119.047,119.22,4911042,0.0,1.0\nGLD,2017-03-30,118.47,119.12,118.32,118.77,6894572,118.47,119.12,118.32,118.77,6894572,0.0,1.0\nGLD,2017-03-31,118.72,119.08,118.46,118.61,8520847,118.72,119.08,118.46,118.61,8520847,0.0,1.0\nGLD,2017-04-03,119.35,119.37,118.67,118.69,6105704,119.35,119.37,118.67,118.69,6105704,0.0,1.0\nGLD,2017-04-04,119.62,119.77,119.38,119.59,4687301,119.62,119.77,119.38,119.59,4687301,0.0,1.0\nGLD,2017-04-05,119.62,119.63,118.4,118.62,8105952,119.62,119.63,118.4,118.62,8105952,0.0,1.0\nGLD,2017-04-06,119.18,119.42,118.975,119.22,4633470,119.18,119.42,118.975,119.22,4633470,0.0,1.0\nGLD,2017-04-07,119.46,120.67,119.14,120.3,12350093,119.46,120.67,119.14,120.3,12350093,0.0,1.0\nGLD,2017-04-10,119.46,119.69,118.85,119.04,4623673,119.46,119.69,118.85,119.04,4623673,0.0,1.0\nGLD,2017-04-11,121.19,121.4,120.29,120.33,12455294,121.19,121.4,120.29,120.33,12455294,0.0,1.0\nGLD,2017-04-12,122.02,122.22,121.14,121.38,9502540,122.02,122.22,121.14,121.38,9502540,0.0,1.0\nGLD,2017-04-13,122.6,122.65,122.03,122.54,9892641,122.6,122.65,122.03,122.54,9892641,0.0,1.0\nGLD,2017-04-17,122.24,123.07,121.99,122.55,8469580,122.24,123.07,121.99,122.55,8469580,0.0,1.0\nGLD,2017-04-18,122.82,123.0292,121.73,122.43,10903701,122.82,123.0292,121.73,122.43,10903701,0.0,1.0\nGLD,2017-04-19,121.73,122.26,121.28,122.25,8472702,121.73,122.26,121.28,122.25,8472702,0.0,1.0\nGLD,2017-04-20,121.96,122.17,121.5,121.82,11787330,121.96,122.17,121.5,121.82,11787330,0.0,1.0\nGLD,2017-04-21,122.31,122.61,121.73,122.14,15608031,122.31,122.61,121.73,122.14,15608031,0.0,1.0\nGLD,2017-04-24,121.48,121.51,120.6601,120.77,11053559,121.48,121.51,120.6601,120.77,11053559,0.0,1.0\nGLD,2017-04-25,120.25,120.87,120.05,120.53,10344784,120.25,120.87,120.05,120.53,10344784,0.0,1.0\nGLD,2017-04-26,120.84,120.96,119.87,120.21,9758844,120.84,120.96,119.87,120.21,9758844,0.0,1.0\nGLD,2017-04-27,120.39,120.5975,119.97,120.42,8266954,120.39,120.5975,119.97,120.42,8266954,0.0,1.0\nGLD,2017-04-28,120.77,120.77,120.27,120.34,9134246,120.77,120.77,120.27,120.34,9134246,0.0,1.0\nGLD,2017-05-01,119.67,120.75,119.35,120.21,9331809,119.67,120.75,119.35,120.21,9331809,0.0,1.0\nGLD,2017-05-02,119.65,119.65,119.24,119.3,4900784,119.65,119.65,119.24,119.3,4900784,0.0,1.0\nGLD,2017-05-03,117.98,119.34,117.92,119.18,10811052,117.98,119.34,117.92,119.18,10811052,0.0,1.0\nGLD,2017-05-04,116.79,117.28,116.63,116.81,12405323,116.79,117.28,116.63,116.81,12405323,0.0,1.0\nGLD,2017-05-05,117.01,117.07,116.68,116.86,7127199,117.01,117.07,116.68,116.86,7127199,0.0,1.0\nGLD,2017-05-08,116.75,117.14,116.68,117.03,5438375,116.75,117.14,116.68,117.03,5438375,0.0,1.0\nGLD,2017-05-09,116.05,116.24,115.56,116.18,6801280,116.05,116.24,115.56,116.18,6801280,0.0,1.0\nGLD,2017-05-10,116.04,116.4999,115.86,116.42,4290459,116.04,116.4999,115.86,116.42,4290459,0.0,1.0\nGLD,2017-05-11,116.5,116.845,116.185,116.21,6274368,116.5,116.845,116.185,116.21,6274368,0.0,1.0\nGLD,2017-05-12,116.83,117.16,116.73,117.07,5434477,116.83,117.16,116.73,117.07,5434477,0.0,1.0\nGLD,2017-05-15,117.14,117.54,116.99,117.52,5019589,117.14,117.54,116.99,117.52,5019589,0.0,1.0\nGLD,2017-05-16,117.65,117.91,117.4,117.45,4942822,117.65,117.91,117.4,117.45,4942822,0.0,1.0\nGLD,2017-05-17,119.79,120.02,119.27,119.36,13936876,119.79,120.02,119.27,119.36,13936876,0.0,1.0\nGLD,2017-05-18,118.81,119.84,118.56,119.77,10341141,118.81,119.84,118.56,119.77,10341141,0.0,1.0\nGLD,2017-05-19,119.4,119.545,118.88,119.39,6787254,119.4,119.545,118.88,119.39,6787254,0.0,1.0\n"
  },
  {
    "path": "tests/test_data/options_data.csv",
    "content": ",underlying,underlying_last,optionroot,type,expiration,quotedate,strike,last,bid,ask,volume,openinterest,impliedvol,delta,gamma,theta,vega,optionalias,dte\n5857589,SPX,1989.63,SPX160617C00650000,call,2016-06-17,2014-12-15,650.0,0.0,0.0,0.0,0,0,0.2352,0.9795,0.0,25.1577,0.3713,SPX160617C00650000,550\n5857590,SPX,1989.63,SPX160617C00700000,call,2016-06-17,2014-12-15,700.0,0.0,0.0,0.0,0,0,0.2352,0.9794,0.0,24.9852,0.9941,SPX160617C00700000,550\n5857662,SPX,1989.63,SPX160617P00650000,put,2016-06-17,2014-12-15,650.0,1.9,1.85,3.9,0,63,0.3525,-0.0028,0.0,-2.5109,20.9218,SPX160617P00650000,550\n5857663,SPX,1989.63,SPX160617P00700000,put,2016-06-17,2014-12-15,700.0,3.2,1.95,4.7,0,1,0.3525,-0.0047,0.0,-3.9784,33.1076,SPX160617P00700000,550\n5863595,SPX,1972.75,SPX160617C00650000,call,2016-06-17,2014-12-16,650.0,0.0,0.0,0.0,0,0,0.2407,0.9795,0.0,24.8969,0.564,SPX160617C00650000,549\n5863596,SPX,1972.75,SPX160617C00700000,call,2016-06-17,2014-12-16,700.0,0.0,0.0,0.0,0,0,0.2407,0.9794,0.0,24.7023,1.4386,SPX160617C00700000,549\n5863668,SPX,1972.75,SPX160617P00650000,put,2016-06-17,2014-12-16,650.0,1.9,1.85,4.0,0,63,0.3493,-0.0028,0.0,-2.4444,20.5137,SPX160617P00650000,549\n5863669,SPX,1972.75,SPX160617P00700000,put,2016-06-17,2014-12-16,700.0,3.2,1.15,5.7,0,1,0.3493,-0.0046,0.0,-3.893,32.6286,SPX160617P00700000,549\n5869635,SPX,2012.88,SPX160617C00650000,call,2016-06-17,2014-12-17,650.0,0.0,0.0,0.0,0,0,0.232,0.9796,0.0,25.4519,0.2524,SPX160617C00650000,548\n5869636,SPX,2012.88,SPX160617C00700000,call,2016-06-17,2014-12-17,700.0,0.0,0.0,0.0,0,0,0.232,0.9796,0.0,25.2935,0.7037,SPX160617C00700000,548\n5869708,SPX,2012.88,SPX160617P00650000,put,2016-06-17,2014-12-17,650.0,1.9,1.85,3.4,0,63,0.3352,-0.0017,0.0,-1.5632,13.6461,SPX160617P00650000,548\n5869709,SPX,2012.88,SPX160617P00700000,put,2016-06-17,2014-12-17,700.0,3.2,1.3,3.9,0,1,0.3352,-0.003,0.0,-2.6079,22.736,SPX160617P00700000,548\n5875706,SPX,2061.23,SPX160617C00650000,call,2016-06-17,2014-12-18,650.0,0.0,0.0,0.0,0,0,0.232,0.9797,0.0,26.1087,0.1805,SPX160617C00650000,547\n5875707,SPX,2061.23,SPX160617C00700000,call,2016-06-17,2014-12-18,700.0,0.0,0.0,0.0,0,0,0.232,0.9796,0.0,25.9596,0.5154,SPX160617C00700000,547\n5875779,SPX,2061.23,SPX160617P00650000,put,2016-06-17,2014-12-18,650.0,1.75,0.35,2.5,20,63,0.3229,-0.001,0.0,-0.9601,8.686,SPX160617P00650000,547\n5875780,SPX,2061.23,SPX160617P00700000,put,2016-06-17,2014-12-18,700.0,3.2,0.9,3.7,0,1,0.3229,-0.0019,0.0,-1.6821,15.1983,SPX160617P00700000,547\n5881779,SPX,2070.65,SPX160617C00650000,call,2016-06-17,2014-12-19,650.0,0.0,0.0,0.0,0,0,0.2307,0.9797,0.0,26.2512,0.1524,SPX160617C00650000,546\n5881780,SPX,2070.65,SPX160617C00700000,call,2016-06-17,2014-12-19,700.0,0.0,0.0,0.0,0,0,0.2307,0.9797,0.0,26.1057,0.4427,SPX160617C00700000,546\n5881852,SPX,2070.65,SPX160617P00650000,put,2016-06-17,2014-12-19,650.0,1.7,1.75,2.85,6,81,0.3271,-0.0011,0.0,-1.0413,9.2839,SPX160617P00650000,546\n5881853,SPX,2070.65,SPX160617P00700000,put,2016-06-17,2014-12-19,700.0,3.2,0.95,3.7,0,1,0.3271,-0.002,0.0,-1.8056,16.0785,SPX160617P00700000,546\n5887606,SPX,2078.54,SPX160617C00650000,call,2016-06-17,2014-12-22,650.0,0.0,0.0,0.0,0,0,0.2267,0.9797,0.0,26.5552,0.1026,SPX160617C00650000,543\n5887607,SPX,2078.54,SPX160617C00700000,call,2016-06-17,2014-12-22,700.0,0.0,0.0,0.0,0,0,0.2267,0.9796,0.0,26.4164,0.3122,SPX160617C00700000,543\n5887679,SPX,2078.54,SPX160617P00650000,put,2016-06-17,2014-12-22,650.0,1.65,1.6,2.75,12,89,0.3267,-0.001,0.0,-0.9845,8.7399,SPX160617P00650000,543\n5887680,SPX,2078.54,SPX160617P00700000,put,2016-06-17,2014-12-22,700.0,3.2,0.85,3.6,0,1,0.3267,-0.0019,0.0,-1.7172,15.2252,SPX160617P00700000,543\n5893338,SPX,2082.17,SPX160617C00650000,call,2016-06-17,2014-12-23,650.0,0.0,0.0,0.0,0,0,0.2279,0.9797,0.0,26.6447,0.1077,SPX160617C00650000,542\n5893339,SPX,2082.17,SPX160617C00700000,call,2016-06-17,2014-12-23,700.0,0.0,0.0,0.0,0,0,0.2279,0.9796,0.0,26.5051,0.3252,SPX160617C00700000,542\n5893411,SPX,2082.17,SPX160617P00650000,put,2016-06-17,2014-12-23,650.0,1.6,1.55,2.65,10,101,0.3265,-0.001,0.0,-0.961,8.521,SPX160617P00650000,542\n5893412,SPX,2082.17,SPX160617P00700000,put,2016-06-17,2014-12-23,700.0,3.2,0.85,3.6,0,1,0.3265,-0.0018,0.0,-1.6803,14.8797,SPX160617P00700000,542\n5899200,SPX,2081.88,SPX160617C00650000,call,2016-06-17,2014-12-24,650.0,0.0,0.0,0.0,0,0,0.2104,0.9797,0.0,26.6882,0.0241,SPX160617C00650000,541\n5899201,SPX,2081.88,SPX160617C00700000,call,2016-06-17,2014-12-24,700.0,0.0,0.0,0.0,0,0,0.2104,0.9797,0.0,26.5612,0.0878,SPX160617C00700000,541\n5899273,SPX,2081.88,SPX160617P00650000,put,2016-06-17,2014-12-24,650.0,1.55,0.0,2.2,5,111,0.3044,-0.0005,0.0,-0.4771,4.527,SPX160617P00650000,541\n5899274,SPX,2081.88,SPX160617P00700000,put,2016-06-17,2014-12-24,700.0,3.2,0.0,2.65,0,1,0.3044,-0.001,0.0,-0.9032,8.5579,SPX160617P00700000,541\n5905573,SPX,2088.77,SPX160617C00650000,call,2016-06-17,2014-12-26,650.0,0.0,0.0,0.0,0,0,0.2257,0.9796,0.0,26.9262,0.0826,SPX160617C00650000,539\n5905574,SPX,2088.77,SPX160617C00700000,call,2016-06-17,2014-12-26,700.0,0.0,0.0,0.0,0,0,0.2257,0.9796,0.0,26.79,0.2572,SPX160617C00700000,539\n5905646,SPX,2088.77,SPX160617P00650000,put,2016-06-17,2014-12-26,650.0,1.55,1.6,2.7,0,116,0.3211,-0.0008,0.0,-0.7867,7.0522,SPX160617P00650000,539\n5905647,SPX,2088.77,SPX160617P00700000,put,2016-06-17,2014-12-26,700.0,3.2,0.75,3.5,0,1,0.3211,-0.0015,0.0,-1.4063,12.5908,SPX160617P00700000,539\n5911946,SPX,2090.58,SPX160617C00650000,call,2016-06-17,2014-12-29,650.0,0.0,0.0,0.0,0,0,0.2248,0.9797,0.0,27.0204,0.0721,SPX160617C00650000,536\n5911947,SPX,2090.58,SPX160617C00700000,call,2016-06-17,2014-12-29,700.0,0.0,0.0,0.0,0,0,0.2248,0.9797,0.0,26.8857,0.2279,SPX160617C00700000,536\n5912019,SPX,2090.58,SPX160617P00650000,put,2016-06-17,2014-12-29,650.0,1.5,0.3,2.7,9,116,0.3193,-0.0007,0.0,-0.7241,6.4912,SPX160617P00650000,536\n5912020,SPX,2090.58,SPX160617P00700000,put,2016-06-17,2014-12-29,700.0,3.2,0.6,3.4,0,1,0.3193,-0.0014,0.0,-1.307,11.7018,SPX160617P00700000,536\n5918049,SPX,2080.34,SPX160617C00650000,call,2016-06-17,2014-12-30,650.0,0.0,0.0,0.0,0,0,0.2258,0.9797,0.0,26.8844,0.0824,SPX160617C00650000,535\n5918050,SPX,2080.34,SPX160617C00700000,call,2016-06-17,2014-12-30,700.0,0.0,0.0,0.0,0,0,0.2258,0.9797,0.0,26.7481,0.2574,SPX160617C00700000,535\n5918122,SPX,2080.34,SPX160617P00650000,put,2016-06-17,2014-12-30,650.0,1.5,0.25,2.65,1,125,0.3206,-0.0008,0.0,-0.7745,6.9025,SPX160617P00650000,535\n5918123,SPX,2080.34,SPX160617P00700000,put,2016-06-17,2014-12-30,700.0,3.2,0.7,3.5,0,1,0.3206,-0.0015,0.0,-1.3899,12.3706,SPX160617P00700000,535\n5924152,SPX,2058.9,SPX160617C00650000,call,2016-06-17,2014-12-31,650.0,0.0,0.0,0.0,0,0,0.2226,0.9798,0.0,26.5927,0.0735,SPX160617C00650000,534\n5924153,SPX,2058.9,SPX160617C00700000,call,2016-06-17,2014-12-31,700.0,0.0,0.0,0.0,0,0,0.2226,0.9798,0.0,26.4576,0.2349,SPX160617C00700000,534\n5924225,SPX,2058.9,SPX160617P00650000,put,2016-06-17,2014-12-31,650.0,1.5,0.4,2.8,0,126,0.3156,-0.0007,0.0,-0.7107,6.4202,SPX160617P00650000,534\n5924226,SPX,2058.9,SPX160617P00700000,put,2016-06-17,2014-12-31,700.0,3.2,0.5,3.7,0,1,0.3156,-0.0014,0.0,-1.2924,11.6598,SPX160617P00700000,534\n5930478,SPX,2058.2,SPX160617C00650000,call,2016-06-17,2015-01-02,650.0,0.0,0.0,0.0,0,0,0.1424,1.0,0.0,-1.6475,0.0,SPX160617C00650000,532\n5930479,SPX,2058.2,SPX160617C00700000,call,2016-06-17,2015-01-02,700.0,0.0,0.0,0.0,0,0,0.1424,1.0,0.0,-1.7742,0.0,SPX160617C00700000,532\n5930551,SPX,2058.2,SPX160617P00650000,put,2016-06-17,2015-01-02,650.0,1.5,0.4,2.6,0,126,0.3352,-0.0011,0.0,-1.0456,9.1379,SPX160617P00650000,532\n5930552,SPX,2058.2,SPX160617P00700000,put,2016-06-17,2015-01-02,700.0,3.2,0.95,3.7,0,1,0.3352,-0.002,0.0,-1.8011,15.7464,SPX160617P00700000,532\n5936502,SPX,2020.58,SPX160617C00650000,call,2016-06-17,2015-01-05,650.0,0.0,0.0,0.0,0,0,0.1472,1.0,0.0,-1.6475,0.0,SPX160617C00650000,529\n5936503,SPX,2020.58,SPX160617C00700000,call,2016-06-17,2015-01-05,700.0,0.0,0.0,0.0,0,0,0.1472,1.0,0.0,-1.7743,0.0,SPX160617C00700000,529\n5936575,SPX,2020.58,SPX160617P00650000,put,2016-06-17,2015-01-05,650.0,1.5,0.75,2.7,0,126,0.3965,-0.0043,0.0,-4.224,31.0324,SPX160617P00650000,529\n5936576,SPX,2020.58,SPX160617P00700000,put,2016-06-17,2015-01-05,700.0,3.2,1.4,3.8,0,1,0.3947,-0.0066,0.0,-6.104,45.0648,SPX160617P00700000,529\n5942261,SPX,2002.58,SPX160617C00650000,call,2016-06-17,2015-01-06,650.0,0.0,0.0,0.0,0,0,0.1463,1.0,0.0,-1.6475,0.0,SPX160617C00650000,528\n5942262,SPX,2002.58,SPX160617C00700000,call,2016-06-17,2015-01-06,700.0,0.0,0.0,0.0,0,0,0.1463,1.0,0.0,-1.7743,0.0,SPX160617C00700000,528\n5942334,SPX,2002.58,SPX160617P00650000,put,2016-06-17,2015-01-06,650.0,1.5,0.8,3.2,0,126,0.4007,-0.0049,0.0,-4.6881,34.0169,SPX160617P00650000,528\n5942335,SPX,2002.58,SPX160617P00700000,put,2016-06-17,2015-01-06,700.0,3.2,1.5,3.9,0,1,0.3949,-0.006999999999999999,0.0,-6.3332,46.6465,SPX160617P00700000,528\n5948020,SPX,2025.9,SPX160617C00650000,call,2016-06-17,2015-01-07,650.0,0.0,0.0,0.0,0,0,0.147,1.0,0.0,-1.6475,0.0,SPX160617C00650000,527\n5948021,SPX,2025.9,SPX160617C00700000,call,2016-06-17,2015-01-07,700.0,0.0,0.0,0.0,0,0,0.147,1.0,0.0,-1.7743,0.0,SPX160617C00700000,527\n5948093,SPX,2025.9,SPX160617P00650000,put,2016-06-17,2015-01-07,650.0,1.5,0.65,3.2,0,126,0.4004,-0.0045,0.0,-4.4152,31.9977,SPX160617P00650000,527\n5948094,SPX,2025.9,SPX160617P00700000,put,2016-06-17,2015-01-07,700.0,3.2,1.3,3.9,0,1,0.3954,-0.0065,0.0,-6.0557,44.4583,SPX160617P00700000,527\n5953858,SPX,2062.12,SPX160617C00650000,call,2016-06-17,2015-01-08,650.0,0.0,0.0,0.0,0,0,0.1472,1.0,0.0,-1.6476,0.0,SPX160617C00650000,526\n5953859,SPX,2062.12,SPX160617C00700000,call,2016-06-17,2015-01-08,700.0,0.0,0.0,0.0,0,0,0.1472,1.0,0.0,-1.7743,0.0,SPX160617C00700000,526\n5953931,SPX,2062.12,SPX160617P00650000,put,2016-06-17,2015-01-08,650.0,1.65,1.65,1.75,2,126,0.4092,-0.0046,0.0,-4.6906,33.1963,SPX160617P00650000,526\n5953932,SPX,2062.12,SPX160617P00700000,put,2016-06-17,2015-01-08,700.0,3.2,1.0,3.7,0,1,0.3938,-0.0057,0.0,-5.4545,40.1267,SPX160617P00700000,526\n5959920,SPX,2044.81,SPX160617C00650000,call,2016-06-17,2015-01-09,650.0,0.0,0.0,0.0,0,0,0.1466,1.0,0.0,-1.6476,0.0,SPX160617C00650000,525\n5959921,SPX,2044.81,SPX160617C00700000,call,2016-06-17,2015-01-09,700.0,0.0,0.0,0.0,0,0,0.1466,1.0,0.0,-1.7743,0.0,SPX160617C00700000,525\n5959993,SPX,2044.81,SPX160617P00650000,put,2016-06-17,2015-01-09,650.0,1.75,1.65,1.7,1,126,0.4064,-0.0046,0.0,-4.6323,32.9475,SPX160617P00650000,525\n5959994,SPX,2044.81,SPX160617P00700000,put,2016-06-17,2015-01-09,700.0,3.2,1.15,3.7,0,1,0.3946,-0.006,0.0,-5.7059,41.8131,SPX160617P00700000,525\n5965982,SPX,2028.56,SPX160617C00650000,call,2016-06-17,2015-01-12,650.0,0.0,0.0,0.0,0,0,0.1419,1.0,0.0,-1.6476,0.0,SPX160617C00650000,522\n5965983,SPX,2028.56,SPX160617C00700000,call,2016-06-17,2015-01-12,700.0,0.0,0.0,0.0,0,0,0.1419,1.0,0.0,-1.7743,0.0,SPX160617C00700000,522\n5966055,SPX,2028.56,SPX160617P00650000,put,2016-06-17,2015-01-12,650.0,1.8,1.65,1.9,4,126,0.4082,-0.0049,0.0,-4.8715,34.2988,SPX160617P00650000,522\n5966056,SPX,2028.56,SPX160617P00700000,put,2016-06-17,2015-01-12,700.0,3.2,1.2,3.7,0,1,0.3943,-0.0062,0.0,-5.8101,42.3651,SPX160617P00700000,522\n5971781,SPX,2023.03,SPX160617C00650000,call,2016-06-17,2015-01-13,650.0,0.0,0.0,0.0,0,0,0.1531,1.0,0.0,-1.6476,0.0,SPX160617C00650000,521\n5971782,SPX,2023.03,SPX160617C00700000,call,2016-06-17,2015-01-13,700.0,0.0,0.0,0.0,0,0,0.1531,1.0,0.0,-1.7744,0.0,SPX160617C00700000,521\n5971854,SPX,2023.03,SPX160617P00650000,put,2016-06-17,2015-01-13,650.0,1.9,0.75,2.85,6,126,0.4015,-0.0045,0.0,-4.4081,31.4937,SPX160617P00650000,521\n5971855,SPX,2023.03,SPX160617P00700000,put,2016-06-17,2015-01-13,700.0,3.2,1.4,3.9,0,1,0.3989,-0.0067,0.0,-6.2783,45.1641,SPX160617P00700000,521\n5977580,SPX,2011.28,SPX160617C00650000,call,2016-06-17,2015-01-14,650.0,0.0,0.0,0.0,0,0,0.1527,1.0,0.0,-1.6476,0.0,SPX160617C00650000,520\n5977581,SPX,2011.28,SPX160617C00700000,call,2016-06-17,2015-01-14,700.0,0.0,0.0,0.0,0,0,0.1527,1.0,0.0,-1.7744,0.0,SPX160617C00700000,520\n5977653,SPX,2011.28,SPX160617P00650000,put,2016-06-17,2015-01-14,650.0,1.9,0.75,3.4,0,128,0.3455,-0.0016,0.0,-1.464,12.1327,SPX160617P00650000,520\n5977654,SPX,2011.28,SPX160617P00700000,put,2016-06-17,2015-01-14,700.0,3.2,1.4,4.0,0,1,0.3455,-0.0028,0.0,-2.4501,20.3123,SPX160617P00700000,520\n5983381,SPX,1992.68,SPX160617C00650000,call,2016-06-17,2015-01-15,650.0,0.0,0.0,0.0,0,0,0.1454,1.0,0.0,-1.6476,0.0,SPX160617C00650000,519\n5983382,SPX,1992.68,SPX160617C00700000,call,2016-06-17,2015-01-15,700.0,0.0,0.0,0.0,0,0,0.1454,1.0,0.0,-1.7744,0.0,SPX160617C00700000,519\n5983454,SPX,1992.68,SPX160617P00650000,put,2016-06-17,2015-01-15,650.0,1.9,0.85,3.4,0,128,0.4059,-0.0052,0.0,-5.0104,35.2747,SPX160617P00650000,519\n5983455,SPX,1992.68,SPX160617P00700000,put,2016-06-17,2015-01-15,700.0,3.2,1.5,4.1,0,1,0.3983,-0.0072,0.0,-6.5791,47.2201,SPX160617P00700000,519\n5989264,SPX,2019.41,SPX160617C00650000,call,2016-06-17,2015-01-16,650.0,0.0,0.0,0.0,0,0,0.1446,1.0,0.0,-1.6476,0.0,SPX160617C00650000,518\n5989265,SPX,2019.41,SPX160617C00700000,call,2016-06-17,2015-01-16,700.0,0.0,0.0,0.0,0,0,0.1446,1.0,0.0,-1.7744,0.0,SPX160617C00700000,518\n5989337,SPX,2019.41,SPX160617P00650000,put,2016-06-17,2015-01-16,650.0,1.9,1.35,2.75,0,128,0.4139,-0.0053,0.0,-5.3332,36.7476,SPX160617P00650000,518\n5989338,SPX,2019.41,SPX160617P00700000,put,2016-06-17,2015-01-16,700.0,3.2,2.0,3.4,0,1,0.4038,-0.0071,0.0,-6.7214,47.4884,SPX160617P00700000,518\n5994999,SPX,2022.54,SPX160617C00650000,call,2016-06-17,2015-01-20,650.0,0.0,0.0,0.0,0,0,0.141,1.0,0.0,-1.6477,0.0,SPX160617C00650000,514\n5995000,SPX,2022.54,SPX160617C00700000,call,2016-06-17,2015-01-20,700.0,0.0,0.0,0.0,0,0,0.141,1.0,0.0,-1.7744,0.0,SPX160617C00700000,514\n5995072,SPX,2022.54,SPX160617P00650000,put,2016-06-17,2015-01-20,650.0,1.9,0.65,2.7,0,128,0.3997,-0.0042,0.0,-4.1516,29.3918,SPX160617P00650000,514\n5995073,SPX,2022.54,SPX160617P00700000,put,2016-06-17,2015-01-20,700.0,3.2,1.25,3.4,0,1,0.3947,-0.0061,0.0,-5.7394,41.1623,SPX160617P00700000,514\n6000439,SPX,2032.13,SPX160617C00650000,call,2016-06-17,2015-01-21,650.0,0.0,0.0,0.0,0,0,0.1377,1.0,0.0,-1.6477,0.0,SPX160617C00650000,513\n6000440,SPX,2032.13,SPX160617C00700000,call,2016-06-17,2015-01-21,700.0,0.0,0.0,0.0,0,0,0.1377,1.0,0.0,-1.7745,0.0,SPX160617C00700000,513\n6000512,SPX,2032.13,SPX160617P00650000,put,2016-06-17,2015-01-21,650.0,1.8,1.5,1.85,6,128,0.4091,-0.0046,0.0,-4.7189999999999985,32.5765,SPX160617P00650000,513\n6000513,SPX,2032.13,SPX160617P00700000,put,2016-06-17,2015-01-21,700.0,3.2,1.15,3.8,0,1,0.3982,-0.0062,0.0,-5.9109,41.9361,SPX160617P00700000,513\n6005879,SPX,2063.15,SPX160617C00650000,call,2016-06-17,2015-01-22,650.0,0.0,0.0,0.0,0,0,0.1271,1.0,0.0,-1.6477,0.0,SPX160617C00650000,512\n6005880,SPX,2063.15,SPX160617C00700000,call,2016-06-17,2015-01-22,700.0,0.0,0.0,0.0,0,0,0.1271,1.0,0.0,-1.7745,0.0,SPX160617C00700000,512\n6005952,SPX,2063.15,SPX160617P00650000,put,2016-06-17,2015-01-22,650.0,1.8,1.5,1.85,0,128,0.4139,-0.0045,0.0,-4.7469,32.3232,SPX160617P00650000,512\n6005953,SPX,2063.15,SPX160617P00700000,put,2016-06-17,2015-01-22,700.0,3.2,0.85,3.6,0,1,0.3951,-0.0053,0.0,-5.2486,37.4533,SPX160617P00700000,512\n6011565,SPX,2051.82,SPX160617C00650000,call,2016-06-17,2015-01-23,650.0,0.0,0.0,0.0,0,0,0.1439,1.0,0.0,-1.6477,0.0,SPX160617C00650000,511\n6011566,SPX,2051.82,SPX160617C00700000,call,2016-06-17,2015-01-23,700.0,0.0,0.0,0.0,0,0,0.1439,1.0,0.0,-1.7745,0.0,SPX160617C00700000,511\n6011638,SPX,2051.82,SPX160617P00650000,put,2016-06-17,2015-01-23,650.0,1.8,1.5,1.7,0,128,0.4105,-0.0044,0.0,-4.5858,31.4238,SPX160617P00650000,511\n6011639,SPX,2051.82,SPX160617P00700000,put,2016-06-17,2015-01-23,700.0,3.2,0.9,3.7,0,1,0.3958,-0.0056,0.0,-5.4162,38.5056,SPX160617P00700000,511\n6017292,SPX,2057.09,SPX160617C00650000,call,2016-06-17,2015-01-26,650.0,0.0,0.0,0.0,0,0,0.1433,1.0,0.0,-1.6478,0.0,SPX160617C00650000,508\n6017293,SPX,2057.09,SPX160617C00700000,call,2016-06-17,2015-01-26,700.0,0.0,0.0,0.0,0,0,0.1433,1.0,0.0,-1.7745,0.0,SPX160617C00700000,508\n6017365,SPX,2057.09,SPX160617P00650000,put,2016-06-17,2015-01-26,650.0,1.5,0.3,1.6,3,128,0.3785,-0.0025,0.0,-2.5068,18.5197,SPX160617P00650000,508\n6017366,SPX,2057.09,SPX160617P00700000,put,2016-06-17,2015-01-26,700.0,3.2,0.8,3.6,0,1,0.3946,-0.0053,0.0,-5.1876,36.7736,SPX160617P00700000,508\n6022775,SPX,2029.56,SPX160617C00650000,call,2016-06-17,2015-01-27,650.0,0.0,0.0,0.0,0,0,0.1478,1.0,0.0,-1.6478,0.0,SPX160617C00650000,507\n6022776,SPX,2029.56,SPX160617C00700000,call,2016-06-17,2015-01-27,700.0,0.0,0.0,0.0,0,0,0.1478,1.0,0.0,-1.7745,0.0,SPX160617C00700000,507\n6022849,SPX,2029.56,SPX160617P00650000,put,2016-06-17,2015-01-27,650.0,1.5,1.4,2.15,0,131,0.4136,-0.0048,0.0,-4.9622,33.4833,SPX160617P00650000,507\n6022850,SPX,2029.56,SPX160617P00700000,put,2016-06-17,2015-01-27,700.0,3.2,1.05,3.7,0,1,0.3973,-0.0059,0.0,-5.7229,40.2152,SPX160617P00700000,507\n6028274,SPX,2002.16,SPX160617C00650000,call,2016-06-17,2015-01-28,650.0,0.0,0.0,0.0,0,0,0.1414,1.0,0.0,-1.6478,0.0,SPX160617C00650000,506\n6028275,SPX,2002.16,SPX160617C00700000,call,2016-06-17,2015-01-28,700.0,0.0,0.0,0.0,0,0,0.1414,1.0,0.0,-1.7745,0.0,SPX160617C00700000,506\n6028348,SPX,2002.16,SPX160617P00650000,put,2016-06-17,2015-01-28,650.0,1.5,1.4,2.05,0,131,0.3331,-0.0011,0.0,-0.9931,8.3051,SPX160617P00650000,506\n6028349,SPX,2002.16,SPX160617P00700000,put,2016-06-17,2015-01-28,700.0,3.2,0.9,4.1,0,1,0.3331,-0.0019,0.0,-1.7442,14.5921,SPX160617P00700000,506\n6033773,SPX,1999.74,SPX160617C00650000,call,2016-06-17,2015-01-29,650.0,0.0,0.0,0.0,0,0,0.183,1.0,0.0,-1.6478,0.0006,SPX160617C00650000,505\n6033774,SPX,1999.74,SPX160617C00700000,call,2016-06-17,2015-01-29,700.0,0.0,0.0,0.0,0,0,0.183,1.0,0.0,-1.7748,0.0034,SPX160617C00700000,505\n6033847,SPX,1999.74,SPX160617P00650000,put,2016-06-17,2015-01-29,650.0,1.5,1.4,1.85,0,131,0.4059,-0.0046,0.0,-4.6462,31.8216,SPX160617P00650000,505\n6033848,SPX,1999.74,SPX160617P00700000,put,2016-06-17,2015-01-29,700.0,3.2,1.25,3.8,0,1,0.3983,-0.0065,0.0,-6.147,42.919,SPX160617P00700000,505\n6039416,SPX,1994.99,SPX160617C00650000,call,2016-06-17,2015-01-30,650.0,0.0,0.0,0.0,0,0,0.1344,1.0,0.0,-1.6478,0.0,SPX160617C00650000,504\n6039417,SPX,1994.99,SPX160617C00700000,call,2016-06-17,2015-01-30,700.0,0.0,0.0,0.0,0,0,0.1344,1.0,0.0,-1.7746,0.0,SPX160617C00700000,504\n6039490,SPX,1994.99,SPX160617P00650000,put,2016-06-17,2015-01-30,650.0,2.15,1.4,3.4,1,131,0.4234,-0.006,0.0,-6.0945,39.9352,SPX160617P00650000,504\n6039491,SPX,1994.99,SPX160617P00700000,put,2016-06-17,2015-01-30,700.0,3.2,1.65,4.4,0,1,0.4091,-0.0076,0.0,-7.2357,49.0884,SPX160617P00700000,504\n6045059,SPX,2020.86,SPX160617C00650000,call,2016-06-17,2015-02-02,650.0,0.0,0.0,0.0,0,0,0.1334,1.0,0.0,-1.6737,0.0,SPX160617C00650000,501\n6045060,SPX,2020.86,SPX160617C00700000,call,2016-06-17,2015-02-02,700.0,0.0,0.0,0.0,0,0,0.1334,1.0,0.0,-1.8024,0.0,SPX160617C00700000,501\n6045133,SPX,2020.86,SPX160617P00650000,put,2016-06-17,2015-02-02,650.0,2.15,1.4,2.8,0,131,0.4225,-0.0054,0.0,-5.6346,36.7799,SPX160617P00650000,501\n6045134,SPX,2020.86,SPX160617P00700000,put,2016-06-17,2015-02-02,700.0,3.2,2.05,3.5,0,1,0.4124,-0.0073,0.0,-7.1108,47.5698,SPX160617P00700000,501\n6050522,SPX,2050.03,SPX160617C00650000,call,2016-06-17,2015-02-03,650.0,0.0,0.0,0.0,0,0,0.1363,1.0,0.0,-1.6737,0.0,SPX160617C00650000,500\n6050523,SPX,2050.03,SPX160617C00700000,call,2016-06-17,2015-02-03,700.0,0.0,0.0,0.0,0,0,0.1363,1.0,0.0,-1.8024,0.0,SPX160617C00700000,500\n6050596,SPX,2050.03,SPX160617P00650000,put,2016-06-17,2015-02-03,650.0,2.15,1.4,2.7,0,131,0.3439,-0.0011,0.0,-1.0884,8.7111,SPX160617P00650000,500\n6050597,SPX,2050.03,SPX160617P00700000,put,2016-06-17,2015-02-03,700.0,3.2,1.1,3.7,0,1,0.3439,-0.002,0.0,-1.8818,15.0658,SPX160617P00700000,500\n6055987,SPX,2041.51,SPX160617C00650000,call,2016-06-17,2015-02-04,650.0,0.0,0.0,0.0,0,0,0.1421,1.0,0.0,-1.6737,0.0,SPX160617C00650000,499\n6055988,SPX,2041.51,SPX160617C00700000,call,2016-06-17,2015-02-04,700.0,0.0,0.0,0.0,0,0,0.1421,1.0,0.0,-1.8024,0.0,SPX160617C00700000,499\n6056061,SPX,2041.51,SPX160617P00650000,put,2016-06-17,2015-02-04,650.0,2.15,1.4,2.75,0,131,0.3471,-0.0012,0.0,-1.208,9.5598,SPX160617P00650000,499\n6056062,SPX,2041.51,SPX160617P00700000,put,2016-06-17,2015-02-04,700.0,3.2,1.3,3.7,0,1,0.3471,-0.0022,0.0,-2.0672,16.3649,SPX160617P00700000,499\n6061452,SPX,2062.51,SPX160617C00650000,call,2016-06-17,2015-02-05,650.0,0.0,0.0,0.0,0,0,0.1323,1.0,0.0,-1.6737,0.0,SPX160617C00650000,498\n6061453,SPX,2062.51,SPX160617C00700000,call,2016-06-17,2015-02-05,700.0,0.0,0.0,0.0,0,0,0.1323,1.0,0.0,-1.8024,0.0,SPX160617C00700000,498\n6061526,SPX,2062.51,SPX160617P00650000,put,2016-06-17,2015-02-05,650.0,2.15,1.4,2.8,0,131,0.4299,-0.0052,0.0,-5.7014,36.3515,SPX160617P00650000,498\n6061527,SPX,2062.51,SPX160617P00700000,put,2016-06-17,2015-02-05,700.0,3.2,1.05,3.7,0,1,0.4058,-0.0058,0.0,-5.8573,39.5773,SPX160617P00700000,498\n6067047,SPX,2055.47,SPX160617C00650000,call,2016-06-17,2015-02-06,650.0,0.0,0.0,0.0,0,0,0.1326,1.0,0.0,-1.6737,0.0,SPX160617C00650000,497\n6067048,SPX,2055.47,SPX160617C00700000,call,2016-06-17,2015-02-06,700.0,0.0,0.0,0.0,0,0,0.1326,1.0,0.0,-1.8025,0.0,SPX160617C00700000,497\n6067121,SPX,2055.47,SPX160617P00650000,put,2016-06-17,2015-02-06,650.0,2.15,1.45,2.9,0,131,0.4313,-0.0054,0.0,-5.8751,37.2622,SPX160617P00650000,497\n6067122,SPX,2055.47,SPX160617P00700000,put,2016-06-17,2015-02-06,700.0,3.2,1.25,3.9,0,1,0.4107,-0.0063,0.0,-6.3694,42.4386,SPX160617P00700000,497\n6072642,SPX,2046.74,SPX160617C00650000,call,2016-06-17,2015-02-09,650.0,0.0,0.0,0.0,0,0,0.1347,1.0,0.0,-1.6737,0.0,SPX160617C00650000,494\n6072643,SPX,2046.74,SPX160617C00700000,call,2016-06-17,2015-02-09,700.0,0.0,0.0,0.0,0,0,0.1347,1.0,0.0,-1.8025,0.0,SPX160617C00700000,494\n6072716,SPX,2046.74,SPX160617P00650000,put,2016-06-17,2015-02-09,650.0,2.15,1.45,2.8,0,131,0.4301,-0.0054,0.0,-5.8014,36.674,SPX160617P00650000,494\n6072717,SPX,2046.74,SPX160617P00700000,put,2016-06-17,2015-02-09,700.0,3.2,2.0,3.4,0,1,0.4177,-0.006999999999999999,0.0,-7.0834,46.1232,SPX160617P00700000,494\n6078019,SPX,2068.58,SPX160617C00650000,call,2016-06-17,2015-02-10,650.0,0.0,0.0,0.0,0,0,0.14800000000000002,1.0,0.0,-1.6738,0.0,SPX160617C00650000,493\n6078020,SPX,2068.58,SPX160617C00700000,call,2016-06-17,2015-02-10,700.0,0.0,0.0,0.0,0,0,0.14800000000000002,1.0,0.0,-1.8025,0.0,SPX160617C00700000,493\n6078093,SPX,2068.58,SPX160617P00650000,put,2016-06-17,2015-02-10,650.0,2.15,1.45,1.95,0,131,0.3521,-0.0012,0.0,-1.21,9.3245,SPX160617P00650000,493\n6078094,SPX,2068.58,SPX160617P00700000,put,2016-06-17,2015-02-10,700.0,3.2,1.25,3.8,0,1,0.3521,-0.0021,0.0,-2.0657,15.9244,SPX160617P00700000,493\n6083396,SPX,2068.53,SPX160617C00650000,call,2016-06-17,2015-02-11,650.0,0.0,0.0,0.0,0,0,0.1513,1.0,0.0,-1.6738,0.0,SPX160617C00650000,492\n6083397,SPX,2068.53,SPX160617C00700000,call,2016-06-17,2015-02-11,700.0,0.0,0.0,0.0,0,0,0.1513,1.0,0.0,-1.8025,0.0,SPX160617C00700000,492\n6083470,SPX,2068.53,SPX160617P00650000,put,2016-06-17,2015-02-11,650.0,1.85,1.45,1.95,2,131,0.4236,-0.0046,0.0,-4.9916,31.9069,SPX160617P00650000,492\n6083471,SPX,2068.53,SPX160617P00700000,put,2016-06-17,2015-02-11,700.0,3.2,1.2,3.7,0,1,0.412,-0.006,0.0,-6.1937,40.7196,SPX160617P00700000,492\n6088966,SPX,2088.48,SPX160617C00650000,call,2016-06-17,2015-02-12,650.0,0.0,0.0,0.0,0,0,0.1438,1.0,0.0,-1.6738,0.0,SPX160617C00650000,491\n6088967,SPX,2088.48,SPX160617C00700000,call,2016-06-17,2015-02-12,700.0,0.0,0.0,0.0,0,0,0.1438,1.0,0.0,-1.8025,0.0,SPX160617C00700000,491\n6089040,SPX,2088.48,SPX160617P00650000,put,2016-06-17,2015-02-12,650.0,1.85,1.45,2.75,0,131,0.4369,-0.0052,0.0,-5.8186,35.9852,SPX160617P00650000,491\n6089041,SPX,2088.48,SPX160617P00700000,put,2016-06-17,2015-02-12,700.0,3.2,0.95,3.7,0,1,0.4103,-0.0055,0.0,-5.7689,38.0042,SPX160617P00700000,491\n6094842,SPX,2096.99,SPX160617C00650000,call,2016-06-17,2015-02-13,650.0,0.0,0.0,0.0,0,0,0.1314,1.0,0.0,-1.6738,0.0,SPX160617C00650000,490\n6094843,SPX,2096.99,SPX160617C00700000,call,2016-06-17,2015-02-13,700.0,0.0,0.0,0.0,0,0,0.1314,1.0,0.0,-1.8025,0.0,SPX160617C00700000,490\n6094916,SPX,2096.99,SPX160617P00650000,put,2016-06-17,2015-02-13,650.0,1.85,1.45,2.75,0,131,0.4386,-0.0051,0.0,-5.8395,35.9,SPX160617P00650000,490\n6094917,SPX,2096.99,SPX160617P00700000,put,2016-06-17,2015-02-13,700.0,3.2,0.85,3.6,0,1,0.4087,-0.0052,0.0,-5.5052,36.3336,SPX160617P00700000,490\n6100724,SPX,2100.34,SPX160617C00650000,call,2016-06-17,2015-02-17,650.0,0.0,0.0,0.0,0,0,0.1219,1.0,0.0,-1.6738,0.0,SPX160617C00650000,486\n6100725,SPX,2100.34,SPX160617C00700000,call,2016-06-17,2015-02-17,700.0,0.0,0.0,0.0,0,0,0.1219,1.0,0.0,-1.8026,0.0,SPX160617C00700000,486\n6100798,SPX,2100.34,SPX160617P00650000,put,2016-06-17,2015-02-17,650.0,1.85,1.45,1.9,0,131,0.43,-0.0044,0.0,-5.0167,31.2001,SPX160617P00650000,486\n6100799,SPX,2100.34,SPX160617P00700000,put,2016-06-17,2015-02-17,700.0,3.2,0.85,3.6,0,1,0.4108,-0.0052,0.0,-5.5486,36.1334,SPX160617P00700000,486\n6106418,SPX,2099.67,SPX160617C00650000,call,2016-06-17,2015-02-18,650.0,0.0,0.0,0.0,0,0,0.1452,1.0,0.0,-1.6739,0.0,SPX160617C00650000,485\n6106419,SPX,2099.67,SPX160617C00700000,call,2016-06-17,2015-02-18,700.0,0.0,0.0,0.0,0,0,0.1452,1.0,0.0,-1.8026,0.0,SPX160617C00700000,485\n6106492,SPX,2099.67,SPX160617P00650000,put,2016-06-17,2015-02-18,650.0,1.85,1.45,2.1,0,131,0.3414,-0.0007,0.0,-0.769,6.0120000000000005,SPX160617P00650000,485\n6106493,SPX,2099.67,SPX160617P00700000,put,2016-06-17,2015-02-18,700.0,3.2,0.85,3.6,0,1,0.3414,-0.0014,0.0,-1.3769,10.7677,SPX160617P00700000,485\n6112210,SPX,2097.45,SPX160617C00650000,call,2016-06-17,2015-02-19,650.0,0.0,0.0,0.0,0,0,0.1449,1.0,0.0,-1.6739,0.0,SPX160617C00650000,484\n6112211,SPX,2097.45,SPX160617C00700000,call,2016-06-17,2015-02-19,700.0,0.0,0.0,0.0,0,0,0.1449,1.0,0.0,-1.8026,0.0,SPX160617C00700000,484\n6112284,SPX,2097.45,SPX160617P00650000,put,2016-06-17,2015-02-19,650.0,1.85,1.45,2.05,0,131,0.4326,-0.0045,0.0,-5.2006,32.0164,SPX160617P00650000,484\n6112285,SPX,2097.45,SPX160617P00700000,put,2016-06-17,2015-02-19,700.0,3.2,0.9,3.7,0,1,0.4133,-0.0053,0.0,-5.7497,37.0624,SPX160617P00700000,484\n6118088,SPX,2110.3,SPX160617C00650000,call,2016-06-17,2015-02-20,650.0,0.0,0.0,0.0,0,0,0.1464,1.0,0.0,-1.6739,0.0,SPX160617C00650000,483\n6118089,SPX,2110.3,SPX160617C00700000,call,2016-06-17,2015-02-20,700.0,0.0,0.0,0.0,0,0,0.1464,1.0,0.0,-1.8026,0.0,SPX160617C00700000,483\n6118162,SPX,2110.3,SPX160617P00650000,put,2016-06-17,2015-02-20,650.0,1.85,1.45,2.15,0,131,0.4362,-0.0046,0.0,-5.3242,32.4383,SPX160617P00650000,483\n6118163,SPX,2110.3,SPX160617P00700000,put,2016-06-17,2015-02-20,700.0,3.2,0.75,3.6,0,1,0.411,-0.005,0.0,-5.3775,34.7836,SPX160617P00700000,483\n6123726,SPX,2109.65,SPX160617C00650000,call,2016-06-17,2015-02-23,650.0,0.0,0.0,0.0,0,0,0.1427,1.0,0.0,-1.6739,0.0,SPX160617C00650000,480\n6123727,SPX,2109.65,SPX160617C00700000,call,2016-06-17,2015-02-23,700.0,0.0,0.0,0.0,0,0,0.1427,1.0,0.0,-1.8027,0.0,SPX160617C00700000,480\n6123800,SPX,2109.65,SPX160617P00650000,put,2016-06-17,2015-02-23,650.0,1.45,0.3,2.0,5,131,0.4025,-0.0026,0.0,-2.9958,19.6571,SPX160617P00650000,480\n6123801,SPX,2109.65,SPX160617P00700000,put,2016-06-17,2015-02-23,700.0,3.2,0.8,3.6,0,1,0.4135,-0.0051,0.0,-5.5228,35.285,SPX160617P00700000,480\n6129119,SPX,2115.49,SPX160617C00650000,call,2016-06-17,2015-02-24,650.0,0.0,0.0,0.0,0,0,0.1409,1.0,0.0,-1.6739,0.0,SPX160617C00650000,479\n6129120,SPX,2115.49,SPX160617C00700000,call,2016-06-17,2015-02-24,700.0,0.0,0.0,0.0,0,0,0.1409,1.0,0.0,-1.8027,0.0,SPX160617C00700000,479\n6129193,SPX,2115.49,SPX160617P00650000,put,2016-06-17,2015-02-24,650.0,1.35,1.3,1.7,10,136,0.4294,-0.004,0.0,-4.6593,28.5965,SPX160617P00650000,479\n6129194,SPX,2115.49,SPX160617P00700000,put,2016-06-17,2015-02-24,700.0,3.2,0.5,3.2,0,1,0.4019,-0.0041,0.0,-4.5011,29.5258,SPX160617P00700000,479\n6134666,SPX,2113.86,SPX160617C00650000,call,2016-06-17,2015-02-25,650.0,0.0,0.0,0.0,0,0,0.1313,1.0,0.0,-1.6739,0.0,SPX160617C00650000,478\n6134667,SPX,2113.86,SPX160617C00700000,call,2016-06-17,2015-02-25,700.0,0.0,0.0,0.0,0,0,0.1313,1.0,0.0,-1.8027,0.0,SPX160617C00700000,478\n6134740,SPX,2113.86,SPX160617P00650000,put,2016-06-17,2015-02-25,650.0,1.3,0.25,2.65,15,146,0.4093,-0.0029,0.0,-3.3211,21.3396,SPX160617P00650000,478\n6134741,SPX,2113.86,SPX160617P00700000,put,2016-06-17,2015-02-25,700.0,3.2,0.5,3.3,0,1,0.403,-0.0042,0.0,-4.5791,29.8922,SPX160617P00700000,478\n6140215,SPX,2110.74,SPX160617C00650000,call,2016-06-17,2015-02-26,650.0,0.0,0.0,0.0,0,0,0.1191,1.0,0.0,-1.6739,0.0,SPX160617C00650000,477\n6140216,SPX,2110.74,SPX160617C00700000,call,2016-06-17,2015-02-26,700.0,0.0,0.0,0.0,0,0,0.1191,1.0,0.0,-1.8027,0.0,SPX160617C00700000,477\n6140289,SPX,2110.74,SPX160617P00650000,put,2016-06-17,2015-02-26,650.0,1.3,0.3,2.7,0,161,0.4129,-0.0031,0.0,-3.5455,22.5351,SPX160617P00650000,477\n6140290,SPX,2110.74,SPX160617P00700000,put,2016-06-17,2015-02-26,700.0,3.2,0.5,3.2,0,1,0.402,-0.0041,0.0,-4.5123,29.4674,SPX160617P00700000,477\n6145942,SPX,2104.5,SPX160617C00650000,call,2016-06-17,2015-02-27,650.0,0.0,0.0,0.0,0,0,0.1229,1.0,0.0,-1.6740000000000002,0.0,SPX160617C00650000,476\n6145943,SPX,2104.5,SPX160617C00700000,call,2016-06-17,2015-02-27,700.0,0.0,0.0,0.0,0,0,0.1229,1.0,0.0,-1.8027,0.0,SPX160617C00700000,476\n6146016,SPX,2104.5,SPX160617P00650000,put,2016-06-17,2015-02-27,650.0,1.3,0.2,2.6,0,161,0.4047,-0.0027,0.0,-3.088,19.9833,SPX160617P00650000,476\n6146017,SPX,2104.5,SPX160617P00700000,put,2016-06-17,2015-02-27,700.0,3.2,0.5,3.2,0,1,0.4016,-0.0042,0.0,-4.5231,29.5053,SPX160617P00700000,476\n6151669,SPX,2117.39,SPX160617C00650000,call,2016-06-17,2015-03-02,650.0,0.0,0.0,0.0,0,0,0.1451,1.0,0.0,-1.7379,0.0,SPX160617C00650000,473\n6151670,SPX,2117.39,SPX160617C00700000,call,2016-06-17,2015-03-02,700.0,0.0,0.0,0.0,0,0,0.1451,1.0,0.0,-1.8716,0.0,SPX160617C00700000,473\n6151743,SPX,2117.39,SPX160617P00650000,put,2016-06-17,2015-03-02,650.0,1.3,0.0,2.6,0,161,0.3256,-0.0004,0.0,-0.3933,3.1443,SPX160617P00650000,473\n6151744,SPX,2117.39,SPX160617P00700000,put,2016-06-17,2015-03-02,700.0,3.2,0.25,3.0,0,1,0.3256,-0.0007,0.0,-0.7583,6.0644,SPX160617P00700000,473\n6157215,SPX,2107.78,SPX160617C00650000,call,2016-06-17,2015-03-03,650.0,0.0,0.0,0.0,0,0,0.1349,1.0,0.0,-1.7379,0.0,SPX160617C00650000,472\n6157216,SPX,2107.78,SPX160617C00700000,call,2016-06-17,2015-03-03,700.0,0.0,0.0,0.0,0,0,0.1349,1.0,0.0,-1.8716,0.0,SPX160617C00700000,472\n6157289,SPX,2107.78,SPX160617P00650000,put,2016-06-17,2015-03-03,650.0,1.3,0.0,2.7,5,161,0.332,-0.0005,0.0,-0.5001,3.9128,SPX160617P00650000,472\n6157290,SPX,2107.78,SPX160617P00700000,put,2016-06-17,2015-03-03,700.0,3.2,0.35,3.0,0,1,0.332,-0.0009,0.0,-0.9407,7.3619,SPX160617P00700000,472\n6162761,SPX,2098.53,SPX160617C00650000,call,2016-06-17,2015-03-04,650.0,0.0,0.0,0.0,0,0,0.1161,1.0,0.0,-1.7379,0.0,SPX160617C00650000,471\n6162762,SPX,2098.53,SPX160617C00700000,call,2016-06-17,2015-03-04,700.0,0.0,0.0,0.0,0,0,0.1161,1.0,0.0,-1.8716,0.0,SPX160617C00700000,471\n6162835,SPX,2098.53,SPX160617P00650000,put,2016-06-17,2015-03-04,650.0,1.3,0.05,2.7,0,166,0.3888,-0.002,0.0,-2.2276,14.8495,SPX160617P00650000,471\n6162836,SPX,2098.53,SPX160617P00700000,put,2016-06-17,2015-03-04,700.0,3.2,0.35,3.1,0,1,0.3957,-0.0037,0.0,-4.0355,26.4408,SPX160617P00700000,471\n6168588,SPX,2101.04,SPX160617C00650000,call,2016-06-17,2015-03-05,650.0,0.0,0.0,0.0,0,0,0.1468,1.0,0.0,-1.7379,0.0,SPX160617C00650000,470\n6168589,SPX,2101.04,SPX160617C00700000,call,2016-06-17,2015-03-05,700.0,0.0,0.0,0.0,0,0,0.1468,1.0,0.0,-1.8716,0.0,SPX160617C00700000,470\n6168662,SPX,2101.04,SPX160617P00650000,put,2016-06-17,2015-03-05,650.0,1.3,1.25,2.65,0,166,0.3308,-0.0004,0.0,-0.4841,3.7853,SPX160617P00650000,470\n6168663,SPX,2101.04,SPX160617P00700000,put,2016-06-17,2015-03-05,700.0,3.2,0.3,3.1,0,1,0.3308,-0.0009,0.0,-0.9153,7.1592,SPX160617P00700000,470\n6174739,SPX,2071.26,SPX160617C00650000,call,2016-06-17,2015-03-06,650.0,0.0,0.0,0.0,0,0,0.1332,1.0,0.0,-1.7380000000000002,0.0,SPX160617C00650000,469\n6174740,SPX,2071.26,SPX160617C00700000,call,2016-06-17,2015-03-06,700.0,0.0,0.0,0.0,0,0,0.1332,1.0,0.0,-1.8716,0.0,SPX160617C00700000,469\n6174813,SPX,2071.26,SPX160617P00650000,put,2016-06-17,2015-03-06,650.0,1.3,1.25,2.65,0,166,0.3332,-0.0005,0.0,-0.5787,4.4828,SPX160617P00650000,469\n6174814,SPX,2071.26,SPX160617P00700000,put,2016-06-17,2015-03-06,700.0,3.2,0.3,3.1,0,1,0.3332,-0.0011,0.0,-1.0781,8.3538,SPX160617P00700000,469\n6180890,SPX,2079.43,SPX160617C00650000,call,2016-06-17,2015-03-09,650.0,0.0,0.0,0.0,0,0,0.1516,1.0,0.0,-1.7380000000000002,0.0,SPX160617C00650000,466\n6180891,SPX,2079.43,SPX160617C00700000,call,2016-06-17,2015-03-09,700.0,0.0,0.0,0.0,0,0,0.1516,1.0,0.0,-1.8717,0.0,SPX160617C00700000,466\n6180964,SPX,2079.43,SPX160617P00650000,put,2016-06-17,2015-03-09,650.0,1.0,0.05,1.3,4,166,0.3282,-0.0004,0.0,-0.4646,3.6304,SPX160617P00650000,466\n6180965,SPX,2079.43,SPX160617P00700000,put,2016-06-17,2015-03-09,700.0,1.4,0.75,3.0,1,1,0.3282,-0.0009,0.0,-0.8866,6.9296,SPX160617P00700000,466\n6186831,SPX,2044.17,SPX160617C00650000,call,2016-06-17,2015-03-10,650.0,0.0,0.0,0.0,0,0,0.136,1.0,0.0,-1.7380000000000002,0.0,SPX160617C00650000,465\n6186832,SPX,2044.17,SPX160617C00700000,call,2016-06-17,2015-03-10,700.0,0.0,0.0,0.0,0,0,0.136,1.0,0.0,-1.8717,0.0,SPX160617C00700000,465\n6186905,SPX,2044.17,SPX160617P00650000,put,2016-06-17,2015-03-10,650.0,1.1,0.05,1.6,1,169,0.3333,-0.0006,0.0,-0.6195,4.7562,SPX160617P00650000,465\n6186906,SPX,2044.17,SPX160617P00700000,put,2016-06-17,2015-03-10,700.0,1.4,0.4,3.2,0,2,0.3333,-0.0012,0.0,-1.1515,8.8437,SPX160617P00700000,465\n6192772,SPX,2040.24,SPX160617C00650000,call,2016-06-17,2015-03-11,650.0,0.0,0.0,0.0,0,0,0.145,1.0,0.0,-1.7380000000000002,0.0,SPX160617C00650000,464\n6192773,SPX,2040.24,SPX160617C00700000,call,2016-06-17,2015-03-11,700.0,0.0,0.0,0.0,0,0,0.145,1.0,0.0,-1.8717,0.0,SPX160617C00700000,464\n6192846,SPX,2040.24,SPX160617P00650000,put,2016-06-17,2015-03-11,650.0,1.1,0.05,1.6,0,168,0.3326,-0.0006,0.0,-0.6097,4.681,SPX160617P00650000,464\n6192847,SPX,2040.24,SPX160617P00700000,put,2016-06-17,2015-03-11,700.0,1.4,0.3,3.1,0,2,0.3326,-0.0011,0.0,-1.1364,8.7273,SPX160617P00700000,464\n6198721,SPX,2065.95,SPX160617C00650000,call,2016-06-17,2015-03-12,650.0,0.0,0.0,0.0,0,0,0.1266,1.0,0.0,-1.7380000000000002,0.0,SPX160617C00650000,463\n6198722,SPX,2065.95,SPX160617C00700000,call,2016-06-17,2015-03-12,700.0,0.0,0.0,0.0,0,0,0.1266,1.0,0.0,-1.8717,0.0,SPX160617C00700000,463\n6198795,SPX,2065.95,SPX160617P00650000,put,2016-06-17,2015-03-12,650.0,1.1,0.05,1.6,0,168,0.3261,-0.0004,0.0,-0.4436,3.466,SPX160617P00650000,463\n6198796,SPX,2065.95,SPX160617P00700000,put,2016-06-17,2015-03-12,700.0,1.4,0.1,2.85,0,2,0.3261,-0.0008,0.0,-0.8535,6.6708,SPX160617P00700000,463\n6204764,SPX,2053.4,SPX160617C00650000,call,2016-06-17,2015-03-13,650.0,0.0,0.0,0.0,0,0,0.1311,1.0,0.0,-1.7380000000000002,0.0,SPX160617C00650000,462\n6204765,SPX,2053.4,SPX160617C00700000,call,2016-06-17,2015-03-13,700.0,0.0,0.0,0.0,0,0,0.1311,1.0,0.0,-1.8717,0.0,SPX160617C00700000,462\n6204838,SPX,2053.4,SPX160617P00650000,put,2016-06-17,2015-03-13,650.0,1.1,0.05,1.6,0,168,0.3302,-0.0005,0.0,-0.5279,4.0648,SPX160617P00650000,462\n6204839,SPX,2053.4,SPX160617P00700000,put,2016-06-17,2015-03-13,700.0,1.4,0.15,2.9,0,2,0.3302,-0.001,0.0,-0.9984,7.6897,SPX160617P00700000,462\n"
  },
  {
    "path": "tests/test_data/test_data_options.csv",
    "content": "underlying,underlying_last, exchange,optionroot,optionext,type,expiration,quotedate,strike,last,bid,ask,volume,openinterest,impliedvol,delta,gamma,theta,vega,optionalias,dte\nSPX,2257.83,*,SPX170317C00300000,,call,2017-03-17,2017-01-03,300,1848.1,1945.3,1950.1,0,12,0.1844,0.9967,0.0,34.053000000000004,0.0,SPX170317C00300000,73\nSPX,2257.83,*,SPX170317P00300000,,put,2017-03-17,2017-01-03,300,0.0,0.0,0.05,0,0,0.2518,0.0,0.0,0.0,0.0,SPX170317P00300000,73\nSPX,2270.75,*,SPX170317C00300000,,call,2017-03-17,2017-01-04,300,1848.1,1956.9,1961.6,0,12,0.1504,0.9968,0.0,34.2057,0.0,SPX170317C00300000,72\nSPX,2270.75,*,SPX170317P00300000,,put,2017-03-17,2017-01-04,300,0.0,0.0,0.25,0,0,0.2327,0.0,0.0,0.0,0.0,SPX170317P00300000,72\nSPX,2269.0,*,SPX170317C00300000,,call,2017-03-17,2017-01-05,300,1848.1,1956.3,1961.0,0,12,0.1196,0.9984,0.0,15.3892,0.0,SPX170317C00300000,71\nSPX,2269.0,*,SPX170317P00300000,,put,2017-03-17,2017-01-05,300,0.0,0.0,0.25,0,0,0.2345,0.0,0.0,0.0,0.0,SPX170317P00300000,71\nSPX,2276.98,*,SPX170317C00300000,,call,2017-03-17,2017-01-06,300,1848.1,1966.0,1970.8,0,12,0.1713,0.997,0.0,32.851,0.0,SPX170317C00300000,70\nSPX,2276.98,*,SPX170317P00300000,,put,2017-03-17,2017-01-06,300,0.0,0.0,0.25,0,0,0.2256,0.0,0.0,0.0,0.0,SPX170317P00300000,70\nSPX,2276.98,*,SPX170421C00500000,,call,2017-04-21,2017-01-06,500,0.0,1763.4,1768.3,0,0,0.1168,1.0,0.0,-5.107,0.0,SPX170421C00500000,105\nSPX,2276.98,*,SPX170421P01375000,,put,2017-04-21,2017-01-06,1375,1.05,0.3,0.65,0,323,0.2537,-0.0001,0.0,-0.1513,0.3447,SPX170421P01375000,105\nSPX,2268.9,*,SPX170317C00300000,,call,2017-03-17,2017-01-09,300,1848.1,1959.0,1963.8,0,12,0.1794,0.9973,0.0,31.0589,0.0,SPX170317C00300000,67\nSPX,2268.9,*,SPX170317P00300000,,put,2017-03-17,2017-01-09,300,0.0,0.0,0.25,0,0,0.2334,0.0,0.0,0.0,0.0,SPX170317P00300000,67\nSPX,2268.9,*,SPX170317C00300000,,call,2017-03-17,2017-01-10,300,1848.1,1958.2,1962.9,0,12,0.1567,0.9974,0.0,29.4374,0.0,SPX170317C00300000,66\nSPX,2268.9,*,SPX170317P00300000,,put,2017-03-17,2017-01-10,300,0.0,0.0,0.05,0,0,0.2254,0.0,0.0,0.0,0.0,SPX170317P00300000,66\nSPX,2275.32,*,SPX170317C00300000,,call,2017-03-17,2017-01-11,300,1848.1,1965.1,1969.9,0,12,0.155,0.9973,0.0,31.1581,0.0,SPX170317C00300000,65\nSPX,2275.32,*,SPX170317P00300000,,put,2017-03-17,2017-01-11,300,0.0,0.0,0.25,0,0,0.2203,0.0,0.0,0.0,0.0,SPX170317P00300000,65\nSPX,2275.32,*,SPX170421C00500000,,call,2017-04-21,2017-01-11,500,0.0,1762.8,1767.6,0,0,0.1318,0.9986,0.0,6.4268,0.0,SPX170421C00500000,100\nSPX,2275.32,*,SPX170421P01375000,,put,2017-04-21,2017-01-11,1375,1.05,0.25,0.55,0,323,0.2493,0.0,0.0,-0.0886,0.1946,SPX170421P01375000,100\nSPX,2270.44,*,SPX170317C00300000,,call,2017-03-17,2017-01-12,300,1848.1,1959.0,1963.7,0,12,0.1288,0.9975,0.0,29.4619,0.0,SPX170317C00300000,64\nSPX,2270.44,*,SPX170317P00300000,,put,2017-03-17,2017-01-12,300,0.0,0.0,0.15,0,0,0.2264,0.0,0.0,0.0,0.0,SPX170317P00300000,64\nSPX,2270.44,*,SPX170421C00500000,,call,2017-04-21,2017-01-12,500,0.0,1756.8,1761.7,0,0,0.1479,0.9973,0.0,17.8806,0.0,SPX170421C00500000,99\nSPX,2270.44,*,SPX170421P01375000,,put,2017-04-21,2017-01-12,1375,0.55,0.25,0.75,9,323,0.2496,0.0,0.0,-0.0938,0.2027,SPX170421P01375000,99\nSPX,2274.64,*,SPX170317C00300000,,call,2017-03-17,2017-01-13,300,1848.1,1964.1,1968.8,0,12,0.126,0.9988,0.0,13.2477,0.0,SPX170317C00300000,63\nSPX,2274.64,*,SPX170317P00300000,,put,2017-03-17,2017-01-13,300,0.0,0.0,0.25,0,0,0.2327,0.0,0.0,0.0,0.0,SPX170317P00300000,63\nSPX,2274.64,*,SPX170421C00500000,,call,2017-04-21,2017-01-13,500,0.0,1761.9,1766.7,0,0,0.1418,0.9986,0.0,6.4234,0.0,SPX170421C00500000,98\nSPX,2274.64,*,SPX170421P01375000,,put,2017-04-21,2017-01-13,1375,0.55,0.3,0.8,0,323,0.2549,0.0,0.0,-0.1106,0.2327,SPX170421P01375000,98\nSPX,2267.89,*,SPX170317C00300000,,call,2017-03-17,2017-01-17,300,1848.1,1956.9,1961.6,0,12,0.0964,1.0,0.0,-3.0682,0.0,SPX170317C00300000,59\nSPX,2267.89,*,SPX170317P00300000,,put,2017-03-17,2017-01-17,300,0.0,0.0,0.1,0,0,0.248,0.0,0.0,0.0,0.0,SPX170317P00300000,59\nSPX,2267.89,*,SPX170421C00500000,,call,2017-04-21,2017-01-17,500,0.0,1754.6,1759.4,0,0,0.1157,1.0,0.0,-5.1086,0.0,SPX170421C00500000,94\nSPX,2267.89,*,SPX170421P01375000,,put,2017-04-21,2017-01-17,1375,0.55,0.3,0.8,0,323,0.2672,-0.0001,0.0,-0.1796,0.3472,SPX170421P01375000,94\nSPX,2271.89,*,SPX170317C00300000,,call,2017-03-17,2017-01-18,300,1848.1,1960.9,1965.6,0,12,0.1017,1.0,0.0,-3.0683,0.0,SPX170317C00300000,58\nSPX,2271.89,*,SPX170317P00300000,,put,2017-03-17,2017-01-18,300,0.0,0.0,0.1,0,0,0.2524,0.0,0.0,0.0,0.0,SPX170317P00300000,58\nSPX,2271.89,*,SPX170421C00500000,,call,2017-04-21,2017-01-18,500,0.0,1758.7,1763.5,0,0,0.1217,1.0,0.0,-5.1087,0.0,SPX170421C00500000,93\nSPX,2271.89,*,SPX170421P01375000,,put,2017-04-21,2017-01-18,1375,0.6,0.3,0.6,3,323,0.2701,-0.0001,0.0,-0.1878,0.3552,SPX170421P01375000,93\nSPX,2263.69,*,SPX170317C00300000,,call,2017-03-17,2017-01-19,300,1848.1,1954.1,1958.8,0,12,0.1187,1.0,0.0,-3.0683,0.0,SPX170317C00300000,57\nSPX,2263.69,*,SPX170317P00300000,,put,2017-03-17,2017-01-19,300,0.0,0.0,0.1,0,0,0.2537,0.0,0.0,0.0,0.0,SPX170317P00300000,57\nSPX,2263.69,*,SPX170421C00500000,,call,2017-04-21,2017-01-19,500,0.0,1751.7,1756.5,0,0,0.1221,1.0,0.0,-5.1089,0.0,SPX170421C00500000,92\nSPX,2263.69,*,SPX170421P01375000,,put,2017-04-21,2017-01-19,1375,0.6,0.35,0.85,0,323,0.2703,-0.0001,0.0,-0.1953,0.3652,SPX170421P01375000,92\nSPX,2271.31,*,SPX170317C00300000,,call,2017-03-17,2017-01-20,300,1848.1,1960.0,1964.7,0,12,0.0974,1.0,0.0,-3.0684,0.0,SPX170317C00300000,56\nSPX,2271.31,*,SPX170317P00300000,,put,2017-03-17,2017-01-20,300,0.0,0.0,0.1,0,0,0.2345,0.0,0.0,0.0,0.0,SPX170317P00300000,56\nSPX,2271.31,*,SPX170421C00500000,,call,2017-04-21,2017-01-20,500,1763.9,1757.7,1762.5,5,0,0.1069,1.0,0.0,-5.109,0.0,SPX170421C00500000,91\nSPX,2271.31,*,SPX170421P01375000,,put,2017-04-21,2017-01-20,1375,0.6,0.2,0.75,0,323,0.2616,0.0,0.0,-0.1,0.191,SPX170421P01375000,91\nSPX,2265.2,*,SPX170317C00300000,,call,2017-03-17,2017-01-23,300,1848.1,1956.2,1960.7,0,12,0.1105,1.0,0.0,-3.0687,0.0,SPX170317C00300000,53\nSPX,2265.2,*,SPX170317P00300000,,put,2017-03-17,2017-01-23,300,0.0,0.0,0.1,0,0,0.2404,0.0,0.0,0.0,0.0,SPX170317P00300000,53\nSPX,2265.2,*,SPX170421C00500000,,call,2017-04-21,2017-01-23,500,1753.55,1754.1,1758.7,1,5,0.1182,1.0,0.0,-5.1095,0.0,SPX170421C00500000,88\nSPX,2265.2,*,SPX170421P01375000,,put,2017-04-21,2017-01-23,1375,0.6,0.2,0.7,0,323,0.2583,0.0,0.0,-0.0693,0.1295,SPX170421P01375000,88\nSPX,2265.2,*,SPX170519P01650000,,put,2017-05-19,2017-01-23,1650,3.0,2.6,3.0,50,0,0.2758,-0.0161,0.0001,-21.9448,51.1889,SPX170519P01650000,116\nSPX,2280.07,*,SPX170317C00300000,,call,2017-03-17,2017-01-24,300,1848.1,1969.5,1974.0,0,12,0.0851,1.0,0.0,-3.0688,0.0,SPX170317C00300000,52\nSPX,2280.07,*,SPX170317P00300000,,put,2017-03-17,2017-01-24,300,0.0,0.0,0.05,0,0,0.2232,0.0,0.0,0.0,0.0,SPX170317P00300000,52\nSPX,2280.07,*,SPX170421C00500000,,call,2017-04-21,2017-01-24,500,1774.8,1767.1,1771.7,3,6,0.099,1.0,0.0,-5.1096,0.0,SPX170421C00500000,87\nSPX,2280.07,*,SPX170421P01375000,,put,2017-04-21,2017-01-24,1375,0.6,0.1,0.5,0,323,0.2425,0.0,0.0,-0.0162,0.0319,SPX170421P01375000,87\nSPX,2280.07,*,SPX170519P01650000,,put,2017-05-19,2017-01-24,1650,2.49,2.0,2.55,53,50,0.2589,-0.01,0.0001,-13.7994,33.9853,SPX170519P01650000,115\nSPX,2298.37,*,SPX170317C00300000,,call,2017-03-17,2017-01-25,300,1848.1,1988.4,1992.9,0,12,0.0971,0.9990000000000001,0.0,12.8855,0.0,SPX170317C00300000,51\nSPX,2298.37,*,SPX170317P00300000,,put,2017-03-17,2017-01-25,300,0.0,0.0,0.05,0,0,0.215,0.0,0.0,0.0,0.0,SPX170317P00300000,51\nSPX,2298.37,*,SPX170421C00500000,,call,2017-04-21,2017-01-25,500,1788.6,1786.2,1790.7,3,9,0.1111,0.9988,0.0,7.0342,0.0,SPX170421C00500000,86\nSPX,2298.37,*,SPX170421P01375000,,put,2017-04-21,2017-01-25,1375,0.6,0.05,0.6,0,323,0.2366,0.0,0.0,-0.0069,0.0136,SPX170421P01375000,86\nSPX,2298.37,*,SPX170519P01650000,,put,2017-05-19,2017-01-25,1650,2.0,1.7,2.2,103,103,0.2521,-0.0071,0.0001,-10.084,25.2716,SPX170519P01650000,114\nSPX,2296.68,*,SPX170317C00300000,,call,2017-03-17,2017-01-26,300,1848.1,1987.8,1992.2,0,12,0.0827,1.0,0.0,-3.0689,0.0,SPX170317C00300000,50\nSPX,2296.68,*,SPX170317P00300000,,put,2017-03-17,2017-01-26,300,0.0,0.0,0.05,0,0,0.2143,0.0,0.0,0.0,0.0,SPX170317P00300000,50\nSPX,2296.68,*,SPX170421C00500000,,call,2017-04-21,2017-01-26,500,1785.75,1785.9,1790.4,1,12,0.10800000000000001,1.0,0.0,-5.1099,0.0,SPX170421C00500000,85\nSPX,2296.68,*,SPX170421P01375000,,put,2017-04-21,2017-01-26,1375,0.6,0.1,0.5,0,323,0.2348,0.0,0.0,-0.0051,0.0101,SPX170421P01375000,85\nSPX,2296.68,*,SPX170519P01650000,,put,2017-05-19,2017-01-26,1650,2.0,1.75,2.25,0,149,0.2865,-0.0146,0.0001,-21.62,47.2325,SPX170519P01650000,113\nSPX,2294.69,*,SPX170317C00300000,,call,2017-03-17,2017-01-27,300,1848.1,1984.2,1988.6,0,12,0.0794,1.0,0.0,-3.069,0.0,SPX170317C00300000,49\nSPX,2294.69,*,SPX170317P00300000,,put,2017-03-17,2017-01-27,300,0.0,0.0,0.05,0,0,0.2154,0.0,0.0,0.0,0.0,SPX170317P00300000,49\nSPX,2294.69,*,SPX170421C00500000,,call,2017-04-21,2017-01-27,500,1785.75,1782.0,1786.5,0,13,0.0965,1.0,0.0,-5.11,0.0,SPX170421C00500000,84\nSPX,2294.69,*,SPX170421P01375000,,put,2017-04-21,2017-01-27,1375,0.6,0.05,0.6,0,323,0.2353,0.0,0.0,-0.0049,0.0096,SPX170421P01375000,84\nSPX,2294.69,*,SPX170519P01650000,,put,2017-05-19,2017-01-27,1650,1.93,1.75,2.25,81,148,0.2871,-0.0147,0.0001,-21.7852,47.0662,SPX170519P01650000,112\nSPX,2280.9,*,SPX170317C00300000,,call,2017-03-17,2017-01-30,300,1848.1,1970.4,1973.7,0,12,0.0863,1.0,0.0,-3.0693,0.0,SPX170317C00300000,46\nSPX,2280.9,*,SPX170317P00300000,,put,2017-03-17,2017-01-30,300,0.0,0.0,0.05,0,0,0.2298,0.0,0.0,0.0,0.0,SPX170317P00300000,46\nSPX,2280.9,*,SPX170421C00500000,,call,2017-04-21,2017-01-30,500,1761.1,1768.1,1771.5,1,13,0.1063,1.0,0.0,-5.1105,0.0,SPX170421C00500000,81\nSPX,2280.9,*,SPX170421P01375000,,put,2017-04-21,2017-01-30,1375,0.6,0.15,0.65,0,323,0.2494,0.0,0.0,-0.0148,0.0263,SPX170421P01375000,81\nSPX,2280.9,*,SPX170519P01650000,,put,2017-05-19,2017-01-30,1650,1.93,2.1,2.45,0,181,0.2649,-0.0097,0.0001,-14.1825,32.3163,SPX170519P01650000,109\nSPX,2278.87,*,SPX170317C00300000,,call,2017-03-17,2017-01-31,300,1965.58,1971.0,1975.3,500,12,0.10800000000000001,1.0,0.0,-3.0694,0.0,SPX170317C00300000,45\nSPX,2278.87,*,SPX170317P00300000,,put,2017-03-17,2017-01-31,300,0.03,0.0,0.05,500,0,0.2335,0.0,0.0,0.0,0.0,SPX170317P00300000,45\nSPX,2278.87,*,SPX170421C00500000,,call,2017-04-21,2017-01-31,500,1761.1,1768.6,1773.0,0,14,0.1174,1.0,0.0,-5.1106,0.0,SPX170421C00500000,80\nSPX,2278.87,*,SPX170421P01375000,,put,2017-04-21,2017-01-31,1375,0.6,0.15,0.6,0,323,0.2442,0.0,0.0,-0.0089,0.0159,SPX170421P01375000,80\nSPX,2278.87,*,SPX170519P01650000,,put,2017-05-19,2017-01-31,1650,1.93,1.85,2.5,0,181,0.2909,-0.016,0.0001,-24.044,49.4101,SPX170519P01650000,108\nSPX,2279.55,*,SPX170317C00300000,,call,2017-03-17,2017-02-01,300,1965.58,1970.6,1975.0,0,512,0.092,1.0,0.0,-3.1299,0.0,SPX170317C00300000,44\nSPX,2279.55,*,SPX170317P00300000,,put,2017-03-17,2017-02-01,300,0.03,0.0,0.05,0,500,0.2289,0.0,0.0,0.0,0.0,SPX170317P00300000,44\nSPX,2279.55,*,SPX170421C00500000,,call,2017-04-21,2017-02-01,500,1761.1,1768.1,1772.6,0,14,0.1039,1.0,0.0,-5.2113,0.0,SPX170421C00500000,79\nSPX,2279.55,*,SPX170421P01375000,,put,2017-04-21,2017-02-01,1375,0.6,0.05,0.6,0,323,0.2445,0.0,0.0,-0.008,0.0141,SPX170421P01375000,79\nSPX,2279.55,*,SPX170519P01650000,,put,2017-05-19,2017-02-01,1650,2.05,1.7,2.05,661,181,0.2863,-0.0143,0.0001,-21.635,44.763000000000005,SPX170519P01650000,107\nSPX,2280.85,*,SPX170317C00300000,,call,2017-03-17,2017-02-02,300,1965.58,1971.6,1976.0,0,512,0.0923,1.0,0.0,-3.13,0.0,SPX170317C00300000,43\nSPX,2280.85,*,SPX170317P00300000,,put,2017-03-17,2017-02-02,300,0.03,0.0,0.05,0,500,0.2333,0.0,0.0,0.0,0.0,SPX170317P00300000,43\nSPX,2280.85,*,SPX170421C00500000,,call,2017-04-21,2017-02-02,500,1761.1,1769.2,1773.7,0,14,0.1008,1.0,0.0,-5.2115,0.0,SPX170421C00500000,78\nSPX,2280.85,*,SPX170421P01375000,,put,2017-04-21,2017-02-02,1375,0.6,0.1,0.6,0,323,0.2491,0.0,0.0,-0.0102,0.0175,SPX170421P01375000,78\nSPX,2280.85,*,SPX170519P01650000,,put,2017-05-19,2017-02-02,1650,2.1,1.8,2.4,1730,869,0.2666,-0.0093,0.0001,-13.9274,30.6559,SPX170519P01650000,106\nSPX,2297.42,*,SPX170317C00300000,,call,2017-03-17,2017-02-03,300,1965.58,1987.6,1991.3,0,512,0.0759,1.0,0.0,-3.1301,0.0,SPX170317C00300000,42\nSPX,2297.42,*,SPX170317P00300000,,put,2017-03-17,2017-02-03,300,0.03,0.0,0.05,0,500,0.2059,0.0,0.0,0.0,0.0,SPX170317P00300000,42\nSPX,2297.42,*,SPX170421C00500000,,call,2017-04-21,2017-02-03,500,1761.1,1785.2,1789.0,0,14,0.0946,1.0,0.0,-5.2116,0.0,SPX170421C00500000,77\nSPX,2297.42,*,SPX170421P01375000,,put,2017-04-21,2017-02-03,1375,0.6,0.05,0.5,0,323,0.2383,0.0,0.0,-0.0025,0.0045,SPX170421P01375000,77\nSPX,2297.42,*,SPX170519P01650000,,put,2017-05-19,2017-02-03,1650,2.1,1.45,2.0,0,2419,0.2521,-0.0055,0.0001,-8.3296,19.1995,SPX170519P01650000,105\nSPX,2292.56,*,SPX170317C00300000,,call,2017-03-17,2017-02-06,300,1965.58,1983.5,1987.3,0,512,0.0997,0.9988,0.0,22.4028,0.0,SPX170317C00300000,39\nSPX,2292.56,*,SPX170317P00300000,,put,2017-03-17,2017-02-06,300,0.03,0.0,0.05,0,500,0.2172,0.0,0.0,0.0,0.0,SPX170317P00300000,39\nSPX,2292.56,*,SPX170421C00500000,,call,2017-04-21,2017-02-06,500,1761.1,1781.0,1784.9,0,14,0.1275,0.9983,0.0,14.4364,0.0,SPX170421C00500000,74\nSPX,2292.56,*,SPX170421P01375000,,put,2017-04-21,2017-02-06,1375,0.6,0.05,0.3,0,323,0.2385,0.0,0.0,-0.002,0.0034,SPX170421P01375000,74\nSPX,2292.56,*,SPX170519P01650000,,put,2017-05-19,2017-02-06,1650,1.71,1.4,2.0,92,2419,0.2908,-0.0131,0.0001,-21.2235,40.5687,SPX170519P01650000,102\nSPX,2293.08,*,SPX170317C00300000,,call,2017-03-17,2017-02-07,300,1965.58,1983.9,1988.3,0,512,0.1345,0.9977,0.0,47.8907,0.0,SPX170317C00300000,38\nSPX,2293.08,*,SPX170317P00300000,,put,2017-03-17,2017-02-07,300,0.03,0.0,0.05,0,500,0.2188,0.0,0.0,0.0,0.0,SPX170317P00300000,38\nSPX,2293.08,*,SPX170421C00500000,,call,2017-04-21,2017-02-07,500,1761.1,1781.4,1785.9,0,14,0.1642,0.9966,0.0,34.0277,0.0,SPX170421C00500000,73\nSPX,2293.08,*,SPX170421P01375000,,put,2017-04-21,2017-02-07,1375,0.6,0.05,0.3,0,323,0.2399,0.0,0.0,-0.0021,0.0035,SPX170421P01375000,73\nSPX,2293.08,*,SPX170519P01650000,,put,2017-05-19,2017-02-07,1650,1.7,1.45,2.05,1,2419,0.2916,-0.0134,0.0001,-22.2421,41.3843,SPX170519P01650000,101\nSPX,2294.67,*,SPX170317C00300000,,call,2017-03-17,2017-02-08,300,1965.58,1986.6,1990.7,0,512,0.1123,0.9988,0.0,25.0759,0.0,SPX170317C00300000,37\nSPX,2294.67,*,SPX170317P00300000,,put,2017-03-17,2017-02-08,300,0.03,0.0,0.05,0,500,0.2203,0.0,0.0,0.0,0.0,SPX170317P00300000,37\nSPX,2294.67,*,SPX170421C00500000,,call,2017-04-21,2017-02-08,500,1761.1,1784.2,1788.3,0,14,0.1436,0.9982,0.0,16.1501,0.0,SPX170421C00500000,72\nSPX,2294.67,*,SPX170421P01375000,,put,2017-04-21,2017-02-08,1375,0.6,0.05,0.3,0,323,0.243,0.0,0.0,-0.0022,0.0036,SPX170421P01375000,72\nSPX,2294.67,*,SPX170519P01650000,,put,2017-05-19,2017-02-08,1650,1.79,1.7,2.0,1244,2420,0.2983,-0.013999999999999999,0.0001,-23.3409,42.6189,SPX170519P01650000,100\nSPX,2307.87,*,SPX170317C00300000,,call,2017-03-17,2017-02-09,300,1965.58,1999.3,2002.6,0,512,0.1373,0.9976,0.0,54.336000000000006,0.0,SPX170317C00300000,36\nSPX,2307.87,*,SPX170317P00300000,,put,2017-03-17,2017-02-09,300,0.03,0.0,0.05,0,500,0.2056,0.0,0.0,0.0,0.0,SPX170317P00300000,36\nSPX,2307.87,*,SPX170421C00500000,,call,2017-04-21,2017-02-09,500,1761.1,1796.9,1800.2,0,14,0.1766,0.9964,0.0,38.1266,0.0,SPX170421C00500000,71\nSPX,2307.87,*,SPX170421P01375000,,put,2017-04-21,2017-02-09,1375,0.6,0.05,0.5,0,323,0.2302,0.0,0.0,-0.0004,0.0007,SPX170421P01375000,71\nSPX,2307.87,*,SPX170519P01650000,,put,2017-05-19,2017-02-09,1650,1.55,1.15,1.7,28,3451,0.2431,-0.0035,0.0,-5.6932,12.435,SPX170519P01650000,99\nSPX,2316.1,*,SPX170317C00300000,,call,2017-03-17,2017-02-10,300,1965.58,2008.1,2012.4,0,512,0.1599,0.9976,0.0,55.3427,0.0,SPX170317C00300000,35\nSPX,2316.1,*,SPX170317P00300000,,put,2017-03-17,2017-02-10,300,0.03,0.0,0.05,0,500,0.2014,0.0,0.0,0.0,0.0,SPX170317P00300000,35\nSPX,2316.1,*,SPX170421C00500000,,call,2017-04-21,2017-02-10,500,1761.1,1805.5,1810.0,0,14,0.195,0.9964,0.0,38.7292,0.0,SPX170421C00500000,70\nSPX,2316.1,*,SPX170421P01375000,,put,2017-04-21,2017-02-10,1375,0.6,0.05,0.5,0,323,0.2238,0.0,0.0,-0.0001,0.0002,SPX170421P01375000,70\nSPX,2316.1,*,SPX170519P01650000,,put,2017-05-19,2017-02-10,1650,1.33,0.95,1.6,78,3451,0.2436,-0.0031,0.0,-5.2182,11.2583,SPX170519P01650000,98\nSPX,2328.25,*,SPX170317C00300000,,call,2017-03-17,2017-02-13,300,1965.58,2022.3,2025.6,0,512,0.2083,0.9978,0.0,57.5402,0.0,SPX170317C00300000,32\nSPX,2328.25,*,SPX170317P00300000,,put,2017-03-17,2017-02-13,300,0.03,0.0,0.05,0,500,0.198,0.0,0.0,0.0,0.0,SPX170317P00300000,32\nSPX,2328.25,*,SPX170421C00500000,,call,2017-04-21,2017-02-13,500,1761.1,1819.8,1823.2,0,14,0.2292,0.9964,0.0,40.4398,0.0,SPX170421C00500000,67\nSPX,2328.25,*,SPX170421P01375000,,put,2017-04-21,2017-02-13,1375,0.6,0.05,0.45,0,323,0.2189,0.0,0.0,0.0,0.0,SPX170421P01375000,67\nSPX,2328.25,*,SPX170519P01650000,,put,2017-05-19,2017-02-13,1650,1.2,0.95,1.55,118,3529,0.2369,-0.0019,0.0,-3.3143,7.1229,SPX170519P01650000,95\nSPX,2337.58,*,SPX170317C00300000,,call,2017-03-17,2017-02-14,300,1965.58,2032.3,2034.7,0,512,0.2235,0.9978,0.0,58.6296,0.0,SPX170317C00300000,31\nSPX,2337.58,*,SPX170317P00300000,,put,2017-03-17,2017-02-14,300,0.03,0.0,0.05,0,500,0.195,0.0,0.0,0.0,0.0,SPX170317P00300000,31\nSPX,2337.58,*,SPX170421C00500000,,call,2017-04-21,2017-02-14,500,1761.1,1829.9,1832.3,0,14,0.1042,0.9964,0.0,41.2313,0.0,SPX170421C00500000,66\nSPX,2337.58,*,SPX170421P01375000,,put,2017-04-21,2017-02-14,1375,0.6,0.05,0.5,0,323,0.2122,0.0,0.0,0.0,0.0,SPX170421P01375000,66\nSPX,2337.58,*,SPX170519P01650000,,put,2017-05-19,2017-02-14,1650,1.0,0.95,1.35,106,3625,0.2248,-0.001,0.0,-1.7456,3.9102,SPX170519P01650000,94\nSPX,2349.25,*,SPX170317C00300000,,call,2017-03-17,2017-02-15,300,1965.58,2044.5,2048.9,0,512,0.1601,0.9979,0.0,58.9423,0.0,SPX170317C00300000,30\nSPX,2349.25,*,SPX170317P00300000,,put,2017-03-17,2017-02-15,300,0.03,0.0,0.05,0,500,0.2138,0.0,0.0,0.0,0.0,SPX170317P00300000,30\nSPX,2349.25,*,SPX170421C00500000,,call,2017-04-21,2017-02-15,500,1761.1,1842.3,1846.8,0,14,0.1177,0.9965,0.0,41.5107,0.0,SPX170421C00500000,65\nSPX,2349.25,*,SPX170421P01375000,,put,2017-04-21,2017-02-15,1375,0.6,0.05,0.5,0,323,0.2234,0.0,0.0,0.0,0.0,SPX170421P01375000,65\nSPX,2349.25,*,SPX170519P01650000,,put,2017-05-19,2017-02-15,1650,1.15,1.1,1.45,186,3681,0.2323,-0.0011,0.0,-2.0459,4.3894,SPX170519P01650000,93\nSPX,2347.22,*,SPX170317C00300000,,call,2017-03-17,2017-02-16,300,1965.58,2042.2,2046.5,0,512,0.1027,0.9979,0.0,58.8931,0.0,SPX170317C00300000,29\nSPX,2347.22,*,SPX170317P00300000,,put,2017-03-17,2017-02-16,300,0.03,0.0,0.05,0,500,0.2098,0.0,0.0,0.0,0.0,SPX170317P00300000,29\nSPX,2347.22,*,SPX170421C00500000,,call,2017-04-21,2017-02-16,500,1761.1,1839.8,1844.2,0,14,0.0939,0.9965,0.0,41.5177,0.0,SPX170421C00500000,64\nSPX,2347.22,*,SPX170421P01375000,,put,2017-04-21,2017-02-16,1375,0.6,0.05,0.35,0,323,0.2246,0.0,0.0,0.0,0.0,SPX170421P01375000,64\nSPX,2347.22,*,SPX170519P01650000,,put,2017-05-19,2017-02-16,1650,1.15,0.95,1.45,0,3731,0.307,-0.0093,0.0001,-18.215999999999998,29.2877,SPX170519P01650000,92\nSPX,2351.16,*,SPX170317C00300000,,call,2017-03-17,2017-02-17,300,1965.58,2043.8,2048.1,0,512,0.1476,0.998,0.0,59.0016,0.0,SPX170317C00300000,28\nSPX,2351.16,*,SPX170317P00300000,,put,2017-03-17,2017-02-17,300,0.03,0.0,0.05,0,500,0.2134,0.0,0.0,0.0,0.0,SPX170317P00300000,28\nSPX,2351.16,*,SPX170421C00500000,,call,2017-04-21,2017-02-17,500,1761.1,1841.4,1845.8,0,14,0.1957,0.9966,0.0,41.5535,0.0,SPX170421C00500000,63\nSPX,2351.16,*,SPX170421P01375000,,put,2017-04-21,2017-02-17,1375,0.6,0.05,0.5,0,323,0.2227,0.0,0.0,0.0,0.0,SPX170421P01375000,63\nSPX,2351.16,*,SPX170519P01650000,,put,2017-05-19,2017-02-17,1650,1.05,0.95,1.35,53,3731,0.2356,-0.0011,0.0,-2.1104,4.3692,SPX170519P01650000,91\nSPX,2365.38,*,SPX170317C00300000,,call,2017-03-17,2017-02-21,300,1965.58,2058.8,2063.1,0,512,0.1259,0.9983,0.0,58.1577,0.0,SPX170317C00300000,24\nSPX,2365.38,*,SPX170317P00300000,,put,2017-03-17,2017-02-21,300,0.03,0.0,0.05,0,500,0.2047,0.0,0.0,0.0,0.0,SPX170317P00300000,24\nSPX,2365.38,*,SPX170421C00500000,,call,2017-04-21,2017-02-21,500,1761.1,1856.3,1860.7,0,14,0.1953,0.9969,0.0,40.4984,0.0,SPX170421C00500000,59\nSPX,2365.38,*,SPX170421P01375000,,put,2017-04-21,2017-02-21,1375,0.6,0.05,0.5,0,323,0.2171,0.0,0.0,0.0,0.0,SPX170421P01375000,59\nSPX,2365.38,*,SPX170519C01000000,,call,2017-05-19,2017-02-21,1000,0.0,1353.4,1358.1,0,0,0.2273,0.9948,0.0,41.3957,0.0,SPX170519C01000000,87\nSPX,2365.38,*,SPX170519P01650000,,put,2017-05-19,2017-02-21,1650,0.9,0.65,1.1,2430,3752,0.239,-0.0009,0.0,-1.7264,3.372,SPX170519P01650000,87\nSPX,2362.82,*,SPX170317C00300000,,call,2017-03-17,2017-02-22,300,1965.58,2056.3,2060.3,0,512,0.1336,0.9984,0.0,57.7292,0.0,SPX170317C00300000,23\nSPX,2362.82,*,SPX170317P00300000,,put,2017-03-17,2017-02-22,300,0.03,0.0,0.05,0,500,0.1876,0.0,0.0,0.0,0.0,SPX170317P00300000,23\nSPX,2362.82,*,SPX170421C00500000,,call,2017-04-21,2017-02-22,500,1761.1,1854.0,1858.4,0,14,0.1906,0.997,0.0,40.141,0.0,SPX170421C00500000,58\nSPX,2362.82,*,SPX170421P01375000,,put,2017-04-21,2017-02-22,1375,0.6,0.05,0.5,0,323,0.2268,0.0,0.0,0.0,0.0,SPX170421P01375000,58\nSPX,2362.82,*,SPX170519C01000000,,call,2017-05-19,2017-02-22,1000,0.0,1351.0,1355.7,0,0,0.2331,0.9949,0.0,41.0863,0.0,SPX170519C01000000,86\nSPX,2362.82,*,SPX170519P01650000,,put,2017-05-19,2017-02-22,1650,0.9,0.7,0.95,21,2542,0.2418,-0.0009,0.0,-1.9046,3.6352,SPX170519P01650000,86\nSPX,2363.81,*,SPX170317C00300000,,call,2017-03-17,2017-02-23,300,1965.58,2059.2,2062.0,0,512,0.0941,1.0,0.0,-3.1319,0.0,SPX170317C00300000,22\nSPX,2363.81,*,SPX170317P00300000,,put,2017-03-17,2017-02-23,300,0.03,0.0,0.05,0,500,0.1946,0.0,0.0,0.0,0.0,SPX170317P00300000,22\nSPX,2363.81,*,SPX170421C00500000,,call,2017-04-21,2017-02-23,500,1761.1,1856.3,1860.4,0,14,0.1064,1.0,0.0,-5.2146,0.0,SPX170421C00500000,57\nSPX,2363.81,*,SPX170421P01375000,,put,2017-04-21,2017-02-23,1375,0.6,0.05,0.35,0,323,0.2285,0.0,0.0,0.0,0.0,SPX170421P01375000,57\nSPX,2363.81,*,SPX170519C01000000,,call,2017-05-19,2017-02-23,1000,0.0,1353.2,1357.3,0,0,0.1092,1.0,0.0,-10.4208,0.0,SPX170519C01000000,85\nSPX,2363.81,*,SPX170519P01650000,,put,2017-05-19,2017-02-23,1650,0.9,0.6,1.1,9,2542,0.2482,-0.001,0.0,-2.0109,3.7910000000000004,SPX170519P01650000,85\nSPX,2367.34,*,SPX170317C00300000,,call,2017-03-17,2017-02-24,300,1965.58,2060.9,2065.6,0,512,0.0861,1.0,0.0,-3.1319999999999997,0.0,SPX170317C00300000,21\nSPX,2367.34,*,SPX170317P00300000,,put,2017-03-17,2017-02-24,300,0.03,0.0,0.05,0,500,0.2052,0.0,0.0,0.0,0.0,SPX170317P00300000,21\nSPX,2367.34,*,SPX170421C00500000,,call,2017-04-21,2017-02-24,500,1761.1,1858.7,1863.5,0,14,0.0937,1.0,0.0,-5.2147,0.0,SPX170421C00500000,56\nSPX,2367.34,*,SPX170421P01375000,,put,2017-04-21,2017-02-24,1375,0.15,0.05,0.45,1,323,0.2252,0.0,0.0,0.0,0.0,SPX170421P01375000,56\nSPX,2367.34,*,SPX170519C01000000,,call,2017-05-19,2017-02-24,1000,0.0,1355.3,1360.4,0,0,0.1164,1.0,0.0,-10.4211,0.0,SPX170519C01000000,84\nSPX,2367.34,*,SPX170519P01650000,,put,2017-05-19,2017-02-24,1650,0.85,0.6,1.1,1677,2543,0.2453,-0.0008,0.0,-1.631,3.0737,SPX170519P01650000,84\nSPX,2369.75,*,SPX170317C00300000,,call,2017-03-17,2017-02-27,300,1965.58,2065.8,2068.5,0,512,0.1011,1.0,0.0,-3.1322,0.0,SPX170317C00300000,18\nSPX,2369.75,*,SPX170317P00300000,,put,2017-03-17,2017-02-27,300,0.03,0.0,0.05,0,500,0.2152,0.0,0.0,0.0,0.0,SPX170317P00300000,18\nSPX,2369.75,*,SPX170421C00500000,,call,2017-04-21,2017-02-27,500,1761.1,1862.9,1866.9,0,14,0.1076,1.0,0.0,-5.2152,0.0,SPX170421C00500000,53\nSPX,2369.75,*,SPX170421P01375000,,put,2017-04-21,2017-02-27,1375,0.15,0.05,0.45,0,324,0.2292,0.0,0.0,0.0,0.0,SPX170421P01375000,53\nSPX,2369.75,*,SPX170519C01000000,,call,2017-05-19,2017-02-27,1000,0.0,1359.8,1363.7,0,0,0.1186,1.0,0.0,-10.422,0.0,SPX170519C01000000,81\nSPX,2369.75,*,SPX170519P01650000,,put,2017-05-19,2017-02-27,1650,0.7,0.5,1.05,30,4179,0.2435,-0.0006,0.0,-1.2483,2.2836,SPX170519P01650000,81\nSPX,2363.64,*,SPX170317C00300000,,call,2017-03-17,2017-02-28,300,1965.58,2059.2,2063.4,0,512,0.1117,1.0,0.0,-3.1323,0.0,SPX170317C00300000,17\nSPX,2363.64,*,SPX170317P00300000,,put,2017-03-17,2017-02-28,300,0.03,0.0,0.05,0,500,0.2267,0.0,0.0,0.0,0.0,SPX170317P00300000,17\nSPX,2363.64,*,SPX170421C00500000,,call,2017-04-21,2017-02-28,500,1761.1,1857.0,1861.3,0,14,0.1176,1.0,0.0,-5.2153,0.0,SPX170421C00500000,52\nSPX,2363.64,*,SPX170421P01375000,,put,2017-04-21,2017-02-28,1375,0.15,0.05,0.45,0,324,0.2379,0.0,0.0,0.0,0.0,SPX170421P01375000,52\nSPX,2363.64,*,SPX170519C01000000,,call,2017-05-19,2017-02-28,1000,0.0,1353.9,1358.5,0,0,0.1229,1.0,0.0,-10.4223,0.0,SPX170519C01000000,80\nSPX,2363.64,*,SPX170519P01650000,,put,2017-05-19,2017-02-28,1650,0.8,0.5,1.05,550,4179,0.2495,-0.0008,0.0,-1.6517,2.9118,SPX170519P01650000,80\nSPX,2395.96,*,SPX170317C00300000,,call,2017-03-17,2017-03-01,300,1965.58,2091.9,2094.6,0,512,0.0931,1.0,0.0,-3.4025,0.0,SPX170317C00300000,16\nSPX,2395.96,*,SPX170317P00300000,,put,2017-03-17,2017-03-01,300,0.03,0.0,0.05,0,500,0.1934,0.0,0.0,0.0,0.0,SPX170317P00300000,16\nSPX,2395.96,*,SPX170421C00500000,,call,2017-04-21,2017-03-01,500,1761.1,1889.8,1892.5,0,14,0.1093,1.0,0.0,-5.6646,0.0,SPX170421C00500000,51\nSPX,2395.96,*,SPX170421P01375000,,put,2017-04-21,2017-03-01,1375,0.15,0.05,0.4,140,324,0.2241,0.0,0.0,0.0,0.0,SPX170421P01375000,51\nSPX,2395.96,*,SPX170519C01000000,,call,2017-05-19,2017-03-01,1000,0.0,1386.7,1389.4,0,0,0.1095,1.0,0.0,-11.3194,0.0,SPX170519C01000000,79\nSPX,2395.96,*,SPX170519P01650000,,put,2017-05-19,2017-03-01,1650,0.65,0.45,1.0,219,4179,0.2419,-0.0003,0.0,-0.7452,1.3386,SPX170519P01650000,79\nSPX,2381.92,*,SPX170317C00300000,,call,2017-03-17,2017-03-02,300,1965.58,2077.5,2081.7,0,512,0.0849,1.0,0.0,-3.4026,0.0,SPX170317C00300000,15\nSPX,2381.92,*,SPX170317P00300000,,put,2017-03-17,2017-03-02,300,0.03,0.0,0.05,0,500,0.1811,0.0,0.0,0.0,0.0,SPX170317P00300000,15\nSPX,2381.92,*,SPX170421C00500000,,call,2017-04-21,2017-03-02,500,1761.1,1875.5,1879.9,0,14,0.1074,1.0,0.0,-5.6648,0.0,SPX170421C00500000,50\nSPX,2381.92,*,SPX170421P01375000,,put,2017-04-21,2017-03-02,1375,0.15,0.05,0.4,10,434,0.2176,0.0,0.0,0.0,0.0,SPX170421P01375000,50\nSPX,2381.92,*,SPX170519C01000000,,call,2017-05-19,2017-03-02,1000,0.0,1372.7,1377.3,0,0,0.1174,1.0,0.0,-11.3198,0.0,SPX170519C01000000,78\nSPX,2381.92,*,SPX170519P01650000,,put,2017-05-19,2017-03-02,1650,0.65,0.45,1.0,0,4179,0.2365,-0.0003,0.0,-0.6297,1.1422,SPX170519P01650000,78\nSPX,2383.12,*,SPX170317C00300000,,call,2017-03-17,2017-03-03,300,1965.58,2078.2,2081.6,0,512,0.0579,1.0,0.0,-3.4027,0.0,SPX170317C00300000,14\nSPX,2383.12,*,SPX170317P00300000,,put,2017-03-17,2017-03-03,300,0.03,0.0,0.05,0,500,0.1633,0.0,0.0,0.0,0.0,SPX170317P00300000,14\nSPX,2383.12,*,SPX170421C00500000,,call,2017-04-21,2017-03-03,500,1761.1,1876.4,1879.7,0,14,0.0866,1.0,0.0,-5.665,0.0,SPX170421C00500000,49\nSPX,2383.12,*,SPX170421P01375000,,put,2017-04-21,2017-03-03,1375,0.15,0.05,0.4,10,444,0.1912,0.0,0.0,0.0,0.0,SPX170421P01375000,49\nSPX,2383.12,*,SPX170519C01000000,,call,2017-05-19,2017-03-03,1000,0.0,1373.4,1376.8,0,0,0.1058,1.0,0.0,-11.3201,0.0,SPX170519C01000000,77\nSPX,2383.12,*,SPX170519P01650000,,put,2017-05-19,2017-03-03,1650,0.65,0.35,0.9,0,4179,0.2222,-0.0001,0.0,-0.2501,0.4766,SPX170519P01650000,77\nSPX,2375.31,*,SPX170317C00300000,,call,2017-03-17,2017-03-06,300,1965.58,2071.7,2075.0,0,512,0.0787,1.0,0.0,-3.403,0.0,SPX170317C00300000,11\nSPX,2375.31,*,SPX170317P00300000,,put,2017-03-17,2017-03-06,300,0.03,0.0,0.05,0,500,0.1636,0.0,0.0,0.0,0.0,SPX170317P00300000,11\nSPX,2375.31,*,SPX170421C00500000,,call,2017-04-21,2017-03-06,500,1761.1,1869.8,1873.2,0,14,0.10099999999999999,1.0,0.0,-5.6655,0.0,SPX170421C00500000,46\nSPX,2375.31,*,SPX170421P01375000,,put,2017-04-21,2017-03-06,1375,0.1,0.05,0.4,55,454,0.2061,0.0,0.0,0.0,0.0,SPX170421P01375000,46\nSPX,2375.31,*,SPX170519C01000000,,call,2017-05-19,2017-03-06,1000,0.0,1366.8,1370.2,0,0,0.1064,1.0,0.0,-11.3212,0.0,SPX170519C01000000,74\nSPX,2375.31,*,SPX170519P01650000,,put,2017-05-19,2017-03-06,1650,0.65,0.5,0.9,0,4179,0.2256,-0.0001,0.0,-0.2739,0.4937,SPX170519P01650000,74\nSPX,2368.39,*,SPX170317C00300000,,call,2017-03-17,2017-03-07,300,1965.58,2064.5,2068.7,0,512,0.083,1.0,0.0,-3.4031,0.0,SPX170317C00300000,10\nSPX,2368.39,*,SPX170317P00300000,,put,2017-03-17,2017-03-07,300,0.03,0.0,0.05,0,500,0.17800000000000002,0.0,0.0,0.0,0.0,SPX170317P00300000,10\nSPX,2368.39,*,SPX170421C00500000,,call,2017-04-21,2017-03-07,500,1761.1,1862.7,1867.1,0,14,0.1007,1.0,0.0,-5.6657,0.0,SPX170421C00500000,45\nSPX,2368.39,*,SPX170421P01375000,,put,2017-04-21,2017-03-07,1375,0.1,0.05,0.15,438,509,0.2132,0.0,0.0,0.0,0.0,SPX170421P01375000,45\nSPX,2368.39,*,SPX170519C01000000,,call,2017-05-19,2017-03-07,1000,0.0,1359.8,1364.5,0,0,0.1103,1.0,0.0,-11.3215,0.0,SPX170519C01000000,73\nSPX,2368.39,*,SPX170519P01650000,,put,2017-05-19,2017-03-07,1650,0.65,0.3,0.9,0,4179,0.231,-0.0002,0.0,-0.3879,0.6735,SPX170519P01650000,73\nSPX,2362.98,*,SPX170317C00300000,,call,2017-03-17,2017-03-08,300,1965.58,2060.2,2064.4,0,512,0.0991,1.0,0.0,-3.4032,0.0,SPX170317C00300000,9\nSPX,2362.98,*,SPX170317P00300000,,put,2017-03-17,2017-03-08,300,0.03,0.0,0.05,0,500,0.2039,0.0,0.0,0.0,0.0,SPX170317P00300000,9\nSPX,2362.98,*,SPX170421C00500000,,call,2017-04-21,2017-03-08,500,1761.1,1858.8,1863.2,0,14,0.1613,0.9992,0.0,9.4751,0.0,SPX170421C00500000,44\nSPX,2362.98,*,SPX170421P01375000,,put,2017-04-21,2017-03-08,1375,0.1,0.05,0.3,396,947,0.218,0.0,0.0,0.0,0.0,SPX170421P01375000,44\nSPX,2362.98,*,SPX170519C01000000,,call,2017-05-19,2017-03-08,1000,0.0,1355.8,1360.5,0,0,0.1517,0.9983,0.0,8.7163,0.0,SPX170519C01000000,72\nSPX,2362.98,*,SPX170519P01650000,,put,2017-05-19,2017-03-08,1650,0.65,0.35,0.9,0,4179,0.2339,-0.0002,0.0,-0.4879,0.8179,SPX170519P01650000,72\nSPX,2364.87,*,SPX170317C00300000,,call,2017-03-17,2017-03-09,300,1965.58,2061.9,2066.2,0,512,0.1042,1.0,0.0,-3.4033,0.0,SPX170317C00300000,8\nSPX,2364.87,*,SPX170317P00300000,,put,2017-03-17,2017-03-09,300,0.03,0.0,0.05,0,500,0.2337,0.0,0.0,0.0,0.0,SPX170317P00300000,8\nSPX,2364.87,*,SPX170421C00500000,,call,2017-04-21,2017-03-09,500,1761.1,1860.3,1864.6,0,14,0.1274,1.0,0.0,-5.666,0.0,SPX170421C00500000,43\nSPX,2364.87,*,SPX170421P01375000,,put,2017-04-21,2017-03-09,1375,0.1,0.05,0.1,0,1342,0.2143,0.0,0.0,0.0,0.0,SPX170421P01375000,43\nSPX,2364.87,*,SPX170519C01000000,,call,2017-05-19,2017-03-09,1000,0.0,1357.3,1361.9,0,0,0.1204,1.0,0.0,-11.3222,0.0,SPX170519C01000000,71\nSPX,2364.87,*,SPX170519P01650000,,put,2017-05-19,2017-03-09,1650,0.6,0.6,0.85,40,4179,0.2406,-0.0002,0.0,-0.5963,0.9661,SPX170519P01650000,71\nSPX,2372.6,*,SPX170317C00300000,,call,2017-03-17,2017-03-10,300,1965.58,2068.7,2072.9,0,512,0.0778,1.0,0.0,-3.4034,0.0,SPX170317C00300000,7\nSPX,2372.6,*,SPX170317P00300000,,put,2017-03-17,2017-03-10,300,0.03,0.0,0.05,0,500,0.1951,0.0,0.0,0.0,0.0,SPX170317P00300000,7\nSPX,2372.6,*,SPX170421C00500000,,call,2017-04-21,2017-03-10,500,1761.1,1866.6,1870.9,0,14,0.1085,1.0,0.0,-5.6662,0.0,SPX170421C00500000,42\nSPX,2372.6,*,SPX170421P01375000,,put,2017-04-21,2017-03-10,1375,0.1,0.05,0.1,0,1342,0.2239,0.0,0.0,0.0,0.0,SPX170421P01375000,42\nSPX,2372.6,*,SPX170519C01000000,,call,2017-05-19,2017-03-10,1000,0.0,1363.5,1368.2,0,0,0.1129,1.0,0.0,-11.3226,0.0,SPX170519C01000000,70\nSPX,2372.6,*,SPX170519P01650000,,put,2017-05-19,2017-03-10,1650,0.6,0.2,0.8,20,4244,0.2372,-0.0002,0.0,-0.41100000000000003,0.6657,SPX170519P01650000,70\nSPX,2373.47,*,SPX170317C00300000,,call,2017-03-17,2017-03-13,300,1965.58,2071.6,2075.9,0,512,0.1297,1.0,0.0,-3.4037,0.0,SPX170317C00300000,4\nSPX,2373.47,*,SPX170317P00300000,,put,2017-03-17,2017-03-13,300,0.03,0.0,0.05,0,500,0.182,0.0,0.0,0.0,0.0,SPX170317P00300000,4\nSPX,2373.47,*,SPX170421C00500000,,call,2017-04-21,2017-03-13,500,1761.1,1869.7,1873.9,0,14,0.1809,0.9988,0.0,20.9593,0.0,SPX170421C00500000,39\nSPX,2373.47,*,SPX170421P01375000,,put,2017-04-21,2017-03-13,1375,0.1,0.05,0.1,0,1342,0.2172,0.0,0.0,0.0,0.0,SPX170421P01375000,39\nSPX,2373.47,*,SPX170519C01000000,,call,2017-05-19,2017-03-13,1000,0.0,1366.7,1371.3,0,0,0.2124,0.997,0.0,27.4223,0.0,SPX170519C01000000,67\nSPX,2373.47,*,SPX170519P01650000,,put,2017-05-19,2017-03-13,1650,0.6,0.5,0.75,0,4264,0.2285,-0.0001,0.0,-0.207,0.3278,SPX170519P01650000,67\nSPX,2365.45,*,SPX170317C00300000,,call,2017-03-17,2017-03-14,300,1965.58,2064.5,2068.8,0,512,0.3303,1.0,0.0,-3.4039,0.0,SPX170317C00300000,3\nSPX,2365.45,*,SPX170317P00300000,,put,2017-03-17,2017-03-14,300,0.03,0.0,0.05,0,500,0.2735,0.0,0.0,0.0,0.0,SPX170317P00300000,3\nSPX,2365.45,*,SPX170421C00500000,,call,2017-04-21,2017-03-14,500,1761.1,1862.6,1866.9,0,14,0.0875,0.9989,0.0,20.6196,0.0,SPX170421C00500000,38\nSPX,2365.45,*,SPX170421P01375000,,put,2017-04-21,2017-03-14,1375,0.1,0.05,0.15,965,1342,0.2322,0.0,0.0,0.0,0.0,SPX170421P01375000,38\nSPX,2365.45,*,SPX170519C01000000,,call,2017-05-19,2017-03-14,1000,0.0,1359.9,1364.4,0,0,0.2519,0.9971,0.0,27.1478,0.0,SPX170519C01000000,66\nSPX,2365.45,*,SPX170519P01650000,,put,2017-05-19,2017-03-14,1650,0.6,0.5,0.85,0,4264,0.2354,-0.0001,0.0,-0.3273,0.4955,SPX170519P01650000,66\nSPX,2385.26,*,SPX170317C00300000,,call,2017-03-17,2017-03-15,300,1965.58,2082.2,2086.9,0,512,0.1159,1.0,0.0,-3.404,0.0,SPX170317C00300000,2\nSPX,2385.26,*,SPX170317P00300000,,put,2017-03-17,2017-03-15,300,0.03,0.0,0.05,0,500,0.1819,0.0,0.0,0.0,0.0,SPX170317P00300000,2\nSPX,2385.26,*,SPX170421C00500000,,call,2017-04-21,2017-03-15,500,1761.1,1880.4,1884.7,0,14,0.1602,0.9989,0.0,21.0928,0.0,SPX170421C00500000,37\nSPX,2385.26,*,SPX170421P01375000,,put,2017-04-21,2017-03-15,1375,0.1,0.05,0.3,923,2307,0.2242,0.0,0.0,0.0,0.0,SPX170421P01375000,37\nSPX,2385.26,*,SPX170519C01000000,,call,2017-05-19,2017-03-15,1000,0.0,1377.5,1382.0,0,0,0.1919,0.9971,0.0,27.6176,0.0,SPX170519C01000000,65\nSPX,2385.26,*,SPX170519P01650000,,put,2017-05-19,2017-03-15,1650,0.55,0.25,0.85,4360,4264,0.2291,-0.0001,0.0,-0.1452,0.2225,SPX170519P01650000,65\nSPX,2381.38,*,SPX170317C00300000,,call,2017-03-17,2017-03-16,300,1965.58,2080.6,2082.8,0,512,0.1555,1.0,0.0,-3.4041,0.0,SPX170317C00300000,1\nSPX,2381.38,*,SPX170317P00300000,,put,2017-03-17,2017-03-16,300,0.03,0.0,0.05,0,500,0.3324,0.0,0.0,0.0,0.0,SPX170317P00300000,1\nSPX,2381.38,*,SPX170421C00500000,,call,2017-04-21,2017-03-16,500,1761.1,1878.5,1880.9,0,14,0.2067,0.9989,0.0,20.2464,0.0,SPX170421C00500000,36\nSPX,2381.38,*,SPX170421P01375000,,put,2017-04-21,2017-03-16,1375,0.1,0.05,0.3,0,3230,0.2195,0.0,0.0,0.0,0.0,SPX170421P01375000,36\nSPX,2381.38,*,SPX170519C01000000,,call,2017-05-19,2017-03-16,1000,0.0,1375.4,1377.7,0,0,0.2145,0.9972,0.0,27.1352,0.0,SPX170519C01000000,64\nSPX,2381.38,*,SPX170519P01650000,,put,2017-05-19,2017-03-16,1650,0.55,0.3,0.75,0,8624,0.2269,0.0,0.0,-0.1192,0.1815,SPX170519P01650000,64\nSPX,2378.25,*,SPX170317C00300000,,call,2017-03-17,2017-03-17,300,1965.58,2078.6,2086.9,0,512,0.3,1.0,0.0,0.0,0.0,SPX170317C00300000,0\nSPX,2378.25,*,SPX170317P00300000,,put,2017-03-17,2017-03-17,300,0.03,0.0,0.05,0,500,0.3,0.0,0.0,0.0,0.0,SPX170317P00300000,0\nSPX,2378.25,*,SPX170421C00500000,,call,2017-04-21,2017-03-17,500,1761.1,1874.7,1879.2,0,14,0.1979,0.9990000000000001,0.0,19.662,0.0,SPX170421C00500000,35\nSPX,2378.25,*,SPX170421P01375000,,put,2017-04-21,2017-03-17,1375,0.1,0.05,0.15,0,3230,0.1908,0.0,0.0,0.0,0.0,SPX170421P01375000,35\nSPX,2378.25,*,SPX170519C01000000,,call,2017-05-19,2017-03-17,1000,0.0,1371.7,1376.2,0,0,0.2011,0.9973,0.0,26.8118,0.0,SPX170519C01000000,63\nSPX,2378.25,*,SPX170519P01650000,,put,2017-05-19,2017-03-17,1650,0.55,0.3,0.85,0,8624,0.2309,-0.0001,0.0,-0.1481,0.2182,SPX170519P01650000,63\nSPX,2373.47,*,SPX170421C00500000,,call,2017-04-21,2017-03-20,500,1761.1,1869.4,1873.8,0,14,0.1719,0.9991,0.0,19.3788,0.0,SPX170421C00500000,32\nSPX,2373.47,*,SPX170421P01375000,,put,2017-04-21,2017-03-20,1375,0.05,0.0,0.25,5,3230,0.2202,0.0,0.0,0.0,0.0,SPX170421P01375000,32\nSPX,2373.47,*,SPX170519C01000000,,call,2017-05-19,2017-03-20,1000,1365.0,1366.3,1370.8,250,0,0.1918,0.9974,0.0,26.6164,0.0,SPX170519C01000000,60\nSPX,2373.47,*,SPX170519P01650000,,put,2017-05-19,2017-03-20,1650,0.55,0.3,0.7,0,8624,0.2285,0.0,0.0,-0.096,0.136,SPX170519P01650000,60\nSPX,2344.02,*,SPX170421C00500000,,call,2017-04-21,2017-03-21,500,1761.1,1840.6,1845.0,0,14,0.2069,0.9991,0.0,19.2997,0.0,SPX170421C00500000,31\nSPX,2344.02,*,SPX170421P01375000,,put,2017-04-21,2017-03-21,1375,0.05,0.0,0.15,0,3230,0.2359,0.0,0.0,0.0,0.0,SPX170421P01375000,31\nSPX,2344.02,*,SPX170519C01000000,,call,2017-05-19,2017-03-21,1000,1339.54,1337.3,1341.9,3100,0,0.2185,0.9974,0.0,26.2681,0.0,SPX170519C01000000,59\nSPX,2344.02,*,SPX170519P01650000,,put,2017-05-19,2017-03-21,1650,0.55,0.5,0.7,0,8624,0.2439,-0.0001,0.0,-0.3766,0.4916,SPX170519P01650000,59\nSPX,2348.45,*,SPX170421C00500000,,call,2017-04-21,2017-03-22,500,1761.1,1843.3,1847.7,0,14,0.1532,0.9992,0.0,19.1158,0.0,SPX170421C00500000,30\nSPX,2348.45,*,SPX170421P01375000,,put,2017-04-21,2017-03-22,1375,0.05,0.0,0.1,121,3230,0.2322,0.0,0.0,0.0,0.0,SPX170421P01375000,30\nSPX,2348.45,*,SPX170519C01000000,,call,2017-05-19,2017-03-22,1000,1339.54,1340.1,1344.6,0,3100,0.1718,0.9975,0.0,26.219,0.0,SPX170519C01000000,58\nSPX,2348.45,*,SPX170519P01650000,,put,2017-05-19,2017-03-22,1650,0.6,0.5,0.85,240,8624,0.2448,-0.0001,0.0,-0.3332,0.426,SPX170519P01650000,58\nSPX,2345.96,*,SPX170421C00500000,,call,2017-04-21,2017-03-23,500,1761.1,1840.2,1844.6,0,14,0.1428,0.9992,0.0,18.737000000000002,0.0,SPX170421C00500000,29\nSPX,2345.96,*,SPX170421P01375000,,put,2017-04-21,2017-03-23,1375,0.05,0.0,0.1,0,3351,0.2192,0.0,0.0,0.0,0.0,SPX170421P01375000,29\nSPX,2345.96,*,SPX170519C01000000,,call,2017-05-19,2017-03-23,1000,1339.54,1336.8,1341.4,0,3100,0.1707,0.9975,0.0,26.0539,0.0,SPX170519C01000000,57\nSPX,2345.96,*,SPX170519P01650000,,put,2017-05-19,2017-03-23,1650,0.5,0.35,0.8,2,8624,0.2491,-0.0001,0.0,-0.3974,0.4907,SPX170519P01650000,57\nSPX,2343.98,*,SPX170421C00500000,,call,2017-04-21,2017-03-24,500,1761.1,1843.9,1848.2,0,14,2.5075,0.9945,0.0,-128.8663,8.8249,SPX170421C00500000,28\nSPX,2343.98,*,SPX170421P01375000,,put,2017-04-21,2017-03-24,1375,0.05,0.0,0.1,0,3351,0.233,0.0,0.0,0.0,0.0,SPX170421P01375000,28\nSPX,2343.98,*,SPX170519C01000000,,call,2017-05-19,2017-03-24,1000,1339.54,1340.5,1345.0,0,3100,0.3027,0.9976,0.0,26.0289,0.0,SPX170519C01000000,56\nSPX,2343.98,*,SPX170519P01650000,,put,2017-05-19,2017-03-24,1650,0.5,0.35,0.85,0,8626,0.2432,-0.0001,0.0,-0.2608,0.324,SPX170519P01650000,56\nSPX,2341.59,*,SPX170421C00500000,,call,2017-04-21,2017-03-27,500,1761.1,1838.4,1842.7,0,14,0.1855,0.9993,0.0,18.4671,0.0,SPX170421C00500000,25\nSPX,2341.59,*,SPX170421P01375000,,put,2017-04-21,2017-03-27,1375,0.05,0.0,0.05,0,3351,0.22,0.0,0.0,0.0,0.0,SPX170421P01375000,25\nSPX,2341.59,*,SPX170519C01000000,,call,2017-05-19,2017-03-27,1000,1339.54,1335.1,1339.6,0,3100,0.1917,0.9977,0.0,25.8919,0.0,SPX170519C01000000,53\nSPX,2341.59,*,SPX170519P01650000,,put,2017-05-19,2017-03-27,1650,0.5,0.3,0.8,0,8626,0.2338,0.0,0.0,-0.1004,0.1227,SPX170519P01650000,53\nSPX,2358.57,*,SPX170421C00500000,,call,2017-04-21,2017-03-28,500,1761.1,1853.4,1857.8,0,14,0.1082,0.9993,0.0,18.537,0.0,SPX170421C00500000,24\nSPX,2358.57,*,SPX170421P01375000,,put,2017-04-21,2017-03-28,1375,0.05,0.0,0.1,0,3351,0.1894,0.0,0.0,0.0,0.0,SPX170421P01375000,24\nSPX,2358.57,*,SPX170519C01000000,,call,2017-05-19,2017-03-28,1000,1339.54,1350.2,1354.7,0,3100,0.13,0.9978,0.0,26.0702,0.0,SPX170519C01000000,52\nSPX,2358.57,*,SPX170519P01650000,,put,2017-05-19,2017-03-28,1650,0.5,0.2,0.7,0,8626,0.2208,0.0,0.0,-0.0217,0.0276,SPX170519P01650000,52\nSPX,2361.13,*,SPX170421C00500000,,call,2017-04-21,2017-03-29,500,1761.1,1857.9,1862.2,0,14,0.1235,0.9997,0.0,6.5568,0.0,SPX170421C00500000,23\nSPX,2361.13,*,SPX170421P01375000,,put,2017-04-21,2017-03-29,1375,0.05,0.0,0.05,0,3351,0.18899999999999997,0.0,0.0,0.0,0.0,SPX170421P01375000,23\nSPX,2361.13,*,SPX170519C01000000,,call,2017-05-19,2017-03-29,1000,1339.54,1354.5,1359.0,0,3100,0.1237,0.9989,0.0,7.5105,0.0,SPX170519C01000000,51\nSPX,2361.13,*,SPX170519P01650000,,put,2017-05-19,2017-03-29,1650,0.5,0.1,0.65,0,8626,0.2196,0.0,0.0,-0.0145,0.0182,SPX170519P01650000,51\nSPX,2368.06,*,SPX170421C00500000,,call,2017-04-21,2017-03-30,500,1761.1,1863.7,1868.0,0,14,0.0915,1.0,0.0,-5.6697,0.0,SPX170421C00500000,22\nSPX,2368.06,*,SPX170421P01375000,,put,2017-04-21,2017-03-30,1375,0.05,0.0,0.1,0,3351,0.1537,0.0,0.0,0.0,0.0,SPX170421P01375000,22\nSPX,2368.06,*,SPX170519C01000000,,call,2017-05-19,2017-03-30,1000,1339.54,1360.5,1365.0,0,3100,0.09699999999999999,1.0,0.0,-11.3296,0.0,SPX170519C01000000,50\nSPX,2368.06,*,SPX170519P01650000,,put,2017-05-19,2017-03-30,1650,0.37,0.1,0.7,0,8626,0.2231,0.0,0.0,-0.0133,0.0163,SPX170519P01650000,50\nSPX,2362.72,*,SPX170421C00500000,,call,2017-04-21,2017-03-31,500,1761.1,1860.1,1864.4,0,14,0.1171,1.0,0.0,-5.6699,0.0,SPX170421C00500000,21\nSPX,2362.72,*,SPX170421P01375000,,put,2017-04-21,2017-03-31,1375,0.05,0.0,0.05,0,3351,0.1933,0.0,0.0,0.0,0.0,SPX170421P01375000,21\nSPX,2362.72,*,SPX170519C01000000,,call,2017-05-19,2017-03-31,1000,1339.54,1356.8,1361.2,0,3100,0.1084,1.0,0.0,-11.33,0.0,SPX170519C01000000,49\nSPX,2362.72,*,SPX170519P01650000,,put,2017-05-19,2017-03-31,1650,0.37,0.15,0.65,0,8626,0.2261,0.0,0.0,-0.0164,0.0194,SPX170519P01650000,49\nSPX,2358.84,*,SPX170421C00500000,,call,2017-04-21,2017-04-03,500,1761.1,1856.5,1860.6,0,14,0.1247,1.0,0.0,-5.7918,0.0,SPX170421C00500000,18\nSPX,2358.84,*,SPX170421P01375000,,put,2017-04-21,2017-04-03,1375,0.05,0.0,0.1,0,3351,0.1826,0.0,0.0,0.0,0.0,SPX170421P01375000,18\nSPX,2358.84,*,SPX170519C01000000,,call,2017-05-19,2017-04-03,1000,1339.54,1352.9,1357.4,0,3100,0.1154,1.0,0.0,-11.5733,0.0,SPX170519C01000000,46\nSPX,2358.84,*,SPX170519P01650000,,put,2017-05-19,2017-04-03,1650,0.37,0.2,0.7,0,8626,0.2273,0.0,0.0,-0.011000000000000001,0.0122,SPX170519P01650000,46\nSPX,2360.16,*,SPX170421C00500000,,call,2017-04-21,2017-04-04,500,1761.1,1858.1,1862.3,0,14,0.1236,1.0,0.0,-5.792000000000001,0.0,SPX170421C00500000,17\nSPX,2360.16,*,SPX170421P01375000,,put,2017-04-21,2017-04-04,1375,0.05,0.0,0.1,0,3351,0.1712,0.0,0.0,0.0,0.0,SPX170421P01375000,17\nSPX,2360.16,*,SPX170519C01000000,,call,2017-05-19,2017-04-04,1000,1339.54,1354.6,1359.0,0,3100,0.1051,1.0,0.0,-11.5737,0.0,SPX170519C01000000,45\nSPX,2360.16,*,SPX170519P01650000,,put,2017-05-19,2017-04-04,1650,0.37,0.1,0.6,0,8626,0.2223,0.0,0.0,-0.0053,0.0058,SPX170519P01650000,45\nSPX,2352.95,*,SPX170421C00500000,,call,2017-04-21,2017-04-05,500,1761.1,1848.3,1852.5,0,14,0.095,1.0,0.0,-5.7922,0.0,SPX170421C00500000,16\nSPX,2352.95,*,SPX170421P01375000,,put,2017-04-21,2017-04-05,1375,0.05,0.0,0.1,0,3351,0.1904,0.0,0.0,0.0,0.0,SPX170421P01375000,16\nSPX,2352.95,*,SPX170519C01000000,,call,2017-05-19,2017-04-05,1000,1339.54,1345.0,1349.4,0,3100,0.1033,1.0,0.0,-11.5741,0.0,SPX170519C01000000,44\nSPX,2352.95,*,SPX170519P01650000,,put,2017-05-19,2017-04-05,1650,0.37,0.15,0.65,0,8626,0.2357,0.0,0.0,-0.0176,0.0179,SPX170519P01650000,44\nSPX,2357.49,*,SPX170421C00500000,,call,2017-04-21,2017-04-06,500,1761.1,1853.9,1858.0,0,14,0.1141,0.9997,0.0,12.8305,0.0,SPX170421C00500000,15\nSPX,2357.49,*,SPX170421P01375000,,put,2017-04-21,2017-04-06,1375,0.05,0.0,0.1,0,3351,0.1719,0.0,0.0,0.0,0.0,SPX170421P01375000,15\nSPX,2357.49,*,SPX170519C01000000,,call,2017-05-19,2017-04-06,1000,1339.54,1350.3,1354.7,0,3100,0.136,0.9988,0.0,12.4476,0.0,SPX170519C01000000,43\nSPX,2357.49,*,SPX170519P01650000,,put,2017-05-19,2017-04-06,1650,0.37,0.1,0.55,0,8626,0.2276,0.0,0.0,-0.0065,0.0066,SPX170519P01650000,43\nSPX,2355.54,*,SPX170421C00500000,,call,2017-04-21,2017-04-07,500,1761.1,1853.3,1857.4,0,14,0.12,1.0,0.0,-5.7925,0.0,SPX170421C00500000,14\nSPX,2355.54,*,SPX170421P01375000,,put,2017-04-21,2017-04-07,1375,0.05,0.0,0.1,0,3351,0.1722,0.0,0.0,0.0,0.0,SPX170421P01375000,14\nSPX,2355.54,*,SPX170519C01000000,,call,2017-05-19,2017-04-07,1000,1339.54,1349.8,1354.1,0,3100,0.1247,1.0,0.0,-11.5748,0.0,SPX170519C01000000,42\nSPX,2355.54,*,SPX170519P01650000,,put,2017-05-19,2017-04-07,1650,0.37,0.1,0.6,0,8626,0.2394,0.0,0.0,-0.0148,0.0141,SPX170519P01650000,42\nSPX,2357.16,*,SPX170421C00500000,,call,2017-04-21,2017-04-10,500,1761.1,1853.5,1857.7,0,14,0.096,1.0,0.0,-5.7931,0.0,SPX170421C00500000,11\nSPX,2357.16,*,SPX170421P01375000,,put,2017-04-21,2017-04-10,1375,0.05,0.0,0.1,0,3351,0.1753,0.0,0.0,0.0,0.0,SPX170421P01375000,11\nSPX,2357.16,*,SPX170519C01000000,,call,2017-05-19,2017-04-10,1000,1353.9,1349.9,1354.3,22,3100,0.1875,0.9979,0.0,36.4153,0.0,SPX170519C01000000,39\nSPX,2357.16,*,SPX170519P01650000,,put,2017-05-19,2017-04-10,1650,0.37,0.1,0.35,0,8626,0.2493,0.0,0.0,-0.0187,0.0157,SPX170519P01650000,39\nSPX,2353.78,*,SPX170421C00500000,,call,2017-04-21,2017-04-11,500,1761.1,1850.7,1854.8,0,14,0.1065,1.0,0.0,-5.7933,0.0,SPX170421C00500000,10\nSPX,2353.78,*,SPX170421P01375000,,put,2017-04-21,2017-04-11,1375,0.05,0.0,0.1,0,3351,0.1804,0.0,0.0,0.0,0.0,SPX170421P01375000,10\nSPX,2353.78,*,SPX170519C01000000,,call,2017-05-19,2017-04-11,1000,1353.9,1347.2,1351.6,0,3122,0.1617,0.9989,0.0,12.9198,0.0,SPX170519C01000000,38\nSPX,2353.78,*,SPX170519P01650000,,put,2017-05-19,2017-04-11,1650,0.4,0.2,0.65,10,8626,0.2708,0.0,0.0,-0.0733,0.0554,SPX170519P01650000,38\nSPX,2344.93,*,SPX170421C00500000,,call,2017-04-21,2017-04-12,500,1761.1,1844.2,1848.3,0,14,0.1945,1.0,0.0,-5.7935,0.0,SPX170421C00500000,9\nSPX,2344.93,*,SPX170421P01375000,,put,2017-04-21,2017-04-12,1375,0.05,0.0,0.1,0,3351,0.1754,0.0,0.0,0.0,0.0,SPX170421P01375000,9\nSPX,2344.93,*,SPX170519C01000000,,call,2017-05-19,2017-04-12,1000,1353.9,1340.6,1344.9,0,3122,0.1683,1.0,0.0,-11.5766,0.0,SPX170519C01000000,37\nSPX,2344.93,*,SPX170519P01650000,,put,2017-05-19,2017-04-12,1650,0.4,0.2,0.5,0,8626,0.2696,0.0,0.0,-0.0617,0.0458,SPX170519P01650000,37\nSPX,2328.95,*,SPX170421C00500000,,call,2017-04-21,2017-04-13,500,1761.1,1826.9,1831.0,0,14,0.1379,1.0,0.0,-5.7937,0.0,SPX170421C00500000,8\nSPX,2328.95,*,SPX170421P01375000,,put,2017-04-21,2017-04-13,1375,0.05,0.0,0.1,0,3351,0.1969,0.0,0.0,0.0,0.0,SPX170421P01375000,8\nSPX,2328.95,*,SPX170519C01000000,,call,2017-05-19,2017-04-13,1000,1353.9,1323.4,1327.7,0,3122,0.24100000000000002,0.9978,0.0,40.5696,0.0,SPX170519C01000000,36\nSPX,2328.95,*,SPX170519P01650000,,put,2017-05-19,2017-04-13,1650,0.4,0.15,0.45,0,8626,0.2731,0.0,0.0,-0.0969,0.0683,SPX170519P01650000,36\nSPX,2349.01,*,SPX170421C00500000,,call,2017-04-21,2017-04-17,500,1761.1,1845.6,1849.8,0,14,0.0987,1.0,0.0,-5.7944,0.0,SPX170421C00500000,4\nSPX,2349.01,*,SPX170421P01375000,,put,2017-04-21,2017-04-17,1375,0.05,0.0,0.1,0,3351,0.1731,0.0,0.0,0.0,0.0,SPX170421P01375000,4\nSPX,2349.01,*,SPX170519C01000000,,call,2017-05-19,2017-04-17,1000,1335.77,1342.0,1346.3,2,3122,0.1864,0.9979,0.0,45.3163,0.0,SPX170519C01000000,32\nSPX,2349.01,*,SPX170519P01650000,,put,2017-05-19,2017-04-17,1650,0.4,0.2,0.4,0,8626,0.2557,0.0,0.0,-0.0055,0.0037,SPX170519P01650000,32\nSPX,2342.19,*,SPX170421C00500000,,call,2017-04-21,2017-04-18,500,1761.1,1839.1,1843.3,0,14,0.102,1.0,0.0,-5.7946,0.0,SPX170421C00500000,3\nSPX,2342.19,*,SPX170421P01375000,,put,2017-04-21,2017-04-18,1375,0.05,0.0,0.1,0,3351,0.1735,0.0,0.0,0.0,0.0,SPX170421P01375000,3\nSPX,2342.19,*,SPX170519C01000000,,call,2017-05-19,2017-04-18,1000,1337.55,1335.6,1339.9,100,3124,0.2047,0.998,0.0,45.6444,0.0,SPX170519C01000000,31\nSPX,2342.19,*,SPX170519P01650000,,put,2017-05-19,2017-04-18,1650,0.25,0.05,0.35,51,8626,0.2461,0.0,0.0,-0.0018,0.0012,SPX170519P01650000,31\nSPX,2338.17,*,SPX170421C00500000,,call,2017-04-21,2017-04-19,500,1761.1,1835.4,1839.8,0,14,0.1061,1.0,0.0,-5.7948,0.0,SPX170421C00500000,2\nSPX,2338.17,*,SPX170421P01375000,,put,2017-04-21,2017-04-19,1375,0.05,0.0,0.1,0,3351,0.2123,0.0,0.0,0.0,0.0,SPX170421P01375000,2\nSPX,2338.17,*,SPX170519C01000000,,call,2017-05-19,2017-04-19,1000,1339.7,1331.8,1336.1,75,3224,0.2112,0.998,0.0,46.0387,0.0,SPX170519C01000000,30\nSPX,2338.17,*,SPX170519P01650000,,put,2017-05-19,2017-04-19,1650,0.3,0.3,0.35,1885,8656,0.2631,0.0,0.0,-0.006999999999999999,0.0043,SPX170519P01650000,30\nSPX,2355.84,*,SPX170421C00500000,,call,2017-04-21,2017-04-20,500,1761.1,1853.6,1859.1,0,14,0.1324,1.0,0.0,-5.7949,0.0,SPX170421C00500000,1\nSPX,2355.84,*,SPX170421P01375000,,put,2017-04-21,2017-04-20,1375,0.05,0.0,0.1,0,3351,0.2755,0.0,0.0,0.0,0.0,SPX170421P01375000,1\nSPX,2355.84,*,SPX170519C01000000,,call,2017-05-19,2017-04-20,1000,1355.4,1350.7,1355.0,25,3299,0.2497,0.9981,0.0,45.9849,0.0,SPX170519C01000000,29\nSPX,2355.84,*,SPX170519P01650000,,put,2017-05-19,2017-04-20,1650,0.25,0.2,0.3,105,10406,0.265,0.0,0.0,-0.0036,0.0021,SPX170519P01650000,29\nSPX,2348.69,*,SPX170421C00500000,,call,2017-04-21,2017-04-21,500,1761.1,1852.9,1858.4,0,14,0.3,1.0,0.0,0.0,0.0,SPX170421C00500000,0\nSPX,2348.69,*,SPX170421P01375000,,put,2017-04-21,2017-04-21,1375,0.05,0.0,0.1,0,3351,0.3,0.0,0.0,0.0,0.0,SPX170421P01375000,0\nSPX,2348.69,*,SPX170519C01000000,,call,2017-05-19,2017-04-21,1000,1351.0,1342.9,1347.3,500,3324,0.2362,0.9982,0.0,46.3051,0.0,SPX170519C01000000,28\nSPX,2348.69,*,SPX170519P01650000,,put,2017-05-19,2017-04-21,1650,0.35,0.1,0.4,185,10356,0.2775,0.0,0.0,-0.0086,0.0046,SPX170519P01650000,28\nSPX,2374.15,*,SPX170519C01000000,,call,2017-05-19,2017-04-24,1000,1351.0,1367.9,1372.1,0,3524,0.1316,0.9983,0.0,47.4568,0.0,SPX170519C01000000,25\nSPX,2374.15,*,SPX170519P01650000,,put,2017-05-19,2017-04-24,1650,0.35,0.1,0.15,0,10541,0.1984,0.0,0.0,0.0,0.0,SPX170519P01650000,25\nSPX,2388.61,*,SPX170519C01000000,,call,2017-05-19,2017-04-25,1000,1385.0,1382.4,1386.5,3070,3524,0.1147,0.9984,0.0,48.3367,0.0,SPX170519C01000000,24\nSPX,2388.61,*,SPX170519P01650000,,put,2017-05-19,2017-04-25,1650,0.35,0.05,0.1,0,10541,0.1902,0.0,0.0,0.0,0.0,SPX170519P01650000,24\nSPX,2387.45,*,SPX170519C01000000,,call,2017-05-19,2017-04-26,1000,1388.0,1380.9,1385.1,3300,4124,0.086,0.9992,0.0,18.3878,0.0,SPX170519C01000000,23\nSPX,2387.45,*,SPX170519P01650000,,put,2017-05-19,2017-04-26,1650,0.35,0.05,0.1,0,10541,0.1903,0.0,0.0,0.0,0.0,SPX170519P01650000,23\nSPX,2388.77,*,SPX170519C01000000,,call,2017-05-19,2017-04-27,1000,1387.31,1384.0,1388.2,24,7194,0.0774,1.0,0.0,-11.5822,0.0,SPX170519C01000000,22\nSPX,2388.77,*,SPX170519P01650000,,put,2017-05-19,2017-04-27,1650,0.35,0.05,0.1,0,10541,0.1831,0.0,0.0,0.0,0.0,SPX170519P01650000,22\nSPX,2384.2,*,SPX170519C01000000,,call,2017-05-19,2017-04-28,1000,1387.31,1379.8,1384.7,0,8392,0.0881,1.0,0.0,-11.5825,0.0,SPX170519C01000000,21\nSPX,2384.2,*,SPX170519P01650000,,put,2017-05-19,2017-04-28,1650,0.05,0.05,0.1,342,10541,0.2025,0.0,0.0,0.0,0.0,SPX170519P01650000,21\nSPX,2388.33,*,SPX170519C01000000,,call,2017-05-19,2017-05-01,1000,1388.5,1383.7,1388.7,7,8392,0.0656,1.0,0.0,-11.8533,0.0,SPX170519C01000000,18\nSPX,2388.33,*,SPX170519P01650000,,put,2017-05-19,2017-05-01,1650,0.05,0.0,0.05,0,10551,0.0846,0.0,0.0,0.0,0.0,SPX170519P01650000,18\nSPX,2391.17,*,SPX170519C01000000,,call,2017-05-19,2017-05-02,1000,1388.5,1385.0,1390.0,0,8399,0.0681,1.0,0.0,-11.8537,0.0,SPX170519C01000000,17\nSPX,2391.17,*,SPX170519P01650000,,put,2017-05-19,2017-05-02,1650,0.05,0.0,0.05,10,10401,0.0933,0.0,0.0,0.0,0.0,SPX170519P01650000,17\nSPX,2388.13,*,SPX170519C01000000,,call,2017-05-19,2017-05-03,1000,1388.5,1382.5,1387.5,0,8399,0.0684,1.0,0.0,-11.8541,0.0,SPX170519C01000000,16\nSPX,2388.13,*,SPX170519P01650000,,put,2017-05-19,2017-05-03,1650,0.05,0.0,0.05,0,10401,0.0911,0.0,0.0,0.0,0.0,SPX170519P01650000,16\nSPX,2389.52,*,SPX170519C01000000,,call,2017-05-19,2017-05-04,1000,1388.5,1382.7,1387.7,0,8399,0.0663,1.0,0.0,-11.8545,0.0,SPX170519C01000000,15\nSPX,2389.52,*,SPX170519P01650000,,put,2017-05-19,2017-05-04,1650,0.05,0.0,0.05,100,10401,0.0945,0.0,0.0,0.0,0.0,SPX170519P01650000,15\nSPX,2399.29,*,SPX170519C01000000,,call,2017-05-19,2017-05-05,1000,1388.5,1393.5,1399.5,0,8399,0.0665,1.0,0.0,-11.8549,0.0,SPX170519C01000000,14\nSPX,2399.29,*,SPX170519P01650000,,put,2017-05-19,2017-05-05,1650,0.05,0.0,0.05,0,10401,0.0874,0.0,0.0,0.0,0.0,SPX170519P01650000,14\nSPX,2399.38,*,SPX170519C01000000,,call,2017-05-19,2017-05-08,1000,1394.57,1392.4,1397.1,150,8399,0.0624,0.9989,0.0,81.3482,0.0,SPX170519C01000000,11\nSPX,2399.38,*,SPX170519P01650000,,put,2017-05-19,2017-05-08,1650,0.05,0.0,0.05,0,10401,0.0783,0.0,0.0,0.0,0.0,SPX170519P01650000,11\nSPX,2396.92,*,SPX170519C01000000,,call,2017-05-19,2017-05-09,1000,1400.0,1391.0,1395.6,850,8249,0.068,0.9990000000000001,0.0,82.6971,0.0,SPX170519C01000000,10\nSPX,2396.92,*,SPX170519P01650000,,put,2017-05-19,2017-05-09,1650,0.05,0.0,0.05,0,10401,0.083,0.0,0.0,0.0,0.0,SPX170519P01650000,10\nSPX,2399.63,*,SPX170519C01000000,,call,2017-05-19,2017-05-10,1000,1400.0,1394.5,1399.2,0,8120,0.0658,0.9991,0.0,85.2728,0.0,SPX170519C01000000,9\nSPX,2399.63,*,SPX170519P01650000,,put,2017-05-19,2017-05-10,1650,0.05,0.0,0.05,0,10401,0.0739,0.0,0.0,0.0,0.0,SPX170519P01650000,9\nSPX,2394.44,*,SPX170519C01000000,,call,2017-05-19,2017-05-11,1000,1400.0,1390.5,1395.2,0,8120,0.0684,0.9996,0.0,37.8558,0.0,SPX170519C01000000,8\nSPX,2394.44,*,SPX170519P01650000,,put,2017-05-19,2017-05-11,1650,0.05,0.0,0.05,0,10401,0.0832,0.0,0.0,0.0,0.0,SPX170519P01650000,8\nSPX,2390.9,*,SPX170519C01000000,,call,2017-05-19,2017-05-12,1000,1400.0,1388.0,1392.6,0,8120,0.0631,1.0,0.0,-11.8576,0.0,SPX170519C01000000,7\nSPX,2390.9,*,SPX170519P01650000,,put,2017-05-19,2017-05-12,1650,0.05,0.0,0.05,0,10401,0.07200000000000001,0.0,0.0,0.0,0.0,SPX170519P01650000,7\nSPX,2402.32,*,SPX170519C01000000,,call,2017-05-19,2017-05-15,1000,1400.0,1397.7,1402.3,0,8120,0.0542,1.0,0.0,-11.8587,0.0,SPX170519C01000000,4\nSPX,2402.32,*,SPX170519P01650000,,put,2017-05-19,2017-05-15,1650,0.05,0.0,0.05,0,10401,0.0842,0.0,0.0,0.0,0.0,SPX170519P01650000,4\nSPX,2400.67,*,SPX170519C01000000,,call,2017-05-19,2017-05-16,1000,1400.0,1397.1,1401.6,0,8120,0.0642,1.0,0.0,-11.8591,0.0,SPX170519C01000000,3\nSPX,2400.67,*,SPX170519P01650000,,put,2017-05-19,2017-05-16,1650,0.05,0.0,0.05,0,10401,0.0816,0.0,0.0,0.0,0.0,SPX170519P01650000,3\nSPX,2357.03,*,SPX170519C01000000,,call,2017-05-19,2017-05-17,1000,1400.0,1357.5,1365.1,0,8120,0.1665,1.0,0.0,-11.8595,0.0,SPX170519C01000000,2\nSPX,2357.03,*,SPX170519P01650000,,put,2017-05-19,2017-05-17,1650,0.05,0.0,0.05,0,10401,0.158,0.0,0.0,0.0,0.0,SPX170519P01650000,2\nSPX,2365.72,*,SPX170519C01000000,,call,2017-05-19,2017-05-18,1000,1368.13,1361.2,1368.8,500,8120,0.1967,1.0,0.0,-11.8599,0.0,SPX170519C01000000,1\nSPX,2365.72,*,SPX170519P01650000,,put,2017-05-19,2017-05-18,1650,0.05,0.0,0.05,0,10401,0.1635,0.0,0.0,0.0,0.0,SPX170519P01650000,1\nSPX,2381.73,*,SPX170519C01000000,,call,2017-05-19,2017-05-19,1000,1368.13,1361.2,1368.9,0,7770,0.3,1.0,0.0,0.0,0.0,SPX170519C01000000,0\nSPX,2381.73,*,SPX170519P01650000,,put,2017-05-19,2017-05-19,1650,0.05,0.0,0.05,0,10401,0.3,0.0,0.0,0.0,0.0,SPX170519P01650000,0\n"
  },
  {
    "path": "tests/test_data/test_data_stocks.csv",
    "content": "symbol,date,close,high,low,open,volume,adjClose,adjHigh,adjLow,adjOpen,adjVolume,divCash,splitFactor\nVOO,2017-01-03,206.74,207.33,205.56,206.68,4750181,194.997817873,195.5543077277,193.8848381638,194.9412256844,4750181,0.0,1.0\nVOO,2017-01-04,207.96,208.18,207.12,207.2,4622614,196.148525708,196.3560303996,195.3562350675,195.431691319,4622614,0.0,1.0\nVOO,2017-01-05,207.8,208.04,207.013,207.75,2772065,195.99761320509998,196.2239819595,195.2553123312,195.9504530479,2772065,0.0,1.0\nVOO,2017-01-06,208.61,209.09,207.4,207.99,2194576,196.7616077513,197.21434526009998,195.6203319477,196.1768218023,2194576,0.0,1.0\nVOO,2017-01-09,207.95,208.48,207.885,208.34,1705181,196.1390936766,196.63899134259998,196.0777854723,196.5069429025,1705181,0.0,1.0\nVOO,2017-01-10,207.92,208.82,207.51,207.9,2189422,196.1107975823,196.9596804114,195.7240842935,196.0919335194,2189422,0.0,1.0\nVOO,2017-01-11,208.51,208.51,207.12,207.84,5678042,196.66728743689998,196.66728743689998,195.3562350675,196.0353413308,5678042,0.0,1.0\nVOO,2017-01-12,208.05,208.19,206.54,207.97,3244560,196.2334139909,196.365462431,194.8091772444,196.1579577395,3244560,0.0,1.0\nVOO,2017-01-13,208.46,208.78,208.13,208.18,2822744,196.6201272797,196.9219522856,196.30887024240002,196.3560303996,2822744,0.0,1.0\nVOO,2017-01-17,207.72,208.21,207.33,207.79,1690053,195.9221569536,196.3843264939,195.5543077277,195.9881811736,1690053,0.0,1.0\nVOO,2017-01-18,208.16,208.22,207.42,208.01,1980477,196.3371663367,196.3937585253,195.63919601060002,196.1956858652,1980477,0.0,1.0\nVOO,2017-01-19,207.46,208.43,206.97,208.27,1666905,195.67692413630002,196.5918311854,195.21475459599998,196.4409186825,1666905,0.0,1.0\nVOO,2017-01-20,208.13,208.7,207.56,208.14,2082660,196.30887024240002,196.8464960342,195.7712444507,196.31830227380001,2082660,0.0,1.0\nVOO,2017-01-23,207.65,208.21,206.83,207.84,4586616,195.8561327336,196.3843264939,195.0827061559,196.0353413308,4586616,0.0,1.0\nVOO,2017-01-24,208.97,209.4,207.7479,207.86,1940125,197.10116088290002,197.5067382346,195.94847232130002,196.0542053937,1940125,0.0,1.0\nVOO,2017-01-25,210.71,210.77,209.79,209.98,2872970,198.7423343525,198.7989265411,197.87458746049998,198.05379605779999,2872970,0.0,1.0\nVOO,2017-01-26,210.55,210.9,210.25,210.65,2485086,198.5914218495,198.9215429497,198.30846090650002,198.6857421639,2485086,0.0,1.0\nVOO,2017-01-27,210.25,210.7579,210.03,210.65,1428164,198.30846090650002,198.78751378299998,198.1009562149,198.6857421639,1428164,0.0,1.0\nVOO,2017-01-30,208.98,209.52,207.88,209.48,2782355,197.1105929143,197.6199226118,196.0730694566,197.582194486,2782355,0.0,1.0\nVOO,2017-01-31,208.97,208.97,207.79,208.34,3787144,197.10116088290002,197.10116088290002,195.9881811736,196.5069429025,3787144,0.0,1.0\nVOO,2017-02-01,209.0,209.86,208.37,209.62,2698826,197.12945697720002,197.94061168049998,196.5352389968,197.7142429261,2698826,0.0,1.0\nVOO,2017-02-02,209.09,209.4,208.23,208.59,1661322,197.21434526009998,197.5067382346,196.4031905568,196.7427436884,1661322,0.0,1.0\nVOO,2017-02-03,210.64,210.73,209.77,210.08,4264496,198.6763101324,198.7611984153,197.85572339759997,198.1481163721,4264496,0.0,1.0\nVOO,2017-02-06,210.2,210.5425,209.84,210.12,1345575,198.2613007493,198.5843478259,197.9217476177,198.1858444978,1345575,0.0,1.0\nVOO,2017-02-07,210.25,210.8368,209.99,210.65,1194607,198.30846090650002,198.861932511,198.06322808919998,198.6857421639,1194607,0.0,1.0\nVOO,2017-02-08,210.52,210.61,209.63,209.89,1096151,198.5631257552,198.6480140381,197.7236749576,197.9689077748,1096151,0.0,1.0\nVOO,2017-02-09,211.72,212.0432,210.74,210.78,1237378,199.6949695273,199.99981278330003,198.7706304468,198.8083585725,1237378,0.0,1.0\nVOO,2017-02-10,212.57,212.8,212.01,212.13,1412364,200.49669219919997,200.7136289222,199.96849843889999,200.0816828161,1412364,0.0,1.0\nVOO,2017-02-13,213.7,214.0,213.07,213.11,1756259,201.5625117513,201.84547269439997,200.968293771,201.0060218967,1756259,0.0,1.0\nVOO,2017-02-14,214.59,214.59,213.16,213.55,2528144,202.40196254900002,202.40196254900002,201.0531820539,201.4210312798,2528144,0.0,1.0\nVOO,2017-02-15,215.68,215.89,214.31,214.34,2050241,203.43005397529998,203.6281266354,202.1378656688,202.1661617631,2050241,0.0,1.0\nVOO,2017-02-16,215.51,215.9,214.73,215.74,1920430,203.2697094409,203.6375586669,202.5340109891,203.4866461639,1920430,0.0,1.0\nVOO,2017-02-17,215.84,215.84,214.7938,214.84,1452593,203.5809664783,203.5809664783,202.5941873496,202.63776333479998,1452593,0.0,1.0\nVOO,2017-02-21,217.11,217.32,216.2475,216.32,1893172,204.7788344704,204.9769071305,203.96532175919998,204.03370398709998,1893172,0.0,1.0\nVOO,2017-02-22,217.01,217.19,216.55,216.72,1471519,204.6845141561,204.85429072189999,204.2506407101,204.4109852445,1471519,0.0,1.0\nVOO,2017-02-23,217.13,217.54,216.3,217.54,1717171,204.7976985333,205.1844118221,204.01483992419998,205.1844118221,1717171,0.0,1.0\nVOO,2017-02-24,217.39,217.42,216.16,216.22,1637987,205.0429313506,205.07122744490002,203.88279148419997,203.93938367279998,1637987,0.0,1.0\nVOO,2017-02-27,217.71,217.8956,217.02,217.32,1942241,205.34475635650003,205.5198148599,204.6939461875,204.9769071305,1942241,0.0,1.0\nVOO,2017-02-28,217.07,217.58,216.72,217.34,2181041,204.7411063447,205.22213994779997,204.4109852445,204.9957711934,2181041,0.0,1.0\nVOO,2017-03-01,220.15,220.66,218.87,218.9,3325472,207.6461720265,208.12720562959998,206.4388720029,206.46716809720002,3325472,0.0,1.0\nVOO,2017-03-02,218.86,220.02,218.75,219.99,1657102,206.4294399714,207.52355561779999,206.3256876257,207.49525952349998,1657102,0.0,1.0\nVOO,2017-03-03,218.98,219.09,218.308,218.7,1765867,206.54262434860001,206.64637669439998,205.9087918363,206.2785274685,1765867,0.0,1.0\nVOO,2017-03-06,218.3,218.648,217.63,218.07,1530991,205.90124621110002,206.229480905,205.269300105,205.6843094881,1530991,0.0,1.0\nVOO,2017-03-07,217.64,218.33,217.42,217.98,1743154,205.27873213639998,205.92954230540002,205.07122744490002,205.5994212052,1743154,0.0,1.0\nVOO,2017-03-08,217.18,218.2,217.07,217.95,1784358,204.8448586905,205.8069258968,204.7411063447,205.5711251109,1784358,0.0,1.0\nVOO,2017-03-09,217.41,217.836,216.4602,217.33,1636234,205.0617954135,205.4635999526,204.1659410678,204.986339162,1636234,0.0,1.0\nVOO,2017-03-10,218.17,218.56,217.26,218.55,1819935,205.7786298025,206.1464790284,204.9203149419,206.137046997,1819935,0.0,1.0\nVOO,2017-03-13,218.3,218.41,217.86,218.18,1388168,205.90124621110002,206.00499855689998,205.486236828,205.7880618339,1388168,0.0,1.0\nVOO,2017-03-14,217.56,217.81,216.89,217.77,1428401,205.203275885,205.4390766708,204.5713297789,205.40134854509998,1428401,0.0,1.0\nVOO,2017-03-15,219.37,219.85,217.87,218.13,3358849,206.9104735746,207.3632110834,205.49566885939998,205.7409016767,3358849,0.0,1.0\nVOO,2017-03-16,219.0,219.65,218.651,219.61,1710910,206.56148841150002,207.17457045470002,206.2323105145,207.13684232900002,1710910,0.0,1.0\nVOO,2017-03-17,218.56,219.44,218.56,219.3,1588569,206.1464790284,206.9764977946,206.1464790284,206.8444493545,1588569,0.0,1.0\nVOO,2017-03-20,218.3327,218.89,217.9795,218.59,1035933,205.9320889539,206.4577360657,205.5989496036,206.1747751227,1035933,0.0,1.0\nVOO,2017-03-21,215.58,219.13,215.41,219.0,3468676,203.33573366099998,206.6841048202,203.1753891266,206.56148841150002,3468676,0.0,1.0\nVOO,2017-03-22,215.02,215.3448,213.95,214.58,2600312,203.74885663779997,204.05663093150002,202.7349450175,203.3319210182,2600312,0.998,1.0\nVOO,2017-03-23,214.8,216.0249,214.44,214.8,2436713,203.540388828,204.701080738,203.19925968470002,203.540388828,2436713,0.0,1.0\nVOO,2017-03-24,214.73,215.77,213.88,215.14,2647642,203.4740581613,204.459542353,202.6686143507,203.86256635220002,2647642,0.0,1.0\nVOO,2017-03-27,214.38,214.72,212.62,212.87,2082864,203.1424048275,203.4645823517,201.47466234919997,201.71155758759997,2082864,0.0,1.0\nVOO,2017-03-28,215.97,216.46,214.05,214.16,3057247,204.6490585437,205.1133732109,202.8297031128,202.9339370177,3057247,0.0,1.0\nVOO,2017-03-29,216.29,216.47,215.48,215.72,1748673,204.95228444880001,205.12284902049998,204.1847438764,204.4121633053,1748673,0.0,1.0\nVOO,2017-03-30,216.87,217.13,215.98,216.17,2494842,205.5018814019,205.7482524498,204.6585343532,204.8385747344,2494842,0.0,1.0\nVOO,2017-03-31,216.35,217.1097,216.35,216.56,1331402,205.0091393061,205.72901655650003,205.0091393061,205.2081313063,1331402,0.0,1.0\nVOO,2017-04-03,216.08,216.69,214.74,216.51,2843874,204.7532924486,205.33131683029998,203.4835339708,205.1607522586,2843874,0.0,1.0\nVOO,2017-04-04,216.2,216.25,215.33,215.71,2481598,204.867002163,204.9143812107,204.0426067334,204.40268749580002,2481598,0.0,1.0\nVOO,2017-04-05,215.54,217.93,215.34,216.89,2311797,204.2415987337,206.5063172127,204.0520825429,205.520833021,2311797,0.0,1.0\nVOO,2017-04-06,216.1,216.6834,215.22,215.71,1635457,204.7722440677,205.325062796,203.93837282849998,204.40268749580002,1635457,0.0,1.0\nVOO,2017-04-07,215.91,216.64,215.4,215.86,1835945,204.59220368650003,205.2839377826,204.1089374002,204.5448246388,1835945,0.0,1.0\nVOO,2017-04-10,216.06,216.88,215.5,216.06,1311885,204.7343408295,205.5113572114,204.2036954955,204.7343408295,1311885,0.0,1.0\nVOO,2017-04-11,215.79,215.88,214.25,215.63,1787316,204.478493972,204.5637762579,203.0192193035,204.3268810195,1787316,0.0,1.0\nVOO,2017-04-12,214.9,215.6899,214.62,215.49,2002741,203.63514692340001,204.3836411186,203.3698242564,204.194219686,2002741,0.0,1.0\nVOO,2017-04-13,213.47,215.24,213.47,214.5,1806823,202.2801061598,203.95732444759997,202.2801061598,203.2561145419,1806823,0.0,1.0\nVOO,2017-04-17,215.34,215.35,213.8,214.0,1746903,204.0520825429,204.0615583525,202.5928078744,202.78232406509997,1746903,0.0,1.0\nVOO,2017-04-18,214.75,215.24,213.9775,214.55,2696140,203.49300978029999,203.95732444759997,202.7610034937,203.30349358959998,2696140,0.0,1.0\nVOO,2017-04-19,214.28,215.7,214.06,215.31,2032384,203.0476467322,204.39321168619998,202.8391789224,204.0236551143,2032384,0.0,1.0\nVOO,2017-04-20,216.03,216.5,214.62,214.98,2734329,204.7059134009,205.1512764491,203.3698242564,203.7109533997,2734329,0.0,1.0\nVOO,2017-04-21,215.4,216.02,214.93,215.97,2594355,204.1089374002,204.69643759139998,203.663574352,204.6490585437,2594355,0.0,1.0\nVOO,2017-04-24,217.7,217.95,217.2299,217.75,2366350,206.2883735934,206.5252688318,205.8429157871,206.33575264110002,2366350,0.0,1.0\nVOO,2017-04-25,219.01,219.36,218.29,218.41,1745507,207.5297046426,207.8613579763,206.84744635599998,206.96115607040002,1745507,0.0,1.0\nVOO,2017-04-26,218.85,219.8979,218.81,218.96,2824460,207.37809169,208.37106177119998,207.3401884518,207.4823255949,2824460,0.0,1.0\nVOO,2017-04-27,219.02,219.3567,218.47,219.2,1154178,207.5391804521,207.85823095919997,207.0180109276,207.70974502369998,1154178,0.0,1.0\nVOO,2017-04-28,218.6,219.3399,218.43,219.33,1257631,207.1411964516,207.8423115991,206.98010768950002,207.83293054770002,1257631,0.0,1.0\nVOO,2017-05-01,219.13,219.5729,218.65,219.16,1506653,207.643414357,208.0630979613,207.18857549930001,207.6718417856,1506653,0.0,1.0\nVOO,2017-05-02,219.2,219.38,218.77,219.29,2573012,207.70974502369998,207.8803095954,207.3022852137,207.7950273096,2573012,0.0,1.0\nVOO,2017-05-03,218.98,219.17,218.24,218.78,1654565,207.50127721400003,207.68131759509998,206.80006730830002,207.3117610232,1654565,0.0,1.0\nVOO,2017-05-04,219.18,219.35,218.3098,219.27,1439117,207.6907934047,207.8518821668,206.8662084589,207.7760756905,1439117,0.0,1.0\nVOO,2017-05-05,220.06,220.06,219.12,219.56,1729796,208.5246646438,208.5246646438,207.6339385475,208.05087416700002,1729796,0.0,1.0\nVOO,2017-05-08,220.07,220.25,219.57,220.13,2225028,208.5341404534,208.704705025,208.0603499766,208.59099531060002,2225028,0.0,1.0\nVOO,2017-05-09,219.79,220.4857,219.44,220.29,1637189,208.2688177864,208.92804985580003,207.93716445259997,208.7426082631,1637189,0.0,1.0\nVOO,2017-05-10,220.2,220.22,219.55,219.76,1067300,208.6573259773,208.67627759639998,208.0413983575,208.2403903577,1067300,0.0,1.0\nVOO,2017-05-11,219.75,219.93,218.62,219.74,1855740,208.2309145482,208.40147911990002,207.1601480707,208.2214387387,1855740,0.0,1.0\nVOO,2017-05-12,219.41,219.6,219.12,219.5,1056500,207.908737024,208.0887774052,207.6339385475,207.9940193098,1056500,0.0,1.0\nVOO,2017-05-15,220.52,220.72,219.83,219.88,1441038,208.9605518825,209.1500680732,208.3067210245,208.35410007220003,1441038,0.0,1.0\nVOO,2017-05-16,220.48,220.94,220.0,220.88,2452545,208.9226486443,209.358535883,208.4678097866,209.3016810257,2452545,0.0,1.0\nVOO,2017-05-17,216.57,219.07,216.42,218.61,3507715,205.2176071158,207.5865594998,205.0754699728,207.1506722611,3507715,0.0,1.0\nVOO,2017-05-18,217.36,218.24,216.1301,216.33,2894833,205.96619606919998,206.80006730830002,204.80076625439997,204.990187687,2894833,0.0,1.0\nVOO,2017-05-19,218.71,219.4679,217.825,217.88,2436492,207.2454303565,207.9636019612,206.4068212126,206.458938165,2436492,0.0,1.0\nTUR,2017-01-03,31.41,31.61,31.3,31.61,769510,28.4375888322,28.6186623045,28.3379984224,28.6186623045,769510,0.0,1.0\nTUR,2017-01-04,31.46,31.46,31.2,31.28,340208,28.482857200300003,28.482857200300003,28.247461686199998,28.3198910752,340208,0.0,1.0\nTUR,2017-01-05,31.33,31.36,30.9,31.01,729530,28.365159443200003,28.3923204641,27.9758514777,28.0754418875,729530,0.0,1.0\nTUR,2017-01-06,31.02,31.28,31.02,31.18,243982,28.084495561100002,28.3198910752,28.084495561100002,28.229354339,243982,0.0,1.0\nTUR,2017-01-09,30.62,30.74,30.41,30.49,298368,27.722348616399998,27.8309926998,27.5322214704,27.6046508594,298368,0.0,1.0\nTUR,2017-01-10,30.0,30.28,30.0,30.23,708775,27.161020852100002,27.4145237134,27.161020852100002,27.3692553453,708775,0.0,1.0\nTUR,2017-01-11,29.96,29.99,28.98,29.33,1292448,27.1248061577,27.151967178499998,26.237546143200003,26.5544247198,1292448,0.0,1.0\nTUR,2017-01-12,31.68,31.74,31.34,31.34,1252215,28.6820380199,28.7363600616,28.374213116900002,28.374213116900002,1252215,0.0,1.0\nTUR,2017-01-13,32.57,32.57,31.5,31.65,929015,29.4878149718,29.4878149718,28.519071894699998,28.654876999000006,929015,0.0,1.0\nTUR,2017-01-17,32.24,32.29,31.97,32.11,778686,29.1890437424,29.234312110500003,28.944594554800002,29.0713459854,778686,0.0,1.0\nTUR,2017-01-18,32.17,32.37,32.0303,32.16,1265242,29.1256680271,29.3067414995,28.9991882067,29.116614353499997,1265242,0.0,1.0\nTUR,2017-01-19,31.55,31.69,31.4,31.57,362610,28.564340262800002,28.6910916935,28.428535158600006,28.5824476101,362610,0.0,1.0\nTUR,2017-01-20,32.39,32.4,31.93,31.95,545336,29.3248488467,29.3339025203,28.908379860300002,28.9264872075,545336,0.0,1.0\nTUR,2017-01-23,32.76,32.83,32.22,32.31,715477,29.6598347705,29.7232104859,29.1709363952,29.2524194577,715477,0.0,1.0\nTUR,2017-01-24,32.83,33.06,32.8214,32.89,394686,29.7232104859,29.931444979000002,29.715424326500006,29.777532527600002,394686,0.0,1.0\nTUR,2017-01-25,32.09,32.11,31.75,31.82,727668,29.053238638200003,29.0713459854,28.7454137352,28.8087894505,727668,0.0,1.0\nTUR,2017-01-26,31.95,32.19,31.9,32.01,208042,28.9264872075,29.1437753743,28.8812188394,28.9808092492,208042,0.0,1.0\nTUR,2017-01-27,32.0,32.05,31.8,31.92,169141,28.9717555756,29.0170239437,28.7906821033,28.8993261867,169141,0.0,1.0\nTUR,2017-01-30,33.6,33.67,33.05,33.29,792756,30.4203433544,30.483719069699998,29.922391305399998,30.139679472199997,792756,0.0,1.0\nTUR,2017-01-31,33.61,33.75,33.47,33.73,300296,30.429397028,30.556148458600006,30.302645597399998,30.5380411114,300296,0.0,1.0\nTUR,2017-02-01,33.78,33.89,33.56,33.77,335291,30.5833094795,30.6828998893,30.384128659899996,30.574255805900002,335291,0.0,1.0\nTUR,2017-02-02,34.38,34.5,34.22,34.33,368214,31.1265298965,31.23517398,30.9816711187,31.0812615285,368214,0.0,1.0\nTUR,2017-02-03,35.17,35.34,34.93,34.96,956937,31.8417701123,31.995682563800006,31.6244819455,31.651642966399997,956937,0.0,1.0\nTUR,2017-02-06,34.82,35.01,34.6918,35.01,280813,31.5248915357,31.6969113344,31.408823439899997,31.6969113344,280813,0.0,1.0\nTUR,2017-02-07,34.23,34.3899,34.11,34.33,338618,30.9907247923,31.135493033400003,30.8820807089,31.0812615285,338618,0.0,1.0\nTUR,2017-02-08,34.95,35.15,34.7,34.72,379667,31.6425892927,31.823662765100003,31.4162474523,31.4343547995,379667,0.0,1.0\nTUR,2017-02-09,35.47,35.54,35.31,35.45,435312,32.1133803208,32.1767560362,31.968521543,32.095272973600004,435312,0.0,1.0\nTUR,2017-02-10,34.97,35.05,34.56,34.8,399209,31.66069664,31.7331260289,31.2894960217,31.5067841885,399209,0.0,1.0\nTUR,2017-02-13,35.5,35.55,35.3101,35.42,375674,32.1405413417,32.1858097098,31.968612079699998,32.0681119528,375674,0.0,1.0\nTUR,2017-02-14,35.48,35.6,35.04,35.56,533090,32.1224339945,32.2310780779,31.724072355300002,32.194863383400005,533090,0.0,1.0\nTUR,2017-02-15,35.55,35.64,34.96,35.04,618913,32.1858097098,32.2672927723,31.651642966399997,31.724072355300002,618913,0.0,1.0\nTUR,2017-02-16,35.27,35.56,35.24,35.45,560524,31.932306848499998,32.194863383400005,31.905145827600002,32.095272973600004,560524,0.0,1.0\nTUR,2017-02-17,36.06,36.14,35.7301,35.8,346988,32.6475470643,32.719976453200005,32.348866371599996,32.4121515502,346988,0.0,1.0\nTUR,2017-02-21,36.24,36.299,35.975,36.05,208163,32.8105131894,32.8639298637,32.570590838499996,32.6384933906,208163,0.0,1.0\nTUR,2017-02-22,36.46,36.5,35.95,36.22,347618,33.009694009,33.0459087034,32.5479566545,32.7924058421,347618,0.0,1.0\nTUR,2017-02-23,36.7,36.82,36.63,36.74,334416,33.226982175799996,33.3356262592,33.1636064605,33.2631968702,334416,0.0,1.0\nTUR,2017-02-24,35.95,36.2,35.89,36.13,328845,32.5479566545,32.774298494899995,32.4936346128,32.7109227796,328845,0.0,1.0\nTUR,2017-02-27,35.64,36.0,35.61,35.95,231613,32.2672927723,32.5932250226,32.2401317515,32.5479566545,231613,0.0,1.0\nTUR,2017-02-28,35.14,35.56,35.1,35.55,268095,31.8146090915,32.194863383400005,31.778394397,32.1858097098,268095,0.0,1.0\nTUR,2017-03-01,35.9,36.0399,35.58,35.58,302041,32.5026882864,32.629349180300004,32.2129707306,32.2129707306,302041,0.0,1.0\nTUR,2017-03-02,35.17,35.6,35.1388,35.51,480148,31.8417701123,32.2310780779,31.8135226506,32.1495950153,480148,0.0,1.0\nTUR,2017-03-03,35.61,35.63,35.11,35.22,334195,32.2401317515,32.258239098699995,31.7874480706,31.8870384804,334195,0.0,1.0\nTUR,2017-03-06,35.99,36.14,35.88,36.03,612655,32.5841713489,32.719976453200005,32.4845809392,32.6203860434,612655,0.0,1.0\nTUR,2017-03-07,36.29,36.31,36.14,36.14,236071,32.8557815575,32.873888904699996,32.719976453200005,32.719976453200005,236071,0.0,1.0\nTUR,2017-03-08,34.79,35.27,34.75,35.07,498017,31.4977305149,31.932306848499998,31.4615158204,31.7512333761,498017,0.0,1.0\nTUR,2017-03-09,34.36,34.93,34.16,34.92,370147,31.1084225493,31.6244819455,30.927349077,31.6154282719,370147,0.0,1.0\nTUR,2017-03-10,35.21,35.3,34.92,35.16,294144,31.877984806799997,31.9594678693,31.6154282719,31.8327164387,294144,0.0,1.0\nTUR,2017-03-13,35.11,35.2,34.85,34.85,280569,31.7874480706,31.8689311332,31.5520525566,31.5520525566,280569,0.0,1.0\nTUR,2017-03-14,34.92,34.99,34.8,34.96,305069,31.6154282719,31.6788039872,31.5067841885,31.651642966399997,305069,0.0,1.0\nTUR,2017-03-15,36.0,36.04,35.11,35.26,607448,32.5932250226,32.629439717,31.7874480706,31.923253174899997,607448,0.0,1.0\nTUR,2017-03-16,36.55,36.67,36.15,36.29,626374,33.0911770715,33.1998211549,32.7290301268,32.8557815575,626374,0.0,1.0\nTUR,2017-03-17,36.63,36.63,36.49,36.57,106371,33.1636064605,33.1636064605,33.0368550298,33.1092844187,106371,0.0,1.0\nTUR,2017-03-20,36.8315,36.88,36.45,36.52,178820,33.3460379838,33.3899483009,33.000640335300005,33.064016050700005,178820,0.0,1.0\nTUR,2017-03-21,36.29,37.02,36.2599,36.86,273904,32.8557815575,33.5166997315,32.8285299999,33.3718409537,273904,0.0,1.0\nTUR,2017-03-22,36.32,36.49,36.055,36.11,219541,32.882942578299996,33.0368550298,32.6430202275,32.6928154323,219541,0.0,1.0\nTUR,2017-03-23,36.14,36.39,36.03,36.31,344022,32.719976453200005,32.9463182936,32.6203860434,32.873888904699996,344022,0.0,1.0\nTUR,2017-03-24,36.73,36.825,36.3,36.3,349123,33.2541431966,33.340153096,32.8648352311,32.8648352311,349123,0.0,1.0\nTUR,2017-03-27,36.45,36.53,36.25,36.49,243416,33.000640335300005,33.0730697243,32.819566863,33.0368550298,243416,0.0,1.0\nTUR,2017-03-28,35.72,36.52,35.7,36.38,1021938,32.3397221613,33.064016050700005,32.321614814,32.93726462,1021938,0.0,1.0\nTUR,2017-03-29,35.88,35.94,35.59,35.93,501530,32.4845809392,32.538902980900005,32.2220244042,32.529849307199996,501530,0.0,1.0\nTUR,2017-03-30,35.82,36.1,35.82,36.02,335347,32.430258897399995,32.6837617587,32.430258897399995,32.6113323698,335347,0.0,1.0\nTUR,2017-03-31,35.8,35.97,35.73,35.88,300363,32.4121515502,32.5660640017,32.348775834899996,32.4845809392,300363,0.0,1.0\nTUR,2017-04-03,35.86,35.97,35.63,35.92,381326,32.4664735919,32.5660640017,32.258239098699995,32.5207956336,381326,0.0,1.0\nTUR,2017-04-04,35.57,35.77,35.42,35.73,261785,32.203917057,32.3849905294,32.0681119528,32.348775834899996,261785,0.0,1.0\nTUR,2017-04-05,35.41,35.715,35.41,35.64,775459,32.0590582791,32.3351953245,32.0590582791,32.2672927723,775459,0.0,1.0\nTUR,2017-04-06,35.32,35.53,35.23,35.48,107646,31.9775752166,32.1677023625,31.896092154,32.1224339945,107646,0.0,1.0\nTUR,2017-04-07,35.25,35.2899,35.1,35.23,426221,31.9141995013,31.950323659000002,31.778394397,31.896092154,426221,0.0,1.0\nTUR,2017-04-10,36.3,36.44,36.18,36.19,423021,32.8648352311,32.9915866617,32.7561911477,32.7652448213,423021,0.0,1.0\nTUR,2017-04-11,36.72,36.76,36.35,36.51,412031,33.245089523000004,33.2813042175,32.9101035992,33.054962376999995,412031,0.0,1.0\nTUR,2017-04-12,37.17,37.18,36.87,36.88,551784,33.6525048358,33.6615585094,33.3808946273,33.3899483009,551784,0.0,1.0\nTUR,2017-04-13,36.37,36.73,36.29,36.61,418125,32.9282109464,33.2541431966,32.8557815575,33.1454991132,418125,0.0,1.0\nTUR,2017-04-17,36.32,36.96,36.25,36.8,494893,32.882942578299996,33.4623776898,32.819566863,33.3175189119,494893,0.0,1.0\nTUR,2017-04-18,37.06,37.1,36.7,36.8,426477,33.552914426,33.589129120500004,33.226982175799996,33.3175189119,426477,0.0,1.0\nTUR,2017-04-19,36.53,36.9772,36.41,36.85,320426,33.0730697243,33.4779500084,32.964425640900004,33.36278728,320426,0.0,1.0\nTUR,2017-04-20,37.65,37.69,37.45,37.45,560304,34.0870811694,34.1232958639,33.9060076971,33.9060076971,560304,0.0,1.0\nTUR,2017-04-21,37.78,37.855,37.67,37.8,380652,34.2047789265,34.2726814786,34.105188516700004,34.2228862737,380652,0.0,1.0\nTUR,2017-04-24,39.26,39.3,38.65,38.75,637634,35.5447226218,35.5809373163,34.9924485312,35.0829852673,637634,0.0,1.0\nTUR,2017-04-25,39.47,39.53,38.87,38.98,411176,35.7348497678,35.7891718095,35.191629350700005,35.2912197605,411176,0.0,1.0\nTUR,2017-04-26,39.35,39.54,39.13,39.44,446782,35.6262056844,35.7982254831,35.4270248648,35.7076887469,446782,0.0,1.0\nTUR,2017-04-27,39.52,39.7896,39.38,39.73,316266,35.780118135900004,36.0242051766,35.6533667052,35.9702452818,316266,0.0,1.0\nTUR,2017-04-28,39.77,39.89,39.65,39.8,392497,36.0064599763,36.115104059699995,35.8978158929,36.033620997199996,392497,0.0,1.0\nTUR,2017-05-01,39.64,39.85,39.57,39.76,895722,35.8887622193,36.0788893652,35.825386504,35.9974063027,895722,0.0,1.0\nTUR,2017-05-02,39.87,40.03,39.74,39.85,453212,36.0969967125,36.2418554904,35.9792989555,36.0788893652,453212,0.0,1.0\nTUR,2017-05-03,39.48,39.7,39.355,39.53,457465,35.7439034414,35.943084260999996,35.6307325212,35.7891718095,457465,0.0,1.0\nTUR,2017-05-04,38.87,39.21,38.79,38.99,261983,35.191629350700005,35.4994542537,35.1191999618,35.3002734342,261983,0.0,1.0\nTUR,2017-05-05,39.7,39.705,39.06,39.06,287300,35.943084260999996,35.9476110978,35.3636491495,35.3636491495,287300,0.0,1.0\nTUR,2017-05-08,38.78,39.455,38.7406,39.45,268787,35.1101462882,35.721269257399996,35.0744748141,35.7167424206,268787,0.0,1.0\nTUR,2017-05-09,39.06,39.66,38.95,39.23,511817,35.3636491495,35.9068695665,35.2640587397,35.517561601,511817,0.0,1.0\nTUR,2017-05-10,40.09,40.11,39.77,39.77,382703,36.2961775321,36.3142848793,36.0064599763,36.0064599763,382703,0.0,1.0\nTUR,2017-05-11,39.65,39.945,39.41,39.85,315784,35.8978158929,36.1648992646,35.6805277261,36.0788893652,315784,0.0,1.0\nTUR,2017-05-12,39.57,39.69,39.44,39.63,182478,35.825386504,35.934030587399995,35.7076887469,35.879708545700005,182478,0.0,1.0\nTUR,2017-05-15,40.41,40.41,39.88,39.93,185100,36.585895087800004,36.585895087800004,36.1060503861,36.1513187542,185100,0.0,1.0\nTUR,2017-05-16,40.5,40.52,40.2,40.29,221672,36.6673781504,36.685485497600006,36.3957679419,36.4772510044,221672,0.0,1.0\nTUR,2017-05-17,39.6,40.04,39.6,39.92,201091,35.8525475248,36.250909164,35.8525475248,36.1422650806,201091,0.0,1.0\nTUR,2017-05-18,38.97,39.25,38.71,38.81,346173,35.2821660869,35.535668948200005,35.0467705729,35.137307309,346173,0.0,1.0\nTUR,2017-05-19,40.18,40.29,39.41,39.41,581223,36.3776605946,36.4772510044,35.6805277261,35.6805277261,581223,0.0,1.0\nRSX,2017-01-03,21.62,21.93,21.455,21.81,15898705,18.651536939,18.9189734076,18.5091917218,18.8154496133,15898705,0.0,1.0\nRSX,2017-01-04,21.76,21.82,21.55,21.57,8096706,18.772314699000002,18.8240765961,18.591148059000002,18.608402024700002,8096706,0.0,1.0\nRSX,2017-01-05,21.67,21.69,21.405,21.58,9250698,18.6946718533,18.711925819,18.4660568075,18.6170290075,9250698,0.0,1.0\nRSX,2017-01-06,21.52,21.65,21.5,21.55,5473716,18.5652671104,18.6774178875,18.5480131447,18.591148059000002,5473716,0.0,1.0\nRSX,2017-01-09,21.44,21.5499,21.37,21.41,7413704,18.4962512475,18.5910617891,18.4358623675,18.470370299000002,7413704,0.0,1.0\nRSX,2017-01-10,21.48,21.66,21.39,21.63,5300210,18.530759179,18.6860448704,18.4531163332,18.660163921800002,5300210,0.0,1.0\nRSX,2017-01-11,21.71,21.79,21.24,21.35,12932800,18.7291797847,18.7981956476,18.3237115904,18.4186084018,12932800,0.0,1.0\nRSX,2017-01-12,21.71,21.7505,21.635,21.67,5833126,18.7291797847,18.7641190653,18.6644774133,18.6946718533,5833126,0.0,1.0\nRSX,2017-01-13,21.45,21.485,21.35,21.36,7751121,18.5048782304,18.535072670399998,18.4186084018,18.4272353847,7751121,0.0,1.0\nRSX,2017-01-17,21.28,21.46,21.25,21.43,6317701,18.358219521800002,18.5135052132,18.332338573199998,18.4876242647,6317701,0.0,1.0\nRSX,2017-01-18,21.18,21.43,21.165,21.3,6613795,18.2719496932,18.4876242647,18.2590092189,18.3754734875,6613795,0.0,1.0\nRSX,2017-01-19,21.0,21.075,20.91,21.06,8870235,18.1166640018,18.1813663732,18.0390211561,18.1684258989,8870235,0.0,1.0\nRSX,2017-01-20,21.08,21.19,20.95,21.1,6871495,18.1856798646,18.280576676099997,18.0735290875,18.2029338304,6871495,0.0,1.0\nRSX,2017-01-23,21.17,21.1875,21.01,21.05,5669015,18.2633227104,18.2784199304,18.1252909846,18.159798916099998,5669015,0.0,1.0\nRSX,2017-01-24,21.43,21.52,21.389,21.41,6782047,18.4876242647,18.5652671104,18.4522536349,18.470370299000002,6782047,0.0,1.0\nRSX,2017-01-25,21.52,21.67,21.42,21.54,12197561,18.5652671104,18.6946718533,18.4789972818,18.582521076099997,12197561,0.0,1.0\nRSX,2017-01-26,21.54,21.65,21.48,21.59,9694712,18.582521076099997,18.6774178875,18.530759179,18.6256559904,9694712,0.0,1.0\nRSX,2017-01-27,22.08,22.185,21.945,22.07,17130771,19.0483781504,19.138961470399998,18.9319138819,19.0397511676,17130771,0.0,1.0\nRSX,2017-01-30,21.69,21.77,21.64,21.71,9445013,18.711925819,18.7809416818,18.6687909047,18.7291797847,9445013,0.0,1.0\nRSX,2017-01-31,21.38,21.63,21.24,21.58,10021334,18.4444893504,18.660163921800002,18.3237115904,18.6170290075,10021334,0.0,1.0\nRSX,2017-02-01,21.6,21.61,21.4,21.52,11542104,18.6342829733,18.6429099561,18.461743316099998,18.5652671104,11542104,0.0,1.0\nRSX,2017-02-02,21.77,22.0799,21.45,21.61,24759271,18.7809416818,19.0482918806,18.5048782304,18.6429099561,24759271,0.0,1.0\nRSX,2017-02-03,21.8,21.89,21.74,21.83,6186703,18.8068226304,18.884465476099997,18.7550607333,18.832703579,6186703,0.0,1.0\nRSX,2017-02-06,21.7,21.755,21.62,21.74,4938021,18.7205528018,18.7680012076,18.651536939,18.7550607333,4938021,0.0,1.0\nRSX,2017-02-07,21.57,21.695,21.56,21.62,4217974,18.608402024700002,18.7162393104,18.5997750418,18.651536939,4217974,0.0,1.0\nRSX,2017-02-08,21.46,21.54,21.355,21.46,7170955,18.5135052132,18.582521076099997,18.422921893199998,18.5135052132,7170955,0.0,1.0\nRSX,2017-02-09,21.48,21.5,21.42,21.45,3488010,18.530759179,18.5480131447,18.4789972818,18.5048782304,3488010,0.0,1.0\nRSX,2017-02-10,21.73,21.74,21.5,21.56,6949334,18.7464337504,18.7550607333,18.5480131447,18.5997750418,6949334,0.0,1.0\nRSX,2017-02-13,21.7,21.73,21.59,21.64,3070461,18.7205528018,18.7464337504,18.6256559904,18.6687909047,3070461,0.0,1.0\nRSX,2017-02-14,21.73,21.78,21.54,21.77,5545600,18.7464337504,18.7895686647,18.582521076099997,18.7809416818,5545600,0.0,1.0\nRSX,2017-02-15,21.62,21.71,21.57,21.62,7209385,18.651536939,18.7291797847,18.608402024700002,18.651536939,7209385,0.0,1.0\nRSX,2017-02-16,21.59,21.67,21.54,21.64,4925856,18.6256559904,18.6946718533,18.582521076099997,18.6687909047,4925856,0.0,1.0\nRSX,2017-02-17,21.22,21.34,21.11,21.32,9955996,18.3064576246,18.4099814189,18.2115608132,18.3927274532,9955996,0.0,1.0\nRSX,2017-02-21,21.45,21.47,21.29,21.41,9296268,18.5048782304,18.5221321961,18.3668465047,18.470370299000002,9296268,0.0,1.0\nRSX,2017-02-22,21.09,21.17,21.02,21.04,9390811,18.1943068475,18.2633227104,18.1339179675,18.1511719332,9390811,0.0,1.0\nRSX,2017-02-23,21.15,21.32,21.1,21.27,4446928,18.2460687446,18.3927274532,18.2029338304,18.3495925389,4446928,0.0,1.0\nRSX,2017-02-24,20.73,20.88,20.7,20.87,8709678,17.8837354646,18.0131402075,17.857854516,18.0045132246,8709678,0.0,1.0\nRSX,2017-02-27,20.59,20.81,20.58,20.81,5227599,17.7629577046,17.9527513275,17.7543307217,17.9527513275,5227599,0.0,1.0\nRSX,2017-02-28,20.17,20.41,20.135,20.39,13910723,17.4006244246,17.6076720132,17.3704299846,17.5904180474,13910723,0.0,1.0\nRSX,2017-03-01,20.58,20.59,20.43,20.45,10454233,17.7543307217,17.7629577046,17.624925978900002,17.6421799446,10454233,0.0,1.0\nRSX,2017-03-02,20.17,20.4674,20.16,20.36,9397352,17.4006244246,17.6571908948,17.3919974417,17.5645370989,9397352,0.0,1.0\nRSX,2017-03-03,20.56,20.6,20.28,20.32,8498723,17.737076756,17.7715846875,17.495521236,17.5300291674,8498723,0.0,1.0\nRSX,2017-03-06,20.35,20.58,20.3,20.57,8027701,17.555910116,17.7543307217,17.512775201700002,17.7457037389,8027701,0.0,1.0\nRSX,2017-03-07,20.08,20.225,20.05,20.2,9407595,17.3229815788,17.4480728303,17.2971006303,17.4265053731,9407595,0.0,1.0\nRSX,2017-03-08,19.57,20.09,19.57,20.06,10525469,16.8830054531,17.3316085617,16.8830054531,17.3057276131,10525469,0.0,1.0\nRSX,2017-03-09,19.34,19.4865,19.2,19.48,17671492,16.684584847300002,16.8109701462,16.5638070873,16.8053626074,17671492,0.0,1.0\nRSX,2017-03-10,19.5,19.56,19.42,19.54,9200745,16.8226165731,16.8743784702,16.7536007102,16.8571245045,9200745,0.0,1.0\nRSX,2017-03-13,19.89,19.925,19.66,19.68,10970611,17.1590689045,17.1892633445,16.960648298800002,16.9779022645,10970611,0.0,1.0\nRSX,2017-03-14,19.61,19.74,19.55,19.62,9201190,16.9175133845,17.0296641617,16.8657514874,16.9261403674,9201190,0.0,1.0\nRSX,2017-03-15,20.21,20.28,19.665,19.7,20415652,17.435132356,17.495521236,16.9649617902,16.9951562302,20415652,0.0,1.0\nRSX,2017-03-16,20.43,20.49,20.29,20.33,10949817,17.624925978900002,17.676687876,17.5041482189,17.5386561503,10949817,0.0,1.0\nRSX,2017-03-17,20.89,20.91,20.5,20.67,9848451,18.0217671903,18.0390211561,17.6853148589,17.8319735675,9848451,0.0,1.0\nRSX,2017-03-20,20.8592,20.88,20.67,20.69,10610087,17.995196083099998,18.0131402075,17.8319735675,17.8492275332,10610087,0.0,1.0\nRSX,2017-03-21,20.83,21.205,20.76,21.05,14800232,17.9700052932,18.2935171504,17.9096164132,18.159798916099998,14800232,0.0,1.0\nRSX,2017-03-22,20.9,20.935,20.54,20.63,7686976,18.030394173199998,18.0605886132,17.7198227903,17.797465636,7686976,0.0,1.0\nRSX,2017-03-23,20.82,20.92,20.72,20.79,5680910,17.9613783103,18.0476481389,17.8751084817,17.9354973618,5680910,0.0,1.0\nRSX,2017-03-24,20.95,20.98,20.83,20.94,5277584,18.0735290875,18.0994100361,17.9700052932,18.0649021046,5277584,0.0,1.0\nRSX,2017-03-27,20.74,20.805,20.51,20.58,6007564,17.8923624475,17.948437836,17.6939418417,17.7543307217,6007564,0.0,1.0\nRSX,2017-03-28,20.83,20.92,20.69,20.75,5436506,17.9700052932,18.0476481389,17.8492275332,17.9009894303,5436506,0.0,1.0\nRSX,2017-03-29,20.86,20.885,20.7,20.79,4507672,17.9958862418,18.017453698900002,17.857854516,17.9354973618,4507672,0.0,1.0\nRSX,2017-03-30,21.02,21.08,20.97,21.04,5461007,18.1339179675,18.1856798646,18.0907830532,18.1511719332,5461007,0.0,1.0\nRSX,2017-03-31,20.67,20.82,20.65,20.75,6274574,17.8319735675,17.9613783103,17.814719601700002,17.9009894303,6274574,0.0,1.0\nRSX,2017-04-03,20.93,20.94,20.74,20.76,5072889,18.056275121800002,18.0649021046,17.8923624475,17.9096164132,5072889,0.0,1.0\nRSX,2017-04-04,21.14,21.18,20.91,20.96,8265692,18.2374417618,18.2719496932,18.0390211561,18.082156070299998,8265692,0.0,1.0\nRSX,2017-04-05,21.3,21.51,21.28,21.41,10382967,18.3754734875,18.5566401275,18.358219521800002,18.470370299000002,10382967,0.0,1.0\nRSX,2017-04-06,21.28,21.4,21.27,21.32,4840431,18.358219521800002,18.461743316099998,18.3495925389,18.3927274532,4840431,0.0,1.0\nRSX,2017-04-07,20.6,20.79,20.58,20.71,15514706,17.7715846875,17.9354973618,17.7543307217,17.8664814989,15514706,0.0,1.0\nRSX,2017-04-10,20.1,20.24,20.09,20.21,12244630,17.3402355446,17.4610133046,17.3316085617,17.435132356,12244630,0.0,1.0\nRSX,2017-04-11,20.29,20.32,20.135,20.28,8250403,17.5041482189,17.5300291674,17.3704299846,17.495521236,8250403,0.0,1.0\nRSX,2017-04-12,19.98,20.18,19.75,20.17,25194252,17.2367117503,17.4092514074,17.0382911445,17.4006244246,25194252,0.0,1.0\nRSX,2017-04-13,20.06,20.2,20.02,20.05,7090711,17.3057276131,17.4265053731,17.2712196817,17.2971006303,7090711,0.0,1.0\nRSX,2017-04-17,20.34,20.38,20.11,20.12,7825427,17.5472831331,17.5817910646,17.3488625274,17.3574895103,7825427,0.0,1.0\nRSX,2017-04-18,20.01,20.2,19.98,20.11,8445772,17.262592698800002,17.4265053731,17.2367117503,17.3488625274,8445772,0.0,1.0\nRSX,2017-04-19,19.79,20.13,19.69,20.12,9953650,17.072799076,17.366116493099998,16.9865292474,17.3574895103,9953650,0.0,1.0\nRSX,2017-04-20,20.11,20.155,19.88,19.94,6663203,17.3488625274,17.3876839503,17.1504419217,17.2022038188,6663203,0.0,1.0\nRSX,2017-04-21,20.14,20.23,19.995,20.18,6097905,17.374743476,17.4523863217,17.2496522245,17.4092514074,6097905,0.0,1.0\nRSX,2017-04-24,20.62,20.67,20.51,20.54,10584206,17.7888386532,17.8319735675,17.6939418417,17.7198227903,10584206,0.0,1.0\nRSX,2017-04-25,20.8,20.825,20.67,20.75,5525407,17.9441243446,17.965691801800002,17.8319735675,17.9009894303,5525407,0.0,1.0\nRSX,2017-04-26,20.61,20.83,20.57,20.62,6696412,17.780211670299995,17.9700052932,17.7457037389,17.7888386532,6696412,0.0,1.0\nRSX,2017-04-27,20.82,20.82,20.55,20.7,8129919,17.9613783103,17.9613783103,17.728449773199998,17.857854516,8129919,0.0,1.0\nRSX,2017-04-28,20.88,20.9,20.795,20.82,7070832,18.0131402075,18.030394173199998,17.9398108532,17.9613783103,7070832,0.0,1.0\nRSX,2017-05-01,20.96,21.0,20.88,20.88,2586982,18.082156070299998,18.1166640018,18.0131402075,18.0131402075,2586982,0.0,1.0\nRSX,2017-05-02,20.8,21.05,20.77,21.03,7341473,17.9441243446,18.159798916099998,17.918243395999998,18.1425449503,7341473,0.0,1.0\nRSX,2017-05-03,20.41,20.59,20.4,20.53,8548974,17.6076720132,17.7629577046,17.5990450303,17.7111958074,8548974,0.0,1.0\nRSX,2017-05-04,20.0,20.35,19.97,20.33,10329752,17.253965716,17.555910116,17.2280847674,17.5386561503,10329752,0.0,1.0\nRSX,2017-05-05,20.32,20.32,19.965,19.97,8142464,17.5300291674,17.5300291674,17.223771276,17.2280847674,8142464,0.0,1.0\nRSX,2017-05-08,20.19,20.51,20.19,20.27,5989431,17.4178783903,17.6939418417,17.4178783903,17.4868942531,5989431,0.0,1.0\nRSX,2017-05-09,20.33,20.51,20.29,20.36,6756550,17.5386561503,17.6939418417,17.5041482189,17.5645370989,6756550,0.0,1.0\nRSX,2017-05-10,20.66,20.73,20.5,20.52,9000533,17.8233465846,17.8837354646,17.6853148589,17.7025688246,9000533,0.0,1.0\nRSX,2017-05-11,20.66,20.685,20.48,20.63,3675373,17.8233465846,17.8449140417,17.6680608932,17.797465636,3675373,0.0,1.0\nRSX,2017-05-12,20.46,20.51,20.35,20.48,5911904,17.6508069274,17.6939418417,17.555910116,17.6680608932,5911904,0.0,1.0\nRSX,2017-05-15,20.89,20.91,20.71,20.75,9206198,18.0217671903,18.0390211561,17.8664814989,17.9009894303,9206198,0.0,1.0\nRSX,2017-05-16,20.78,20.86,20.75,20.81,4902126,17.926870378900002,17.9958862418,17.9009894303,17.9527513275,4902126,0.0,1.0\nRSX,2017-05-17,20.42,20.71,20.42,20.6,9642976,17.616298995999998,17.8664814989,17.616298995999998,17.7715846875,9642976,0.0,1.0\nRSX,2017-05-18,20.08,20.22,19.9101,20.02,11947674,17.3229815788,17.4437593388,17.1764091401,17.2712196817,11947674,0.0,1.0\nRSX,2017-05-19,20.26,20.43,20.22,20.31,8915449,17.478267270299998,17.624925978900002,17.4437593388,17.5214021846,8915449,0.0,1.0\nEWY,2017-01-03,54.03,54.035,53.8,53.96,3059882,50.6642753183,50.6689638501,50.4486028525,50.5986358722,3059882,0.0,1.0\nEWY,2017-01-04,54.4,54.51,54.245,54.28,2253107,51.011226676199996,51.1143743772,50.8658821884,50.8987019114,2253107,0.0,1.0\nEWY,2017-01-05,54.94,55.07,54.66,54.73,2837613,51.517588117399995,51.6394899459,51.2550303331,51.3206697792,2837613,0.0,1.0\nEWY,2017-01-06,54.34,54.56,54.33,54.56,1695998,50.9549642938,51.1612596958,50.9455872301,51.1612596958,1695998,0.0,1.0\nEWY,2017-01-09,54.24,54.36,54.05,54.05,1467474,50.8611936565,50.9737184213,50.683029445699994,50.683029445699994,1467474,0.0,1.0\nEWY,2017-01-10,54.47,54.7,54.4,54.45,1408602,51.0768661223,51.292538588,51.011226676199996,51.058111994799994,1408602,0.0,1.0\nEWY,2017-01-11,55.99,56.04,55.23,55.48,3974761,52.5021798088,52.5490651274,51.78952296550001,52.0239495587,3974761,0.0,1.0\nEWY,2017-01-12,56.72,56.745,56.2274,56.24,3969075,53.1867054609,53.2101481202,52.7247913017,52.736606402,3969075,0.0,1.0\nEWY,2017-01-13,56.69,56.72,56.23,56.23,1499340,53.158574269700004,53.1867054609,52.7272293383,52.7272293383,1499340,0.0,1.0\nEWY,2017-01-17,57.08,57.08,56.895,56.91,2399983,53.524279755100004,53.524279755100004,53.3508040761,53.3648696717,2399983,0.0,1.0\nEWY,2017-01-18,56.26,56.71,56.165,56.59,2013990,52.755360529399994,53.1773283972,52.666278424,53.0648036324,2013990,0.0,1.0\nEWY,2017-01-19,56.16,56.32,55.94,56.32,2098721,52.66158989220001,52.8116229118,52.455294490200004,52.8116229118,2098721,0.0,1.0\nEWY,2017-01-20,56.16,56.36,56.03,56.18,2296381,52.66158989220001,52.8491311667,52.5396880637,52.6803440196,2296381,0.0,1.0\nEWY,2017-01-23,56.96,57.0,56.43,56.43,1497455,53.4117549903,53.449263245299996,52.9147706128,52.9147706128,1497455,0.0,1.0\nEWY,2017-01-24,56.94,57.1,56.92,57.0,1653851,53.3930008629,53.5430338825,53.3742467354,53.449263245299996,1653851,0.0,1.0\nEWY,2017-01-25,57.56,57.64,57.01,57.06,2456702,53.974378814,54.0493953238,53.458640308999996,53.5055256276,2456702,0.0,1.0\nEWY,2017-01-26,57.35,57.63,57.3399,57.56,2234615,53.777460475699996,54.04001826010001,53.7679896413,53.974378814,2234615,0.0,1.0\nEWY,2017-01-27,57.2,57.43,56.98,57.2,1868804,53.636804519799995,53.85247698550001,53.4305091178,53.636804519799995,1868804,0.0,1.0\nEWY,2017-01-30,57.12,57.12,56.75,56.97,1680311,53.56178801,53.56178801,53.2148366521,53.4211320541,1680311,0.0,1.0\nEWY,2017-01-31,57.88,58.02,57.66,57.67,2098691,54.274444853199995,54.4057237454,54.0681494512,54.077526515,2098691,0.0,1.0\nEWY,2017-02-01,57.9,58.0,57.69,57.93,2415200,54.2931989807,54.386969618,54.0962806424,54.321330171899994,2415200,0.0,1.0\nEWY,2017-02-02,58.44,58.625,58.31,58.47,2131798,54.799560422,54.9730361009,54.677658593500006,54.827691613199995,2131798,0.0,1.0\nEWY,2017-02-03,58.91,59.09,58.68,58.78,1677263,55.2402824172,55.4090695642,55.0246099514,55.1183805887,1677263,0.0,1.0\nEWY,2017-02-06,58.81,58.86,58.65,58.77,1417170,55.146511779899996,55.19339709850001,54.996478760200006,55.109003525,1417170,0.0,1.0\nEWY,2017-02-07,58.23,58.27,58.065,58.16,1487206,54.6026420837,54.640150338599994,54.4479205322,54.537002637600004,1487206,0.0,1.0\nEWY,2017-02-08,58.01,58.1,57.86,57.89,1925682,54.3963466817,54.480740255200004,54.2556907258,54.283821917,1925682,0.0,1.0\nEWY,2017-02-09,58.07,58.19,57.97,58.06,2017688,54.452609064099995,54.5651338288,54.358838426800006,54.4432320003,2017688,0.0,1.0\nEWY,2017-02-10,58.12,58.14,57.77,57.84,2746685,54.49949438270001,54.5182485102,54.17129715220001,54.2369365983,2746685,0.0,1.0\nEWY,2017-02-13,58.08,58.11,57.75,57.92,1790953,54.461986127799996,54.490117319,54.152543024799996,54.311953108199994,1790953,0.0,1.0\nEWY,2017-02-14,58.14,58.175,57.73,58.13,2282133,54.5182485102,54.5510682332,54.13378889729999,54.5088714464,2282133,0.0,1.0\nEWY,2017-02-15,58.8,58.83,58.08,58.16,1855981,55.1371347162,55.1652659073,54.461986127799996,54.537002637600004,1855981,0.0,1.0\nEWY,2017-02-16,58.15,58.43,58.115,58.39,2336929,54.5276255739,54.7901833582,54.4948058508,54.7526751033,2336929,0.0,1.0\nEWY,2017-02-17,57.97,57.99,57.68,57.71,1312623,54.358838426800006,54.3775925542,54.086903578699996,54.1150347699,1312623,0.0,1.0\nEWY,2017-02-21,59.07,59.1,58.71,58.8,1576532,55.3903154368,55.41844662800001,55.05274114260001,55.1371347162,1576532,0.0,1.0\nEWY,2017-02-22,59.2,59.23,58.84,58.95,1311914,55.51221726520001,55.540348456400004,55.1746429711,55.277790672100004,1311914,0.0,1.0\nEWY,2017-02-23,59.52,59.77,59.475,59.55,2118936,55.8122833045,56.046709897700005,55.7700865177,55.840414495699996,2118936,0.0,1.0\nEWY,2017-02-24,59.07,59.09,58.915,58.92,1007362,55.3903154368,55.4090695642,55.244970949,55.2496594809,1007362,0.0,1.0\nEWY,2017-02-27,58.67,58.88,58.51,58.79,1259655,55.0152328877,55.212151226,54.86519986810001,55.1277576524,1259655,0.0,1.0\nEWY,2017-02-28,58.56,59.05,58.49,59.03,2446410,54.912085186700004,55.371561309300006,54.846445740600004,55.3528071819,2446410,0.0,1.0\nEWY,2017-03-01,58.95,59.01,58.51,58.57,2549075,55.277790672100004,55.334053054399995,54.86519986810001,54.9214622504,2549075,0.0,1.0\nEWY,2017-03-02,58.16,58.71,58.075,58.67,2146152,54.537002637600004,55.05274114260001,54.4572975959,55.0152328877,2146152,0.0,1.0\nEWY,2017-03-03,57.73,57.78,57.38,57.68,2703406,54.13378889729999,54.18067421600001,53.80559166689999,54.086903578699996,2703406,0.0,1.0\nEWY,2017-03-06,57.69,57.75,57.53,57.54,1776779,54.0962806424,54.152543024799996,53.946247622799994,53.955624686499995,1776779,0.0,1.0\nEWY,2017-03-07,58.07,58.18,57.915,57.99,2077635,54.452609064099995,54.5557567651,54.3072645763,54.3775925542,2077635,0.0,1.0\nEWY,2017-03-08,57.83,58.21,57.79,58.13,1428850,54.2275595346,54.5838879562,54.1900512797,54.5088714464,1428850,0.0,1.0\nEWY,2017-03-09,57.44,57.69,57.21,57.69,1833284,53.8618540493,54.0962806424,53.6461815835,54.0962806424,1833284,0.0,1.0\nEWY,2017-03-10,58.36,58.435,57.95,58.08,1812755,54.72454391220001,54.79487189010001,54.3400842993,54.461986127799996,1812755,0.0,1.0\nEWY,2017-03-13,59.31,59.33,58.95,58.95,3310107,55.6153649662,55.6341190937,55.277790672100004,55.277790672100004,3310107,0.0,1.0\nEWY,2017-03-14,59.35,59.54,59.31,59.54,1484453,55.6528732212,55.831037431999995,55.6153649662,55.831037431999995,1484453,0.0,1.0\nEWY,2017-03-15,61.31,61.4,59.72,59.76,5350889,57.4907777117,57.57517128520001,55.9998245791,56.037332834,5350889,0.0,1.0\nEWY,2017-03-16,61.16,61.41,60.97,61.31,3796607,57.3501217558,57.584548348999995,57.171957545,57.4907777117,3796607,0.0,1.0\nEWY,2017-03-17,61.29,61.41,61.2,61.26,2442576,57.4720235842,57.584548348999995,57.38763001069999,57.44389239310001,2442576,0.0,1.0\nEWY,2017-03-20,62.2294,62.43,61.98,61.98,2963917,58.352904950799996,58.541008849099995,58.119040981400005,58.119040981400005,2963917,0.0,1.0\nEWY,2017-03-21,61.83,62.7811,61.83,62.62,3420015,57.9783850255,58.870237556599996,57.9783850255,58.71917306,3420015,0.0,1.0\nEWY,2017-03-22,62.35,62.43,61.86,61.9,2091989,58.4659923393,58.541008849099995,58.0065162167,58.0440244716,2091989,0.0,1.0\nEWY,2017-03-23,62.08,62.39,61.95,62.23,2284175,58.2128116187,58.5035005942,58.0909097902,58.3534675746,2284175,0.0,1.0\nEWY,2017-03-24,62.47,62.55,62.1,62.17,1957687,58.5785171041,58.6535336139,58.231565746099996,58.2972051922,1957687,0.0,1.0\nEWY,2017-03-27,62.44,62.48,62.12,62.26,1414523,58.5503859129,58.587894167799995,58.2503198736,58.3815987658,1414523,0.0,1.0\nEWY,2017-03-28,62.4,62.48,62.14,62.14,3105350,58.512877658,58.587894167799995,58.269074001099995,58.269074001099995,3105350,0.0,1.0\nEWY,2017-03-29,62.51,62.54,62.27,62.42,2279668,58.616025359,58.6441565501,58.3909758295,58.5316317854,2279668,0.0,1.0\nEWY,2017-03-30,62.15,62.3122,62.05,62.08,1344490,58.278451064799995,58.4305470384,58.1846804275,58.2128116187,1344490,0.0,1.0\nEWY,2017-03-31,61.87,62.12,61.87,61.96,1920352,58.0158932804,58.2503198736,58.0158932804,58.100286854,1920352,0.0,1.0\nEWY,2017-04-03,62.07,62.11,61.73,61.86,1781183,58.203434555,58.2409428099,57.8846143882,58.0065162167,1781183,0.0,1.0\nEWY,2017-04-04,61.51,61.61,61.29,61.29,1698019,57.6783189862,57.7720896235,57.4720235842,57.4720235842,1698019,0.0,1.0\nEWY,2017-04-05,61.04,61.41,60.99,61.24,2164482,57.2375969911,57.584548348999995,57.1907116724,57.425138265600005,2164482,0.0,1.0\nEWY,2017-04-06,60.85,60.89,60.74,60.83,1771365,57.059432780200005,57.0969410351,56.9562850792,57.0406786528,1771365,0.0,1.0\nEWY,2017-04-07,60.35,60.65,60.305,60.56,2142543,56.5905795939,56.8718915057,56.5483828071,56.7874979321,2142543,0.0,1.0\nEWY,2017-04-10,59.52,59.83,59.4,59.83,3495909,55.8122833045,56.10297228010001,55.699758539799994,56.10297228010001,3495909,0.0,1.0\nEWY,2017-04-11,59.11,59.33,58.895,59.26,3646350,55.4278236917,55.6341190937,55.2262168216,55.5684796476,3646350,0.0,1.0\nEWY,2017-04-12,59.7,59.75,59.3,59.43,3796929,55.9810704516,56.0279557702,55.6059879025,55.727889731000005,3796929,0.0,1.0\nEWY,2017-04-13,59.92,60.3,59.92,60.13,2988368,56.1873658536,56.543694275200004,56.1873658536,56.384284191899994,2988368,0.0,1.0\nEWY,2017-04-17,60.52,60.52,60.06,60.14,1956961,56.74998967720001,56.74998967720001,56.3186447458,56.3936612556,1956961,0.0,1.0\nEWY,2017-04-18,59.56,59.8,59.35,59.77,2311731,55.849791559399996,56.0748410889,55.6528732212,56.046709897700005,2311731,0.0,1.0\nEWY,2017-04-19,59.33,59.81,59.3,59.75,2267783,55.6341190937,56.0842181526,55.6059879025,56.0279557702,2267783,0.0,1.0\nEWY,2017-04-20,60.49,60.49,60.13,60.13,2213749,56.7218584861,56.7218584861,56.384284191899994,56.384284191899994,2213749,0.0,1.0\nEWY,2017-04-21,60.96,61.01,60.68,60.85,2811268,57.162580481199996,57.209465799899995,56.900022696899995,57.059432780200005,2811268,0.0,1.0\nEWY,2017-04-24,61.38,61.69,61.14,61.6,2094063,57.5564171578,57.8471061333,57.3313676283,57.76271255979999,2094063,0.0,1.0\nEWY,2017-04-25,62.33,62.48,62.2,62.22,2448553,58.44723821189999,58.587894167799995,58.3253363834,58.3440905109,2448553,0.0,1.0\nEWY,2017-04-26,62.2,62.41,62.04,62.22,1788389,58.3253363834,58.5222547217,58.1753033638,58.3440905109,1788389,0.0,1.0\nEWY,2017-04-27,62.12,62.33,62.04,62.26,3490977,58.2503198736,58.44723821189999,58.1753033638,58.3815987658,3490977,0.0,1.0\nEWY,2017-04-28,62.1,62.15,61.88,62.0,2039139,58.231565746099996,58.278451064799995,58.0252703441,58.1377951089,2039139,0.0,1.0\nEWY,2017-05-01,62.45,62.59,62.05,62.08,2023357,58.5597629766,58.6910418688,58.1846804275,58.2128116187,2023357,0.0,1.0\nEWY,2017-05-02,63.29,63.33,62.98,63.13,1592672,59.3474363297,59.3849445846,59.0567473541,59.19740331,1592672,0.0,1.0\nEWY,2017-05-03,62.84,63.04,62.59,62.99,1445770,58.925468462,59.1130097365,58.6910418688,59.0661244179,1445770,0.0,1.0\nEWY,2017-05-04,63.01,63.38,63.0,63.34,3522645,59.0848785453,59.4318299032,59.0755014816,59.3943216483,3522645,0.0,1.0\nEWY,2017-05-05,63.41,63.43,62.84,62.9,2370487,59.45996109439999,59.478715221899996,58.925468462,58.9817308443,2370487,0.0,1.0\nEWY,2017-05-08,65.03,65.38,64.9,65.19,4978977,60.979045418199995,61.3072426487,60.857143589799996,61.1290784379,4978977,0.0,1.0\nEWY,2017-05-09,65.64,66.02,65.17,65.18,6251274,61.5510463056,61.9073747272,61.1103243104,61.119701374099996,6251274,0.0,1.0\nEWY,2017-05-10,64.94,64.95,64.44,64.53,3790255,60.8946518447,60.9040289084,60.425798658299996,60.51019223189999,3790255,0.0,1.0\nEWY,2017-05-11,65.99,66.115,65.355,65.8,3288457,61.879243536000004,61.9964568326,61.283799989399995,61.701079325200006,3288457,0.0,1.0\nEWY,2017-05-12,65.88,65.93,65.6212,65.77,2554709,61.776095835,61.8229811537,61.533417425799996,61.672948133999995,2554709,0.0,1.0\nEWY,2017-05-15,66.51,66.58,66.31,66.42,1972744,62.3668508499,62.4324902959,62.179309575299996,62.2824572763,1972744,0.0,1.0\nEWY,2017-05-16,66.58,66.59,66.32,66.36,1605319,62.4324902959,62.44186735970001,62.188686639,62.2261948939,1605319,0.0,1.0\nEWY,2017-05-17,65.41,66.14,65.35,66.09,4044548,61.3353738399,62.0198994919,61.2791114575,61.9730141733,4044548,0.0,1.0\nEWY,2017-05-18,65.47,65.66,64.87,65.16,2981148,61.39163622220001,61.56980043300001,60.8290123986,61.1009472467,2981148,0.0,1.0\nEWY,2017-05-19,66.77,66.81,66.13,66.13,2374600,62.6106545068,62.6481627617,62.0105224282,62.0105224282,2374600,0.0,1.0\nEWS,2017-01-03,20.13,20.13,20.03,20.07,824417,17.8101222034,17.8101222034,17.7216466832,17.7570368913,824417,0.0,1.0\nEWS,2017-01-04,20.45,20.47,20.36,20.37,735009,18.0932438678,18.1109389718,18.0136158997,18.022463451700002,735009,0.0,1.0\nEWS,2017-01-05,20.84,20.9,20.74,20.77,952595,18.438298396300002,18.4913837084,18.349822876199998,18.3763655322,952595,0.0,1.0\nEWS,2017-01-06,20.71,20.745,20.7,20.72,446394,18.3232802202,18.3542466522,18.3144326681,18.3321277722,446394,0.0,1.0\nEWS,2017-01-09,20.87,20.9,20.77,20.77,435264,18.4648410524,18.4913837084,18.3763655322,18.3763655322,435264,0.0,1.0\nEWS,2017-01-10,21.07,21.14,21.04,21.04,923946,18.6417920926,18.7037249567,18.6152494366,18.6152494366,923946,0.0,1.0\nEWS,2017-01-11,21.18,21.22,20.96,21.03,1400768,18.7391151648,18.7745053728,18.5444690205,18.6064018846,1400768,0.0,1.0\nEWS,2017-01-12,21.23,21.25,21.15,21.25,751645,18.7833529249,18.801048028900002,18.7125725088,18.801048028900002,751645,0.0,1.0\nEWS,2017-01-13,21.37,21.405,21.315,21.39,560708,18.907218653,18.9381850851,18.858557117,18.9249137571,560708,0.0,1.0\nEWS,2017-01-17,21.32,21.398000000000003,21.31,21.38,650354,18.862980893,18.9319917987,18.854133341,18.916066205099998,650354,0.0,1.0\nEWS,2017-01-18,21.16,21.25,21.1125,21.24,602105,18.7214200608,18.801048028900002,18.679394188699998,18.7922004769,602105,0.0,1.0\nEWS,2017-01-19,21.26,21.28,21.19,21.24,691801,18.809895580899997,18.8275906849,18.7479627168,18.7922004769,691801,0.0,1.0\nEWS,2017-01-20,21.38,21.38,21.27,21.28,646155,18.916066205099998,18.916066205099998,18.8187431329,18.8275906849,646155,0.0,1.0\nEWS,2017-01-23,21.53,21.55,21.43,21.48,679071,19.0487794853,19.0664745893,18.9603039651,19.0045417252,679071,0.0,1.0\nEWS,2017-01-24,21.7,21.75,21.64,21.64,759107,19.1991878695,19.2434256296,19.1461025574,19.1461025574,759107,0.0,1.0\nEWS,2017-01-25,21.79,21.79,21.64,21.64,573021,19.2788158376,19.2788158376,19.1461025574,19.1461025574,573021,0.0,1.0\nEWS,2017-01-26,21.61,21.68,21.5887,21.67,453915,19.119559901400002,19.1814927655,19.1007146156,19.172645213499997,453915,0.0,1.0\nEWS,2017-01-27,21.58,21.67,21.58,21.66,500590,19.0930172453,19.172645213499997,19.0930172453,19.1637976614,500590,0.0,1.0\nEWS,2017-01-30,21.74,21.75,21.59,21.6,443629,19.2345780776,19.2434256296,19.1018647973,19.1107123494,443629,0.0,1.0\nEWS,2017-01-31,21.77,21.81,21.72,21.75,696578,19.2611207336,19.2965109416,19.2168829735,19.2434256296,696578,0.0,1.0\nEWS,2017-02-01,21.82,21.9,21.78,21.86,1046839,19.3053584937,19.3761389098,19.2699682856,19.3407487017,1046839,0.0,1.0\nEWS,2017-02-02,21.83,21.84,21.775,21.78,623018,19.3142060457,19.3230535977,19.2655445096,19.2699682856,623018,0.0,1.0\nEWS,2017-02-03,21.92,21.93,21.82,21.86,501751,19.3938340138,19.4026815658,19.3053584937,19.3407487017,501751,0.0,1.0\nEWS,2017-02-06,21.87,21.88,21.795,21.88,336167,19.3495962537,19.3584438057,19.2832396136,19.3584438057,336167,0.0,1.0\nEWS,2017-02-07,21.81,21.86,21.81,21.86,290418,19.2965109416,19.3407487017,19.2965109416,19.3407487017,290418,0.0,1.0\nEWS,2017-02-08,21.82,21.8401,21.74,21.82,352684,19.3053584937,19.3231420732,19.2345780776,19.3053584937,352684,0.0,1.0\nEWS,2017-02-09,21.95,21.99,21.91,21.95,401991,19.4203766698,19.4557668779,19.3849864618,19.4203766698,401991,0.0,1.0\nEWS,2017-02-10,22.06,22.1069,21.975,21.99,460015,19.517699742,19.5591947609,19.4424955499,19.4557668779,460015,0.0,1.0\nEWS,2017-02-13,22.05,22.08,22.02,22.08,510653,19.50885219,19.535394846,19.4823095339,19.535394846,510653,0.0,1.0\nEWS,2017-02-14,21.8,21.84,21.67,21.84,795752,19.2876633896,19.3230535977,19.172645213499997,19.3230535977,795752,0.0,1.0\nEWS,2017-02-15,21.9,21.9,21.7,21.7,262183,19.3761389098,19.3761389098,19.1991878695,19.1991878695,262183,0.0,1.0\nEWS,2017-02-16,21.9,21.9,21.83,21.85,305690,19.3761389098,19.3761389098,19.3142060457,19.331901149700002,305690,0.0,1.0\nEWS,2017-02-17,22.0,22.02,21.9401,21.95,297270,19.4646144299,19.4823095339,19.4116175933,19.4203766698,297270,0.0,1.0\nEWS,2017-02-21,21.91,21.96,21.85,21.87,834637,19.3849864618,19.4292242219,19.331901149700002,19.3495962537,834637,0.0,1.0\nEWS,2017-02-22,22.25,22.265,22.09,22.13,547177,19.6858032302,19.699074558299998,19.544242397999998,19.5796326061,547177,0.0,1.0\nEWS,2017-02-23,22.39,22.44,22.38,22.43,721819,19.8096689584,19.8539067185,19.8008214064,19.8450591665,721819,0.0,1.0\nEWS,2017-02-24,22.3,22.3,22.21,22.24,412946,19.7300409903,19.7300409903,19.6504130222,19.6769556782,412946,0.0,1.0\nEWS,2017-02-27,22.26,22.31,22.2,22.25,467246,19.694650782300002,19.7388885423,19.6415654702,19.6858032302,467246,0.0,1.0\nEWS,2017-02-28,22.32,22.39,22.29,22.35,671712,19.747736094300002,19.8096689584,19.7211934383,19.7742787504,671712,0.0,1.0\nEWS,2017-03-01,22.35,22.42,22.28,22.28,1208289,19.7742787504,19.8362116145,19.7123458863,19.7123458863,1208289,0.0,1.0\nEWS,2017-03-02,22.12,22.2254,22.11,22.19,447408,19.5707850541,19.6640382523,19.5619375021,19.6327179182,447408,0.0,1.0\nEWS,2017-03-03,22.28,22.29,22.12,22.19,576438,19.7123458863,19.7211934383,19.5707850541,19.6327179182,576438,0.0,1.0\nEWS,2017-03-06,22.2,22.22,22.14,22.2,262411,19.6415654702,19.6592605742,19.588480158099998,19.6415654702,262411,0.0,1.0\nEWS,2017-03-07,22.26,22.29,22.225,22.27,326706,19.694650782300002,19.7211934383,19.6636843502,19.7034983343,326706,0.0,1.0\nEWS,2017-03-08,22.19,22.29,22.19,22.25,769635,19.6327179182,19.7211934383,19.6327179182,19.6858032302,769635,0.0,1.0\nEWS,2017-03-09,21.96,22.01,21.9,22.01,1111021,19.4292242219,19.4734619819,19.3761389098,19.4734619819,1111021,0.0,1.0\nEWS,2017-03-10,22.33,22.33,22.2,22.23,604457,19.7565836464,19.7565836464,19.6415654702,19.6681081262,604457,0.0,1.0\nEWS,2017-03-13,22.36,22.418000000000006,22.36,22.37,769435,19.7831263024,19.834442104100003,19.7831263024,19.791973854400002,769435,0.0,1.0\nEWS,2017-03-14,22.24,22.29,22.2201,22.28,301559,19.6769556782,19.7211934383,19.6593490497,19.7123458863,301559,0.0,1.0\nEWS,2017-03-15,22.64,22.64,22.25,22.27,820741,20.0308577588,20.0308577588,19.6858032302,19.7034983343,820741,0.0,1.0\nEWS,2017-03-16,22.78,22.78,22.66,22.74,665810,20.154723487000002,20.154723487000002,20.0485528628,20.1193332789,665810,0.0,1.0\nEWS,2017-03-17,22.74,22.78,22.72,22.77,366326,20.1193332789,20.154723487000002,20.1016381749,20.145875935,366326,0.0,1.0\nEWS,2017-03-20,22.7823,22.819000000000006,22.71,22.75,455641,20.1567584239,20.1892289398,20.0927906229,20.1281808309,455641,0.0,1.0\nEWS,2017-03-21,22.48,22.76,22.48,22.76,595548,19.8892969266,20.1370283829,19.8892969266,20.1370283829,595548,0.0,1.0\nEWS,2017-03-22,22.45,22.49,22.35,22.37,342993,19.862754270499998,19.8981444786,19.7742787504,19.791973854400002,342993,0.0,1.0\nEWS,2017-03-23,22.48,22.51,22.42,22.45,451564,19.8892969266,19.9158395826,19.8362116145,19.862754270499998,451564,0.0,1.0\nEWS,2017-03-24,22.62,22.66,22.51,22.59,317502,20.0131626547,20.0485528628,19.9158395826,19.9866199987,317502,0.0,1.0\nEWS,2017-03-27,22.63,22.67,22.54,22.62,387056,20.0220102068,20.0574004148,19.9423822386,20.0131626547,387056,0.0,1.0\nEWS,2017-03-28,22.78,22.855,22.63,22.63,461519,20.154723487000002,20.2210801271,20.0220102068,20.0220102068,461519,0.0,1.0\nEWS,2017-03-29,22.99,22.99,22.84,22.88,538349,20.3405220793,20.3405220793,20.207808799000002,20.2431990071,538349,0.0,1.0\nEWS,2017-03-30,22.86,22.93,22.86,22.9,895471,20.225503903099998,20.2874367672,20.225503903099998,20.2608941111,895471,0.0,1.0\nEWS,2017-03-31,22.81,22.905,22.81,22.85,578853,20.181266143,20.2653178871,20.181266143,20.2166563511,578853,0.0,1.0\nEWS,2017-04-03,22.93,22.93,22.8,22.87,777161,20.2874367672,20.2874367672,20.172418591,20.2343514551,777161,0.0,1.0\nEWS,2017-04-04,22.87,22.885,22.77,22.83,465650,20.2343514551,20.2476227831,20.145875935,20.198961247,465650,0.0,1.0\nEWS,2017-04-05,22.7,22.85,22.7,22.8,413100,20.0839430709,20.2166563511,20.0839430709,20.172418591,413100,0.0,1.0\nEWS,2017-04-06,22.72,22.745,22.68,22.68,215943,20.1016381749,20.1237570549,20.0662479668,20.0662479668,215943,0.0,1.0\nEWS,2017-04-07,22.65,22.73,22.63,22.7,349352,20.0397053108,20.110485726900002,20.0220102068,20.0839430709,349352,0.0,1.0\nEWS,2017-04-10,22.65,22.66,22.6,22.63,342574,20.0397053108,20.0485528628,19.9954675507,20.0220102068,342574,0.0,1.0\nEWS,2017-04-11,22.67,22.73,22.605,22.72,329555,20.0574004148,20.110485726900002,19.999891326700002,20.1016381749,329555,0.0,1.0\nEWS,2017-04-12,22.77,22.78,22.65,22.78,419829,20.145875935,20.154723487000002,20.0397053108,20.154723487000002,419829,0.0,1.0\nEWS,2017-04-13,22.64,22.75,22.62,22.74,564225,20.0308577588,20.1281808309,20.0131626547,20.1193332789,564225,0.0,1.0\nEWS,2017-04-17,22.65,22.65,22.56,22.61,250706,20.0397053108,20.0397053108,19.9600773427,20.0043151027,250706,0.0,1.0\nEWS,2017-04-18,22.49,22.51,22.415,22.48,405447,19.8981444786,19.9158395826,19.8317878385,19.8892969266,405447,0.0,1.0\nEWS,2017-04-19,22.34,22.49,22.33,22.42,319861,19.765431198399998,19.8981444786,19.7565836464,19.8362116145,319861,0.0,1.0\nEWS,2017-04-20,22.55,22.57,22.47,22.49,247915,19.951229790699998,19.9689248947,19.8804493745,19.8981444786,247915,0.0,1.0\nEWS,2017-04-21,22.56,22.61,22.52,22.52,334990,19.9600773427,20.0043151027,19.9246871346,19.9246871346,334990,0.0,1.0\nEWS,2017-04-24,22.71,22.74,22.6893,22.72,443249,20.0927906229,20.1193332789,20.0744761902,20.1016381749,443249,0.0,1.0\nEWS,2017-04-25,22.91,22.93,22.82,22.83,386650,20.2697416631,20.2874367672,20.190113695,20.198961247,386650,0.0,1.0\nEWS,2017-04-26,22.92,22.99,22.89,22.9,406290,20.2785892152,20.3405220793,20.252046559100002,20.2608941111,406290,0.0,1.0\nEWS,2017-04-27,22.91,22.95,22.87,22.95,369796,20.2697416631,20.3051318712,20.2343514551,20.3051318712,369796,0.0,1.0\nEWS,2017-04-28,22.98,22.99,22.92,22.93,267096,20.3316745272,20.3405220793,20.2785892152,20.2874367672,267096,0.0,1.0\nEWS,2017-05-01,23.1,23.13,23.0,23.04,198408,20.4378451514,20.4643878074,20.3493696313,20.3847598393,198408,0.0,1.0\nEWS,2017-05-02,23.37,23.37,23.27,23.27,301910,20.6767290558,20.6767290558,20.5882535356,20.5882535356,301910,0.0,1.0\nEWS,2017-05-03,23.47,23.535,23.41,23.45,419662,20.7652045759,20.822713664000002,20.712119263800002,20.7475094719,419662,0.0,1.0\nEWS,2017-05-04,23.39,23.4273,23.34,23.34,386834,20.6944241598,20.7274255288,20.6501863997,20.6501863997,386834,0.0,1.0\nEWS,2017-05-05,23.46,23.46,23.33,23.33,467740,20.7563570239,20.7563570239,20.6413388477,20.6413388477,467740,0.0,1.0\nEWS,2017-05-08,23.4,23.47,23.39,23.46,312264,20.7032717118,20.7652045759,20.6944241598,20.7563570239,312264,0.0,1.0\nEWS,2017-05-09,23.44,23.47,23.39,23.44,381699,20.7386619199,20.7652045759,20.6944241598,20.7386619199,381699,0.0,1.0\nEWS,2017-05-10,23.49,23.49,23.38,23.42,388959,20.7828996799,20.7828996799,20.685576607799998,20.7209668158,388959,0.0,1.0\nEWS,2017-05-11,23.55,23.55,23.45,23.46,474357,20.835984992,20.835984992,20.7475094719,20.7563570239,474357,0.0,1.0\nEWS,2017-05-12,23.61,23.64,23.53,23.56,1463281,20.889070304100002,20.9156129601,20.818289888,20.844832544000003,1463281,0.0,1.0\nEWS,2017-05-15,23.83,23.83,23.7,23.77,352872,21.0837164484,21.0837164484,20.9686982722,21.030631136300002,352872,0.0,1.0\nEWS,2017-05-16,23.62,23.645,23.56,23.6,492166,20.897917856099998,20.9200367361,20.844832544000003,20.8802227521,492166,0.0,1.0\nEWS,2017-05-17,23.49,23.59,23.48,23.55,603362,20.7828996799,20.8713752001,20.7740521279,20.835984992,603362,0.0,1.0\nEWS,2017-05-18,23.56,23.58,23.46,23.48,307748,20.844832544000003,20.8625276481,20.7563570239,20.7740521279,307748,0.0,1.0\nEWS,2017-05-19,23.81,23.81,23.66,23.66,964954,21.0660213444,21.0660213444,20.933308064200002,20.933308064200002,964954,0.0,1.0\nVTIP,2017-01-03,49.16,49.17,49.11,49.15,386515,46.3529606315,46.3623896308,46.3058156349,46.34353163220001,386515,0.0,1.0\nVTIP,2017-01-04,49.18,49.18,49.11,49.13,332340,46.3718186301,46.3718186301,46.3058156349,46.324673633500005,332340,0.0,1.0\nVTIP,2017-01-05,49.24,49.249,49.16,49.19,350398,46.428392626000004,46.4368787254,46.3529606315,46.381247629399994,350398,0.0,1.0\nVTIP,2017-01-06,49.16,49.1999,49.13,49.18,299290,46.3529606315,46.3905823387,46.324673633500005,46.3718186301,299290,0.0,1.0\nVTIP,2017-01-09,49.19,49.2,49.12,49.19,299692,46.381247629399994,46.3906766287,46.3152446342,46.381247629399994,299692,0.0,1.0\nVTIP,2017-01-10,49.16,49.2,49.16,49.19,319307,46.3529606315,46.3906766287,46.3529606315,46.381247629399994,319307,0.0,1.0\nVTIP,2017-01-11,49.23,49.29,49.18,49.23,640598,46.4189636267,46.4755376226,46.3718186301,46.4189636267,640598,0.0,1.0\nVTIP,2017-01-12,49.26,49.2999,49.23,49.28,327040,46.4472506246,46.48487233189999,46.4189636267,46.4661086232,327040,0.0,1.0\nVTIP,2017-01-13,49.23,49.25,49.16,49.22,358876,46.4189636267,46.437821625299996,46.3529606315,46.4095346274,358876,0.0,1.0\nVTIP,2017-01-17,49.3,49.3689,49.25,49.33,959374,46.48496662189999,46.54993242720001,46.437821625299996,46.5132536198,959374,0.0,1.0\nVTIP,2017-01-18,49.2,49.3,49.19,49.29,671156,46.3906766287,46.48496662189999,46.381247629399994,46.4755376226,671156,0.0,1.0\nVTIP,2017-01-19,49.21,49.22,49.1401,49.18,402059,46.400105628,46.4095346274,46.3341969228,46.3718186301,402059,0.0,1.0\nVTIP,2017-01-20,49.27,49.2899,49.1842,49.22,535628,46.4566796239,46.4754433326,46.375778809799996,46.4095346274,535628,0.0,1.0\nVTIP,2017-01-23,49.27,49.32,49.22,49.27,397521,46.4566796239,46.50382462050001,46.4095346274,46.4566796239,397521,0.0,1.0\nVTIP,2017-01-24,49.29,49.3,49.26,49.3,449155,46.4755376226,46.48496662189999,46.4472506246,46.48496662189999,449155,0.0,1.0\nVTIP,2017-01-25,49.26,49.26,49.21,49.23,374586,46.4472506246,46.4472506246,46.400105628,46.4189636267,374586,0.0,1.0\nVTIP,2017-01-26,49.26,49.3,49.22,49.26,618080,46.4472506246,46.48496662189999,46.4095346274,46.4472506246,618080,0.0,1.0\nVTIP,2017-01-27,49.31,49.33,49.28,49.3,350097,46.4943956212,46.5132536198,46.4661086232,46.48496662189999,350097,0.0,1.0\nVTIP,2017-01-30,49.32,49.35,49.31,49.34,426674,46.50382462050001,46.5321116184,46.4943956212,46.522682619099996,426674,0.0,1.0\nVTIP,2017-01-31,49.39,49.4,49.3206,49.35,568693,46.5698276157,46.579256615,46.504390360500004,46.5321116184,568693,0.0,1.0\nVTIP,2017-02-01,49.39,49.41,49.3301,49.36,440971,46.5698276157,46.588685614300005,46.5133479098,46.541540617799996,440971,0.0,1.0\nVTIP,2017-02-02,49.4,49.43,49.36,49.42,1320173,46.579256615,46.607543613000004,46.541540617799996,46.5981146137,1320173,0.0,1.0\nVTIP,2017-02-03,49.4,49.48,49.36,49.45,483552,46.579256615,46.6546886095,46.541540617799996,46.626401611599995,483552,0.0,1.0\nVTIP,2017-02-06,49.45,49.46,49.4,49.46,2352499,46.626401611599995,46.6358306109,46.579256615,46.6358306109,2352499,0.0,1.0\nVTIP,2017-02-07,49.39,49.44,49.38,49.39,484005,46.5698276157,46.616972612299996,46.5603986164,46.5698276157,484005,0.0,1.0\nVTIP,2017-02-08,49.37,49.42,49.34,49.41,409036,46.5509696171,46.5981146137,46.522682619099996,46.588685614300005,409036,0.0,1.0\nVTIP,2017-02-09,49.34,49.3832,49.31,49.38,440102,46.522682619099996,46.5634158962,46.4943956212,46.5603986164,440102,0.0,1.0\nVTIP,2017-02-10,49.35,49.4,49.3,49.35,558754,46.5321116184,46.579256615,46.48496662189999,46.5321116184,558754,0.0,1.0\nVTIP,2017-02-13,49.3,49.31,49.29,49.31,423609,46.48496662189999,46.4943956212,46.4755376226,46.4943956212,423609,0.0,1.0\nVTIP,2017-02-14,49.24,49.33,49.21,49.3,551750,46.428392626000004,46.5132536198,46.400105628,46.48496662189999,551750,0.0,1.0\nVTIP,2017-02-15,49.3,49.3,49.25,49.27,493002,46.48496662189999,46.48496662189999,46.437821625299996,46.4566796239,493002,0.0,1.0\nVTIP,2017-02-16,49.34,49.35,49.27,49.32,1775114,46.522682619099996,46.5321116184,46.4566796239,46.50382462050001,1775114,0.0,1.0\nVTIP,2017-02-17,49.37,49.37,49.32,49.37,638199,46.5509696171,46.5509696171,46.50382462050001,46.5509696171,638199,0.0,1.0\nVTIP,2017-02-21,49.38,49.38,49.34,49.38,420652,46.5603986164,46.5603986164,46.522682619099996,46.5603986164,420652,0.0,1.0\nVTIP,2017-02-22,49.42,49.42,49.33,49.4,406027,46.5981146137,46.5981146137,46.5132536198,46.579256615,406027,0.0,1.0\nVTIP,2017-02-23,49.42,49.46,49.42,49.46,366276,46.5981146137,46.6358306109,46.5981146137,46.6358306109,366276,0.0,1.0\nVTIP,2017-02-24,49.51,49.52,49.45,49.49,446239,46.6829756075,46.692404606800004,46.626401611599995,46.66411760890001,446239,0.0,1.0\nVTIP,2017-02-27,49.44,49.49,49.43,49.49,358769,46.616972612299996,46.66411760890001,46.607543613000004,46.66411760890001,358769,0.0,1.0\nVTIP,2017-02-28,49.41,49.45,49.39,49.44,569127,46.588685614300005,46.626401611599995,46.5698276157,46.616972612299996,569127,0.0,1.0\nVTIP,2017-03-01,49.3,49.33,49.27,49.3,403255,46.48496662189999,46.5132536198,46.4566796239,46.48496662189999,403255,0.0,1.0\nVTIP,2017-03-02,49.26,49.35,49.225,49.28,495649,46.4472506246,46.5321116184,46.414249127,46.4661086232,495649,0.0,1.0\nVTIP,2017-03-03,49.37,49.3725,49.23,49.27,334637,46.5509696171,46.553326866899994,46.4189636267,46.4566796239,334637,0.0,1.0\nVTIP,2017-03-06,49.35,49.36,49.31,49.36,423725,46.5321116184,46.541540617799996,46.4943956212,46.541540617799996,423725,0.0,1.0\nVTIP,2017-03-07,49.32,49.35,49.3,49.35,277726,46.50382462050001,46.5321116184,46.48496662189999,46.5321116184,277726,0.0,1.0\nVTIP,2017-03-08,49.21,49.3199,49.21,49.28,528667,46.400105628,46.50373033050001,46.400105628,46.4661086232,528667,0.0,1.0\nVTIP,2017-03-09,49.18,49.28,49.17100000000001,49.2,341329,46.3718186301,46.4661086232,46.3633325307,46.3906766287,341329,0.0,1.0\nVTIP,2017-03-10,49.21,49.2599,49.17,49.25,467235,46.400105628,46.4471563346,46.3623896308,46.437821625299996,467235,0.0,1.0\nVTIP,2017-03-13,49.12,49.2,49.12,49.2,505797,46.3152446342,46.3906766287,46.3152446342,46.3906766287,505797,0.0,1.0\nVTIP,2017-03-14,49.13,49.15,49.11,49.11,232633,46.324673633500005,46.34353163220001,46.3058156349,46.3058156349,232633,0.0,1.0\nVTIP,2017-03-15,49.32,49.35,49.15,49.18,455450,46.50382462050001,46.5321116184,46.34353163220001,46.3718186301,455450,0.0,1.0\nVTIP,2017-03-16,49.31,49.345,49.29,49.34,418293,46.4943956212,46.5273971188,46.4755376226,46.522682619099996,418293,0.0,1.0\nVTIP,2017-03-17,49.33,49.39,49.301,49.35,630912,46.5132536198,46.5698276157,46.485909521800004,46.5321116184,630912,0.0,1.0\nVTIP,2017-03-20,49.36,49.38,49.33,49.36,227127,46.541540617799996,46.5603986164,46.5132536198,46.541540617799996,227127,0.0,1.0\nVTIP,2017-03-21,49.41,49.43,49.38,49.4,1029701,46.588685614300005,46.607543613000004,46.5603986164,46.579256615,1029701,0.0,1.0\nVTIP,2017-03-22,49.42,49.43,49.36,49.42,307197,46.5981146137,46.607543613000004,46.541540617799996,46.5981146137,307197,0.0,1.0\nVTIP,2017-03-23,49.42,49.425,49.36,49.41,297344,46.5981146137,46.6028291133,46.541540617799996,46.588685614300005,297344,0.0,1.0\nVTIP,2017-03-24,49.38,49.4675,49.36,49.38,408548,46.5603986164,46.6429023604,46.541540617799996,46.5603986164,408548,0.0,1.0\nVTIP,2017-03-27,49.44,49.48,49.43,49.48,369854,46.616972612299996,46.6546886095,46.607543613000004,46.6546886095,369854,0.0,1.0\nVTIP,2017-03-28,49.4,49.4999,49.385,49.47,316512,46.579256615,46.6734523182,46.565113116000006,46.64525961020001,316512,0.0,1.0\nVTIP,2017-03-29,49.48,49.48,49.4,49.45,374953,46.6546886095,46.6546886095,46.579256615,46.626401611599995,374953,0.0,1.0\nVTIP,2017-03-30,49.41,49.4747,49.41,49.46,325311,46.588685614300005,46.6496912399,46.588685614300005,46.6358306109,325311,0.0,1.0\nVTIP,2017-03-31,49.47,49.51,49.45,49.46,372684,46.64525961020001,46.6829756075,46.626401611599995,46.6358306109,372684,0.0,1.0\nVTIP,2017-04-03,49.56,49.57,49.5,49.53,591564,46.7301206041,46.7395496034,46.6735466082,46.701833606099996,591564,0.0,1.0\nVTIP,2017-04-04,49.53,49.57,49.51,49.55,371375,46.701833606099996,46.7395496034,46.6829756075,46.7206916047,371375,0.0,1.0\nVTIP,2017-04-05,49.58,49.59,49.52,49.54,1624329,46.74897860270001,46.758407602,46.692404606800004,46.7112626054,1624329,0.0,1.0\nVTIP,2017-04-06,49.55,49.58,49.51,49.56,687057,46.7206916047,46.74897860270001,46.6829756075,46.7301206041,687057,0.0,1.0\nVTIP,2017-04-07,49.48,49.5699,49.46,49.54,393106,46.6546886095,46.7394553134,46.6358306109,46.7112626054,393106,0.0,1.0\nVTIP,2017-04-10,49.5,49.52,49.47,49.51,333186,46.6735466082,46.692404606800004,46.64525961020001,46.6829756075,333186,0.0,1.0\nVTIP,2017-04-11,49.53,49.54,49.48,49.52,245535,46.701833606099996,46.7112626054,46.6546886095,46.692404606800004,245535,0.0,1.0\nVTIP,2017-04-12,49.56,49.59,49.5,49.54,269579,46.7301206041,46.758407602,46.6735466082,46.7112626054,269579,0.0,1.0\nVTIP,2017-04-13,49.6,49.615,49.55,49.59,288991,46.767836601300004,46.7819801003,46.7206916047,46.758407602,288991,0.0,1.0\nVTIP,2017-04-17,49.51,49.56,49.47,49.51,774298,46.6829756075,46.7301206041,46.64525961020001,46.6829756075,774298,0.0,1.0\nVTIP,2017-04-18,49.53,49.55,49.46,49.52,371767,46.701833606099996,46.7206916047,46.6358306109,46.692404606800004,371767,0.0,1.0\nVTIP,2017-04-19,49.47,49.53,49.46,49.51,201272,46.64525961020001,46.701833606099996,46.6358306109,46.6829756075,201272,0.0,1.0\nVTIP,2017-04-20,49.49,49.5,49.45,49.48,227180,46.66411760890001,46.6735466082,46.626401611599995,46.6546886095,227180,0.0,1.0\nVTIP,2017-04-21,49.5,49.55,49.46,49.51,298653,46.6735466082,46.7206916047,46.6358306109,46.6829756075,298653,0.0,1.0\nVTIP,2017-04-24,49.49,49.5,49.43,49.47,194862,46.66411760890001,46.6735466082,46.607543613000004,46.64525961020001,194862,0.0,1.0\nVTIP,2017-04-25,49.45,49.48,49.4429,49.46,448285,46.626401611599995,46.6546886095,46.6197070221,46.6358306109,448285,0.0,1.0\nVTIP,2017-04-26,49.45,49.49,49.45,49.47,294396,46.626401611599995,46.66411760890001,46.626401611599995,46.64525961020001,294396,0.0,1.0\nVTIP,2017-04-27,49.44,49.48,49.44,49.48,285929,46.616972612299996,46.6546886095,46.616972612299996,46.6546886095,285929,0.0,1.0\nVTIP,2017-04-28,49.46,49.49,49.44,49.47,207987,46.6358306109,46.66411760890001,46.616972612299996,46.64525961020001,207987,0.0,1.0\nVTIP,2017-05-01,49.45,49.5,49.41,49.48,322984,46.626401611599995,46.6735466082,46.588685614300005,46.6546886095,322984,0.0,1.0\nVTIP,2017-05-02,49.43,49.46,49.4,49.44,268542,46.607543613000004,46.6358306109,46.579256615,46.616972612299996,268542,0.0,1.0\nVTIP,2017-05-03,49.37,49.4,49.33,49.4,609829,46.5509696171,46.579256615,46.5132536198,46.579256615,609829,0.0,1.0\nVTIP,2017-05-04,49.28,49.31,49.2408,49.3,313085,46.4661086232,46.4943956212,46.4291469459,46.48496662189999,313085,0.0,1.0\nVTIP,2017-05-05,49.32,49.32,49.26,49.29,208393,46.50382462050001,46.50382462050001,46.4472506246,46.4755376226,208393,0.0,1.0\nVTIP,2017-05-08,49.28,49.32,49.2413,49.3,356913,46.4661086232,46.50382462050001,46.4296183959,46.48496662189999,356913,0.0,1.0\nVTIP,2017-05-09,49.24,49.2719,49.21,49.26,285407,46.428392626000004,46.4584711338,46.400105628,46.4472506246,285407,0.0,1.0\nVTIP,2017-05-10,49.29,49.31,49.25,49.29,599732,46.4755376226,46.4943956212,46.437821625299996,46.4755376226,599732,0.0,1.0\nVTIP,2017-05-11,49.36,49.36,49.26,49.3,309970,46.541540617799996,46.541540617799996,46.4472506246,46.48496662189999,309970,0.0,1.0\nVTIP,2017-05-12,49.33,49.38,49.3,49.34,297799,46.5132536198,46.5603986164,46.48496662189999,46.522682619099996,297799,0.0,1.0\nVTIP,2017-05-15,49.32,49.38,49.31,49.34,219845,46.50382462050001,46.5603986164,46.4943956212,46.522682619099996,219845,0.0,1.0\nVTIP,2017-05-16,49.33,49.34,49.29,49.31,273845,46.5132536198,46.522682619099996,46.4755376226,46.4943956212,273845,0.0,1.0\nVTIP,2017-05-17,49.41,49.41,49.35,49.38,304564,46.588685614300005,46.588685614300005,46.5321116184,46.5603986164,304564,0.0,1.0\nVTIP,2017-05-18,49.38,49.4199,49.34,49.39,194555,46.5603986164,46.5980203237,46.522682619099996,46.5698276157,194555,0.0,1.0\nVTIP,2017-05-19,49.42,49.44,49.37,49.41,217420,46.5981146137,46.616972612299996,46.5509696171,46.588685614300005,217420,0.0,1.0\nTLT,2017-01-03,119.64,119.99,118.18,118.41,13217317,110.7985401193,111.1226749324,109.44643489879999,109.659437776,13217317,0.0,1.0\nTLT,2017-01-04,120.1,120.24,119.45,119.76,6699562,111.22454587370001,111.3541997989,110.6225812207,110.9096720552,6699562,0.0,1.0\nTLT,2017-01-05,121.98,122.03,120.17,120.45,13265820,112.9656128699,113.0119178432,111.28937283629999,111.5486806868,13265820,0.0,1.0\nTLT,2017-01-06,120.86,121.54,120.75,121.13,8369334,111.92838146790001,112.55812910479999,111.8265105266,112.1784283237,8369334,0.0,1.0\nTLT,2017-01-09,121.83,122.0,121.46,121.88,8839108,112.82669795,112.9841348592,112.4840411475,112.87300292329999,8839108,0.0,1.0\nTLT,2017-01-10,121.75,121.91,121.28,121.55,8417941,112.7526099927,112.90078590719999,112.3173432436,112.5673900995,8417941,0.0,1.0\nTLT,2017-01-11,122.16,122.69,121.41,121.94,9402523,113.13231077379999,113.6231434908,112.4377361742,112.92856889120002,9402523,0.0,1.0\nTLT,2017-01-12,121.89,123.14,121.83,122.8,9977612,112.8822639179,114.0398882505,112.82669795,113.72501443200001,9977612,0.0,1.0\nTLT,2017-01-13,121.31,121.75,120.62,121.33,9820003,112.3451262276,112.7526099927,111.706117596,112.3636482169,9820003,0.0,1.0\nTLT,2017-01-17,122.58,122.94,121.99,122.81,7853301,113.52127254950001,113.8546683573,112.97487386450001,113.7342754267,7853301,0.0,1.0\nTLT,2017-01-18,121.01,121.96,120.91,121.77,9064689,112.06729638780001,112.9470908806,111.9746864412,112.77113198200001,9064689,0.0,1.0\nTLT,2017-01-19,120.18,120.58,119.55,120.49,11795591,111.298633831,111.6690736174,110.71519116729999,111.58572466540001,11795591,0.0,1.0\nTLT,2017-01-20,119.94,120.31,119.29,119.84,16953217,111.07636995909999,111.4190267616,110.47440530620001,110.9837600125,16953217,0.0,1.0\nTLT,2017-01-23,121.14,121.87,120.02,120.34,13118414,112.1876893184,112.8637419286,111.1504579164,111.44680974549999,13118414,0.0,1.0\nTLT,2017-01-24,120.31,121.13,119.8,120.77,8394978,111.4190267616,112.1784283237,110.94671603389999,111.8450325159,8394978,0.0,1.0\nTLT,2017-01-25,118.8,119.6,118.56,119.28,11115069,110.0206165678,110.7614961407,109.79835269600001,110.4651443115,11115069,0.0,1.0\nTLT,2017-01-26,119.2,119.26,118.33,118.91,7827932,110.3910563542,110.4466223222,109.5853498188,110.1224875091,7827932,0.0,1.0\nTLT,2017-01-27,119.63,119.83,119.25,119.4,7197762,110.78927912459999,110.97449901780001,110.4373613275,110.5762762474,7197762,0.0,1.0\nTLT,2017-01-30,119.27,119.88,119.2,119.44,6569509,110.4558833169,111.02080399110001,110.3910563542,110.6133202261,6569509,0.0,1.0\nTLT,2017-01-31,120.1,120.4,119.25,119.33,13298521,111.22454587370001,111.5023757135,110.4373613275,110.5114492848,13298521,0.0,1.0\nTLT,2017-02-01,119.1,119.53,118.58,119.1,10975521,110.5384534191,110.93754271360001,110.05583380719999,110.5384534191,10975521,0.25915900000000003,1.0\nTLT,2017-02-02,119.05,120.14,119.0,119.93,6978567,110.4920476872,111.503692643,110.4456419553,111.3087885689,6978567,0.0,1.0\nTLT,2017-02-03,119.0,119.87,118.47,119.44,10275574,110.4456419553,111.25310169059999,109.95374119700001,110.8540123962,10275574,0.0,1.0\nTLT,2017-02-06,119.72,120.13,119.12,119.76,8433772,111.11388449489999,111.4944114966,110.55701571190001,111.15100908040002,8433772,0.0,1.0\nTLT,2017-02-07,120.6,121.01,119.47,119.78,8418420,111.9306253766,112.3111523783,110.8818558353,111.1695713732,8418420,0.0,1.0\nTLT,2017-02-08,122.24,122.27,121.41,121.42,15734201,113.4527333833,113.4805768225,112.68239823360001,112.69167938,15734201,0.0,1.0\nTLT,2017-02-09,120.83,121.59,120.66,121.41,16890586,112.1440917434,112.84945886850001,111.9863122549,112.68239823360001,16890586,0.0,1.0\nTLT,2017-02-10,120.76,120.95,120.11,120.11,7978462,112.0791237187,112.2554655,111.4758492038,111.4758492038,7978462,0.0,1.0\nTLT,2017-02-13,120.38,120.41,119.88,120.25,11536780,111.72644015610001,111.75428359530001,111.262382837,111.6057852532,11536780,0.0,1.0\nTLT,2017-02-14,119.51,120.3,118.83,120.24,13210451,110.9189804208,111.6521909851,110.28786246680001,111.5965041068,13210451,0.0,1.0\nTLT,2017-02-15,118.96,119.22,118.65,118.76,8481015,110.4085173698,110.6498271757,110.12080183190001,110.22289444209999,8481015,0.0,1.0\nTLT,2017-02-16,119.61,120.21,119.12,119.22,10005829,111.01179188469999,111.5686606676,110.55701571190001,110.6498271757,10005829,0.0,1.0\nTLT,2017-02-17,120.32,120.7,120.13,120.61,7886448,111.6707532778,112.02343684040001,111.4944114966,111.9399065229,7886448,0.0,1.0\nTLT,2017-02-21,120.11,120.61,119.6,119.68,8703755,111.4758492038,111.9399065229,111.0025107383,111.07675990940001,8703755,0.0,1.0\nTLT,2017-02-22,120.31,120.82,119.59,120.8,8101522,111.66147213149999,112.13481059700001,110.9932295919,112.1162483042,8101522,0.0,1.0\nTLT,2017-02-23,120.67,120.74,120.33,120.57,5330784,111.99559340120001,112.0605614259,111.6800344242,111.9027819374,5330784,0.0,1.0\nTLT,2017-02-24,122.01,122.14,121.26,121.35,11335989,113.2392670165,113.35992191950001,112.54318103780001,112.6267113553,11335989,0.0,1.0\nTLT,2017-02-27,121.29,121.9,121.23,121.81,11039831,112.57102447700001,113.13717440629999,112.51533759870001,113.0536440889,11039831,0.0,1.0\nTLT,2017-02-28,121.74,122.08,121.32,121.5,8410508,112.9886760642,113.3042350412,112.5988679161,112.765928551,8410508,0.0,1.0\nTLT,2017-03-01,119.47,119.55,118.95,119.44,10717113,111.09892050260001,111.17331502540002,110.6153561043,111.0710225565,10717113,0.233877,1.0\nTLT,2017-03-02,119.04,119.21,118.62,119.01,8201247,110.6990499425,110.8571383034,110.30847869770001,110.6711519964,8201247,0.0,1.0\nTLT,2017-03-03,119.35,119.35,118.55,119.23,9515219,110.98732871840001,110.98732871840001,110.2433834902,110.8757369341,9515219,0.0,1.0\nTLT,2017-03-06,118.78,119.12,118.52,119.12,4489229,110.4572677433,110.7734444653,110.2154855442,110.7734444653,4489229,0.0,1.0\nTLT,2017-03-07,118.42,118.68,118.25,118.48,7080088,110.1224923907,110.36427458979999,109.96440402969999,110.17828828280001,7080088,0.0,1.0\nTLT,2017-03-08,117.78,117.95,117.23,117.31,11336336,109.5273362082,109.6854245692,109.0158738639,109.0902683867,11336336,0.0,1.0\nTLT,2017-03-09,116.84,117.48,116.77,117.34,10575439,108.65320056520001,109.2483567477,108.5881053577,109.1181663327,10575439,0.0,1.0\nTLT,2017-03-10,117.25,117.32,116.68,117.14,9218505,109.0344724946,109.099567702,108.50441151950001,108.93218002569999,9218505,0.0,1.0\nTLT,2017-03-13,116.51,117.09,116.49,116.83,6993071,108.3463231586,108.8856834489,108.3277245279,108.6439012498,6993071,0.0,1.0\nTLT,2017-03-14,117.07,117.35,116.71,116.77,9869230,108.8670848182,109.1274656481,108.5323094656,108.5881053577,9869230,0.0,1.0\nTLT,2017-03-15,118.5,118.83,117.43,117.56,14600488,110.19688691350001,110.5037643201,109.2018601709,109.32275127049999,14600488,0.0,1.0\nTLT,2017-03-16,117.9,118.15,117.63,117.99,8018165,109.6389279924,109.8714108762,109.38784647790001,109.72262183059999,8018165,0.0,1.0\nTLT,2017-03-17,118.64,118.75,118.03,118.11,7266766,110.32707732840001,110.4293697973,109.759819092,109.83421361479999,7266766,0.0,1.0\nTLT,2017-03-20,119.15,119.23,118.5,118.55,5497756,110.8013424113,110.8757369341,110.19688691350001,110.2433834902,5497756,0.0,1.0\nTLT,2017-03-21,120.14,120.3,119.02,119.05,12540662,111.72197463110001,111.8707636767,110.6804513118,110.70834925780001,12540662,0.0,1.0\nTLT,2017-03-22,120.62,121.17,120.43,120.72,11661374,112.16834176799999,112.67980411229999,111.9916547763,112.2613349215,11661374,0.0,1.0\nTLT,2017-03-23,120.45,121.02,120.06,120.83,6746461,112.01025340700001,112.540314382,111.6475801083,112.36362739040001,6746461,0.0,1.0\nTLT,2017-03-24,120.88,121.1,120.4,120.53,5819630,112.41012396709999,112.6147089048,111.9637568302,112.0846479298,5819630,0.0,1.0\nTLT,2017-03-27,121.43,121.97,121.19,121.83,6878473,112.92158631139999,113.42374934040001,112.69840274299999,113.2935589255,6878473,0.0,1.0\nTLT,2017-03-28,120.62,121.82,120.49,121.79,6811244,112.16834176799999,113.28425961010001,112.0474506684,113.2563616641,6811244,0.0,1.0\nTLT,2017-03-29,121.34,121.38,120.88,120.94,6291020,112.8378924733,112.8750897347,112.41012396709999,112.4659198592,6291020,0.0,1.0\nTLT,2017-03-30,120.36,121.09,120.32,121.07,7042448,111.92655956879999,112.60540958950001,111.88936230739999,112.58681095879999,7042448,0.0,1.0\nTLT,2017-03-31,120.71,120.81,120.21,120.28,5144760,112.25203560610001,112.34502875959998,111.7870698386,111.85216504600001,5144760,0.0,1.0\nTLT,2017-04-03,121.67,121.87,120.4,120.45,12996495,113.3814913916,113.5678668192,112.19800742620001,112.2446012831,12996495,0.254558,1.0\nTLT,2017-04-04,121.01,121.59,120.95,121.37,6976660,112.7664524804,113.3069412205,112.7105398522,113.1019282502,6976660,0.0,1.0\nTLT,2017-04-05,121.38,121.52,120.36,120.56,8555235,113.11124702149999,113.2417098209,112.1607323407,112.3471077683,8555235,0.0,1.0\nTLT,2017-04-06,121.2,121.41,120.52,121.24,6496995,112.9435091367,113.1392033357,112.3098326828,112.98078422219999,6496995,0.0,1.0\nTLT,2017-04-07,120.71,122.25,120.69,121.84,10264226,112.486889339,113.9219801317,112.4682517963,113.5399105051,10264226,0.0,1.0\nTLT,2017-04-10,121.27,121.62,121.0,121.14,5314493,113.0087405363,113.33489753469999,112.7571337091,112.88759650840001,5314493,0.0,1.0\nTLT,2017-04-11,122.42,122.65,121.72,121.8,11297362,114.0803992452,114.2947309869,113.4280852485,113.50263541950001,11297362,0.0,1.0\nTLT,2017-04-12,123.09,123.19,122.29,122.51,11532463,114.7047569277,114.7979446415,113.9592552172,114.1642681876,11532463,0.0,1.0\nTLT,2017-04-13,123.47,123.74,122.91,123.45,8396661,115.05887024020001,115.3104770674,114.53701904280001,115.0402326974,8396661,0.0,1.0\nTLT,2017-04-17,123.09,123.55,122.86,123.46,8015015,114.7047569277,115.1334204112,114.49042518590001,115.04955146879999,8015015,0.0,1.0\nTLT,2017-04-18,124.7,124.98,123.55,123.86,11529704,116.20507912,116.4660047187,115.1334204112,115.422302324,11529704,0.0,1.0\nTLT,2017-04-19,124.02,124.17,123.7,124.1,7343991,115.5714026661,115.71118423680001,115.2732019819,115.6459528371,7343991,0.0,1.0\nTLT,2017-04-20,123.54,123.92,123.14,123.52,7932918,115.12410163979999,115.4782149523,114.75135078459999,115.1054640971,7932918,0.0,1.0\nTLT,2017-04-21,123.54,124.24,123.49,123.82,9798520,115.12410163979999,115.77641563649999,115.07750778290001,115.3850272385,9798520,0.0,1.0\nTLT,2017-04-24,122.93,123.16,122.46,122.55,8066371,114.55565658559999,114.7699883273,114.1176743307,114.2015432731,8066371,0.0,1.0\nTLT,2017-04-25,121.45,122.46,121.38,122.21,8122253,113.1764784212,114.1176743307,113.11124702149999,113.88470504620001,8122253,0.0,1.0\nTLT,2017-04-26,122.12,122.14,121.43,121.52,5766213,113.80083610370001,113.8194736465,113.1578408784,113.2417098209,5766213,0.0,1.0\nTLT,2017-04-27,122.08,122.35,121.58,121.75,4978328,113.7635610182,114.01516784549999,113.2976224492,113.4560415626,4978328,0.0,1.0\nTLT,2017-04-28,122.35,122.44,121.55,121.6,8166295,114.01516784549999,114.0990367879,113.269666135,113.31625999190001,8166295,0.0,1.0\nTLT,2017-05-01,121.08,122.14,120.71,121.69,8795929,113.0691354937,114.05900404030001,112.72361534059999,113.63877682709999,8795929,0.25481,1.0\nTLT,2017-05-02,121.7,121.78,120.94,120.97,6727813,113.6481152096,113.72282226969999,112.9383981385,112.966413286,6727813,0.0,1.0\nTLT,2017-05-03,121.78,122.39,121.55,122.22,8932912,113.72282226969999,114.29246360319999,113.5080394719,114.1337111004,8932912,0.0,1.0\nTLT,2017-05-04,121.18,121.22,120.67,121.01,9707211,113.1625193188,113.1998728489,112.68626181049999,113.00376681610001,9707211,0.0,1.0\nTLT,2017-05-05,121.29,121.47,120.89,121.38,5605412,113.26524152649999,113.43333241180001,112.8917062259,113.34928696909999,5605412,0.0,1.0\nTLT,2017-05-08,120.63,121.14,120.53,121.11,8057780,112.6489082805,113.12516578879999,112.5555244553,113.0971506412,8057780,0.0,1.0\nTLT,2017-05-09,120.62,120.63,120.14,120.38,5536746,112.63956989799999,112.6489082805,112.1913275372,112.4154487176,5536746,0.0,1.0\nTLT,2017-05-10,120.48,121.06,120.16,120.91,6517131,112.50883254280001,113.0504587286,112.21000430229999,112.9103829909,6517131,0.0,1.0\nTLT,2017-05-11,120.48,120.57,119.91,120.0,7479834,112.50883254280001,112.59287798540001,111.97654473940001,112.06059018200001,7479834,0.0,1.0\nTLT,2017-05-12,121.39,121.49,120.98,121.01,7464184,113.3586253516,113.4520091768,112.97575166850001,113.00376681610001,7464184,0.0,1.0\nTLT,2017-05-15,121.06,121.21,120.75,121.06,5173188,113.0504587286,113.1905344664,112.7609688707,113.0504587286,5173188,0.0,1.0\nTLT,2017-05-16,121.51,121.89,121.11,121.11,6722728,113.4706859418,113.8255444774,113.0971506412,113.0971506412,6722728,0.0,1.0\nTLT,2017-05-17,123.28,123.54,122.39,122.63,11236435,115.12357964700001,115.3663775924,114.29246360319999,114.51658478350001,11236435,0.0,1.0\nTLT,2017-05-18,123.42,123.94,123.19,123.75,8350484,115.2543170022,115.739912893,115.0395342044,115.5624836252,8350484,0.0,1.0\nTLT,2017-05-19,123.71,123.78,123.01,123.36,11277436,115.5251300952,115.5904987728,114.8714433191,115.1982867071,11277436,0.0,1.0\nBWX,2017-01-03,25.66,25.7971,25.565,25.66,1444462,24.9541797284,25.0875085687,24.8617928588,24.9541797284,1444462,0.0,1.0\nBWX,2017-01-04,25.74,25.91,25.67,25.72,1143101,25.031979197600002,25.1973030695,24.9639046621,25.0125293303,1143101,0.0,1.0\nBWX,2017-01-05,26.03,26.09,25.84,25.87,474264,25.314002273200003,25.3723518751,25.129228534,25.158403334899997,474264,0.0,1.0\nBWX,2017-01-06,25.85,25.97,25.77,25.9,444068,25.1389534677,25.2556526714,25.0611539985,25.1875781359,444068,0.0,1.0\nBWX,2017-01-09,25.89,25.9899,25.86,25.87,476565,25.177853202199998,25.275005289299997,25.1486784013,25.158403334899997,476565,0.0,1.0\nBWX,2017-01-10,25.91,26.0,25.86,25.87,1938683,25.1973030695,25.2848274723,25.1486784013,25.158403334899997,1938683,0.0,1.0\nBWX,2017-01-11,25.95,26.13,25.71,25.82,1558505,25.2362028041,25.4112516097,25.0028043967,25.109778666700002,1558505,0.0,1.0\nBWX,2017-01-12,26.09,26.32,26.07,26.32,293861,25.3723518751,25.5960253489,25.3529020078,25.5960253489,293861,0.0,1.0\nBWX,2017-01-13,26.18,26.21,26.0001,26.12,368829,25.4598762779,25.489051078800006,25.2849247216,25.401526676,368829,0.0,1.0\nBWX,2017-01-17,26.38,26.4398,26.33,26.33,644568,25.654374950799998,25.7125300539,25.6057502825,25.6057502825,644568,0.0,1.0\nBWX,2017-01-18,26.07,26.42,26.04,26.23,3886654,25.3529020078,25.6932746853,25.3237272069,25.5085009461,3886654,0.0,1.0\nBWX,2017-01-19,26.03,26.09,25.92,25.99,624771,25.314002273200003,25.3723518751,25.2070280032,25.2751025387,624771,0.0,1.0\nBWX,2017-01-20,26.05,26.133000000000006,25.945,26.01,960146,25.3334521405,25.4141690898,25.2313403373,25.294552405999998,960146,0.0,1.0\nBWX,2017-01-23,26.3,26.4,26.14,26.4,935963,25.5765754816,25.673824818000003,25.4209765433,25.673824818000003,935963,0.0,1.0\nBWX,2017-01-24,26.24,26.39,26.18,26.39,441417,25.518225879699997,25.664099884400002,25.4598762779,25.664099884400002,441417,0.0,1.0\nBWX,2017-01-25,26.24,26.27,26.14,26.21,297931,25.518225879699997,25.5474006807,25.4209765433,25.489051078800006,297931,0.0,1.0\nBWX,2017-01-26,26.06,26.1769,25.9776,26.12,1001820,25.3431770742,25.456861548499997,25.263043620999998,25.401526676,1001820,0.0,1.0\nBWX,2017-01-27,26.05,26.08,25.97,26.0,393447,25.3334521405,25.3626269415,25.2556526714,25.2848274723,393447,0.0,1.0\nBWX,2017-01-30,26.1,26.15,25.96,26.04,501695,25.3820768087,25.430701477,25.2459277377,25.3237272069,501695,0.0,1.0\nBWX,2017-01-31,26.36,26.4,26.22,26.32,960406,25.6349250835,25.673824818000003,25.4987760125,25.5960253489,960406,0.0,1.0\nBWX,2017-02-01,26.32,26.3899,26.15,26.26,568568,25.5960253489,25.6640026351,25.430701477,25.537675746999998,568568,0.0,1.0\nBWX,2017-02-02,26.36,26.52,26.34,26.46,407753,25.6349250835,25.7905240218,25.6154752162,25.732174419899998,407753,0.0,1.0\nBWX,2017-02-03,26.31,26.4799,26.24,26.36,800583,25.586300415300002,25.751527037800003,25.518225879699997,25.6349250835,800583,0.0,1.0\nBWX,2017-02-06,26.41,26.41,26.2397,26.3,311693,25.6835497517,25.6835497517,25.517934131700002,25.5765754816,311693,0.0,1.0\nBWX,2017-02-07,26.24,26.32,26.18,26.29,400804,25.518225879699997,25.5960253489,25.4598762779,25.566850548,400804,0.0,1.0\nBWX,2017-02-08,26.35,26.41,26.29,26.37,378154,25.6252001498,25.6835497517,25.566850548,25.644650017100002,378154,0.0,1.0\nBWX,2017-02-09,26.23,26.43,26.22,26.43,209879,25.5085009461,25.702999619,25.4987760125,25.702999619,209879,0.0,1.0\nBWX,2017-02-10,26.24,26.26,26.1,26.1,281794,25.518225879699997,25.537675746999998,25.3820768087,25.3820768087,281794,0.0,1.0\nBWX,2017-02-13,26.14,26.22,26.09,26.12,1818977,25.4209765433,25.4987760125,25.3723518751,25.401526676,1818977,0.0,1.0\nBWX,2017-02-14,26.11,26.223000000000006,26.0351,26.18,478417,25.3918017424,25.5016934926,25.3189619894,25.4598762779,478417,0.0,1.0\nBWX,2017-02-15,26.12,26.16,25.93,26.0,239022,25.401526676,25.4404264106,25.2167529368,25.2848274723,239022,0.0,1.0\nBWX,2017-02-16,26.28,26.31,26.17,26.24,254519,25.5571256143,25.586300415300002,25.450151344200002,25.518225879699997,254519,0.0,1.0\nBWX,2017-02-17,26.18,26.29,26.16,26.24,372404,25.4598762779,25.566850548,25.4404264106,25.518225879699997,372404,0.0,1.0\nBWX,2017-02-21,26.16,26.21,26.089,26.11,168780,25.4404264106,25.489051078800006,25.371379381700002,25.3918017424,168780,0.0,1.0\nBWX,2017-02-22,26.2,26.24,26.075,26.12,476142,25.479326145199998,25.518225879699997,25.3577644746,25.401526676,476142,0.0,1.0\nBWX,2017-02-23,26.34,26.383000000000006,26.3,26.35,219947,25.6154752162,25.6572924308,25.5765754816,25.6252001498,219947,0.0,1.0\nBWX,2017-02-24,26.42,26.49,26.35,26.41,163135,25.6932746853,25.7613492208,25.6252001498,25.6835497517,163135,0.0,1.0\nBWX,2017-02-27,26.39,26.54,26.385,26.51,4119199,25.664099884400002,25.809973889000002,25.659237417600004,25.7807990881,4119199,0.0,1.0\nBWX,2017-02-28,26.44,26.55,26.42,26.52,322256,25.7127245526,25.8196988227,25.6932746853,25.7905240218,322256,0.0,1.0\nBWX,2017-03-01,26.18,26.26,26.0,26.0,1060756,25.4598762779,25.537675746999998,25.2848274723,25.2848274723,1060756,0.0,1.0\nBWX,2017-03-02,26.01,26.11,25.98,26.09,498507,25.294552405999998,25.3918017424,25.265377605,25.3723518751,498507,0.0,1.0\nBWX,2017-03-03,26.13,26.1874,26.01,26.11,519496,25.4112516097,25.4670727288,25.294552405999998,25.3918017424,519496,0.0,1.0\nBWX,2017-03-06,26.13,26.23,26.08,26.22,511789,25.4112516097,25.5085009461,25.3626269415,25.4987760125,511789,0.0,1.0\nBWX,2017-03-07,26.1,26.16,26.07,26.11,202479,25.3820768087,25.4404264106,25.3529020078,25.3918017424,202479,0.0,1.0\nBWX,2017-03-08,25.97,26.0,25.92,25.94,244221,25.2556526714,25.2848274723,25.2070280032,25.226477870500002,244221,0.0,1.0\nBWX,2017-03-09,25.84,25.96,25.82,25.92,380198,25.129228534,25.2459277377,25.109778666700002,25.2070280032,380198,0.0,1.0\nBWX,2017-03-10,26.0,26.0206,25.851,25.92,680715,25.2848274723,25.3048608356,25.139925961,25.2070280032,680715,0.0,1.0\nBWX,2017-03-13,25.98,26.05,25.9499,25.95,390907,25.265377605,25.3334521405,25.2361055548,25.2362028041,390907,0.0,1.0\nBWX,2017-03-14,25.93,26.019,25.9,25.98,203835,25.2167529368,25.3033048462,25.1875781359,25.265377605,203835,0.0,1.0\nBWX,2017-03-15,26.33,26.4699,25.96,25.97,307063,25.6057502825,25.7418021042,25.2459277377,25.2556526714,307063,0.0,1.0\nBWX,2017-03-16,26.31,26.39,26.26,26.38,363412,25.586300415300002,25.664099884400002,25.537675746999998,25.654374950799998,363412,0.0,1.0\nBWX,2017-03-17,26.45,26.46,26.36,26.37,325890,25.7224494863,25.732174419899998,25.6349250835,25.644650017100002,325890,0.0,1.0\nBWX,2017-03-20,26.45,26.5,26.39,26.47,422966,25.7224494863,25.7710741545,25.664099884400002,25.7418993535,422966,0.0,1.0\nBWX,2017-03-21,26.57,26.62,26.5,26.5,161063,25.83914869,25.8877733582,25.7710741545,25.7710741545,161063,0.0,1.0\nBWX,2017-03-22,26.7,26.74,26.58,26.6,349391,25.9655728273,26.004472561900002,25.848873623600007,25.8683234909,349391,0.0,1.0\nBWX,2017-03-23,26.66,26.7401,26.62,26.7,400165,25.9266730928,26.0045698112,25.8877733582,25.9655728273,400165,0.0,1.0\nBWX,2017-03-24,26.7,26.72,26.622,26.65,272834,25.9655728273,25.9850226946,25.889718344899997,25.9169481591,272834,0.0,1.0\nBWX,2017-03-27,26.82,26.93,26.8107,26.93,321355,26.0822720311,26.189246301100006,26.0732278428,26.189246301100006,321355,0.0,1.0\nBWX,2017-03-28,26.72,26.9,26.68,26.89,165642,25.9850226946,26.1600715002,25.94612296,26.1503465666,165642,0.0,1.0\nBWX,2017-03-29,26.78,26.8,26.6599,26.7,287917,26.0433722965,26.0628221638,25.926575843400002,25.9655728273,287917,0.0,1.0\nBWX,2017-03-30,26.56,26.72,26.53,26.68,204834,25.829423756300002,25.9850226946,25.8002489554,25.94612296,204834,0.0,1.0\nBWX,2017-03-31,26.64,26.67,26.53,26.55,1202934,25.9072232255,25.9363980264,25.8002489554,25.8196988227,1202934,0.0,1.0\nBWX,2017-04-03,26.72,26.75,26.58,26.67,7028647,25.9850226946,26.014197495599998,25.848873623600007,25.9363980264,7028647,0.0,1.0\nBWX,2017-04-04,26.69,26.73,26.66,26.71,2243526,25.9558478937,25.9947476283,25.9266730928,25.975297761,2243526,0.0,1.0\nBWX,2017-04-05,26.73,26.78,26.62,26.68,914320,25.9947476283,26.0433722965,25.8877733582,25.94612296,914320,0.0,1.0\nBWX,2017-04-06,26.64,26.72,26.64,26.65,371312,25.9072232255,25.9850226946,25.9072232255,25.9169481591,371312,0.0,1.0\nBWX,2017-04-07,26.54,26.72,26.5314,26.65,417710,25.809973889000002,25.9850226946,25.8016104461,25.9169481591,417710,0.0,1.0\nBWX,2017-04-10,26.62,26.67,26.52,26.56,486002,25.8877733582,25.9363980264,25.7905240218,25.829423756300002,486002,0.0,1.0\nBWX,2017-04-11,26.69,26.73,26.63,26.73,310454,25.9558478937,25.9947476283,25.897498291799998,25.9947476283,310454,0.0,1.0\nBWX,2017-04-12,26.83,26.88,26.67,26.75,308141,26.091996964699998,26.1406216329,25.9363980264,26.014197495599998,308141,0.0,1.0\nBWX,2017-04-13,26.82,26.88,26.791,26.84,425499,26.0822720311,26.1406216329,26.0540697235,26.1017218983,425499,0.0,1.0\nBWX,2017-04-17,26.92,27.04,26.88,26.95,369015,26.1795213675,26.2962205712,26.1406216329,26.208696168400003,369015,0.0,1.0\nBWX,2017-04-18,27.1,27.2,26.95,27.01,449323,26.3545701731,26.451819509499998,26.208696168400003,26.2670457703,449323,0.0,1.0\nBWX,2017-04-19,27.04,27.1,26.96,27.08,349422,26.2962205712,26.3545701731,26.2184211021,26.3351203058,349422,0.0,1.0\nBWX,2017-04-20,26.95,27.08,26.94,27.0,553627,26.208696168400003,26.3351203058,26.198971234800002,26.257320836599998,553627,0.0,1.0\nBWX,2017-04-21,26.97,27.0,26.93,26.97,289216,26.2281460357,26.257320836599998,26.189246301100006,26.2281460357,289216,0.0,1.0\nBWX,2017-04-24,27.11,27.119,27.02,27.05,143096,26.364295106700002,26.373047547,26.2767707039,26.3059455048,143096,0.0,1.0\nBWX,2017-04-25,27.04,27.09,27.0,27.02,211445,26.2962205712,26.3448452394,26.257320836599998,26.2767707039,211445,0.0,1.0\nBWX,2017-04-26,26.92,26.99,26.84,26.93,300562,26.1795213675,26.247595903,26.1017218983,26.189246301100006,300562,0.0,1.0\nBWX,2017-04-27,26.97,27.02,26.859,26.95,261953,26.2281460357,26.2767707039,26.1201992723,26.208696168400003,261953,0.0,1.0\nBWX,2017-04-28,27.02,27.05,26.95,27.05,147903,26.2767707039,26.3059455048,26.208696168400003,26.3059455048,147903,0.0,1.0\nBWX,2017-05-01,27.02,27.07,26.93,26.98,1425898,26.2767707039,26.3253953721,26.189246301100006,26.237870969299998,1425898,0.0,1.0\nBWX,2017-05-02,26.99,27.05,26.9,26.98,527017,26.247595903,26.3059455048,26.1600715002,26.237870969299998,527017,0.0,1.0\nBWX,2017-05-03,26.92,27.08,26.89,26.94,744842,26.1795213675,26.3351203058,26.1503465666,26.198971234800002,744842,0.0,1.0\nBWX,2017-05-04,26.99,27.03,26.81,26.85,860650,26.247595903,26.2864956376,26.072547097399998,26.111446832,860650,0.0,1.0\nBWX,2017-05-05,27.03,27.03,26.9,26.97,160132,26.2864956376,26.2864956376,26.1600715002,26.2281460357,160132,0.0,1.0\nBWX,2017-05-08,26.87,26.94,26.8101,26.94,142746,26.1308966993,26.198971234800002,26.072644346700002,26.198971234800002,142746,0.0,1.0\nBWX,2017-05-09,26.67,26.7444,26.611,26.68,1410346,25.9363980264,26.0087515327,25.8790209179,25.94612296,1410346,0.0,1.0\nBWX,2017-05-10,26.65,26.73,26.59,26.68,276054,25.9169481591,25.9947476283,25.8585985573,25.94612296,276054,0.0,1.0\nBWX,2017-05-11,26.64,26.689,26.63,26.66,183915,25.9072232255,25.9548754003,25.897498291799998,25.9266730928,183915,0.0,1.0\nBWX,2017-05-12,26.86,26.8691,26.7435,26.8,155403,26.1211717656,26.1300214552,26.0078762887,26.0628221638,155403,0.0,1.0\nBWX,2017-05-15,26.89,26.97,26.84,26.96,132927,26.1503465666,26.2281460357,26.1017218983,26.2184211021,132927,0.0,1.0\nBWX,2017-05-16,27.05,27.13,26.94,26.99,137012,26.3059455048,26.383744974000006,26.198971234800002,26.247595903,137012,0.0,1.0\nBWX,2017-05-17,27.31,27.37,27.17,27.25,166705,26.558793779600002,26.6171433814,26.4226447086,26.5004441777,166705,0.0,1.0\nBWX,2017-05-18,27.29,27.39,27.22,27.34,305427,26.5393439123,26.6365932487,26.4712693768,26.587968580500004,305427,0.0,1.0\nBWX,2017-05-19,27.46,27.5,27.37,27.42,146199,26.7046677842,26.743567518800006,26.6171433814,26.6657680496,146199,0.0,1.0\nPDBC,2017-01-03,17.18,17.43,16.99,17.01,584179,16.126232157,16.3608979334,15.9478861669,15.966659429000002,584179,0.0,1.0\nPDBC,2017-01-04,17.21,17.24,17.04,17.06,105778,16.1543920502,16.1825519433,15.9948193222,16.0135925843,105778,0.0,1.0\nPDBC,2017-01-05,17.26,17.36,17.14,17.29,361848,16.2013252054,16.295191516,16.0886856328,16.229485098599998,361848,0.0,1.0\nPDBC,2017-01-06,17.26,17.35,17.19,17.35,333611,16.2013252054,16.285804885,16.135618788,16.285804885,333611,0.0,1.0\nPDBC,2017-01-09,17.0,17.14,16.96,17.14,196426,15.9572727979,16.0886856328,15.919726273699998,16.0886856328,196426,0.0,1.0\nPDBC,2017-01-10,16.91,17.1,16.91,17.06,86043,15.872793118399999,16.0511391085,15.872793118399999,16.0135925843,86043,0.0,1.0\nPDBC,2017-01-11,17.14,17.2,16.9,17.0001,2972977,16.0886856328,16.145005419100002,15.8634064874,15.9573666643,2972977,0.0,1.0\nPDBC,2017-01-12,17.37,17.39,17.18,17.18,6314119,16.3045781471,16.3233514092,16.126232157,16.126232157,6314119,0.0,1.0\nPDBC,2017-01-13,17.35,17.36,17.2836,17.3,1372934,16.285804885,16.295191516,16.2234776547,16.2388717297,1372934,0.0,1.0\nPDBC,2017-01-17,17.36,17.53,17.34,17.34,168359,16.295191516,16.454764244,16.2764182539,16.2764182539,168359,0.0,1.0\nPDBC,2017-01-18,17.18,17.615,17.11,17.23,516201,16.126232157,16.534550608,16.0605257396,16.1731653123,516201,0.0,1.0\nPDBC,2017-01-19,17.15,17.25,16.96,17.16,122195,16.098072263800002,16.1919385744,15.919726273699998,16.1074588949,122195,0.0,1.0\nPDBC,2017-01-20,17.315,17.46,17.19,17.19,122163,16.2529516763,16.389057826600002,16.135618788,16.135618788,122163,0.0,1.0\nPDBC,2017-01-23,17.34,17.36,17.24,17.24,101888,16.2764182539,16.295191516,16.1825519433,16.1825519433,101888,0.0,1.0\nPDBC,2017-01-24,17.37,17.45,17.35,17.41,2696069,16.3045781471,16.3796711955,16.285804885,16.342124671300002,2696069,0.0,1.0\nPDBC,2017-01-25,17.28,17.3399,17.23,17.25,850437,16.2200984676,16.2763243876,16.1731653123,16.1919385744,850437,0.0,1.0\nPDBC,2017-01-26,17.32,17.42,17.3101,17.32,524137,16.2576449918,16.3515113024,16.248352227,16.2576449918,524137,0.0,1.0\nPDBC,2017-01-27,17.2,17.33,17.13,17.33,219873,16.145005419100002,16.2670316229,16.0792990017,16.2670316229,219873,0.0,1.0\nPDBC,2017-01-30,17.11,17.17,17.05,17.17,124553,16.0605257396,16.1168455259,16.0042059532,16.1168455259,124553,0.0,1.0\nPDBC,2017-01-31,17.2,17.2901,17.14,17.25,341055,16.145005419100002,16.2295789649,16.0886856328,16.1919385744,341055,0.0,1.0\nPDBC,2017-02-01,17.37,17.41,17.21,17.27,518073,16.3045781471,16.342124671300002,16.1543920502,16.2107118365,518073,0.0,1.0\nPDBC,2017-02-02,17.39,17.44,17.322,17.44,913170,16.3233514092,16.3702845645,16.259522318,16.3702845645,913170,0.0,1.0\nPDBC,2017-02-03,17.3599,17.41,17.27,17.3,141719,16.2950976497,16.342124671300002,16.2107118365,16.2388717297,141719,0.0,1.0\nPDBC,2017-02-06,17.25,17.4,17.22,17.35,271274,16.1919385744,16.3327380403,16.1637786812,16.285804885,271274,0.0,1.0\nPDBC,2017-02-07,17.19,17.2,17.1,17.2,549810,16.135618788,16.145005419100002,16.0511391085,16.145005419100002,549810,0.0,1.0\nPDBC,2017-02-08,17.25,17.2861,17.137,17.14,191629,16.1919385744,16.2258243125,16.0858696434,16.0886856328,191629,0.0,1.0\nPDBC,2017-02-09,17.3,17.38,17.275,17.32,2313702,16.2388717297,16.3139647781,16.215405152,16.2576449918,2313702,0.0,1.0\nPDBC,2017-02-10,17.515,17.54,17.34,17.34,378074,16.4406842974,16.4641508751,16.2764182539,16.2764182539,378074,0.0,1.0\nPDBC,2017-02-13,17.29,17.36,17.29,17.36,171296,16.229485098599998,16.295191516,16.229485098599998,16.295191516,171296,0.0,1.0\nPDBC,2017-02-14,17.36,17.45,17.29,17.43,422607,16.295191516,16.3796711955,16.229485098599998,16.3608979334,422607,0.0,1.0\nPDBC,2017-02-15,17.34,17.41,17.2865,17.29,206666,16.2764182539,16.342124671300002,16.226199777799998,16.229485098599998,206666,0.0,1.0\nPDBC,2017-02-16,17.25,17.3724,17.2278,17.37,124777,16.1919385744,16.3068309385,16.1711002534,16.3045781471,124777,0.0,1.0\nPDBC,2017-02-17,17.21,17.24,17.14,17.15,4066470,16.1543920502,16.1825519433,16.0886856328,16.098072263800002,4066470,0.0,1.0\nPDBC,2017-02-21,17.22,17.45,17.1901,17.45,1144900,16.1637786812,16.3796711955,16.1357126544,16.3796711955,1144900,0.0,1.0\nPDBC,2017-02-22,17.16,17.16,17.1,17.14,299522,16.1074588949,16.1074588949,16.0511391085,16.0886856328,299522,0.0,1.0\nPDBC,2017-02-23,17.17,17.31,17.05,17.05,282740,16.1168455259,16.2482583607,16.0042059532,16.0042059532,282740,0.0,1.0\nPDBC,2017-02-24,17.18,17.2,17.11,17.2,269511,16.126232157,16.145005419100002,16.0605257396,16.145005419100002,269511,0.0,1.0\nPDBC,2017-02-27,17.13,17.25,17.1,17.25,166884,16.0792990017,16.1919385744,16.0511391085,16.1919385744,166884,0.0,1.0\nPDBC,2017-02-28,17.17,17.19,17.035,17.08,4903216,16.1168455259,16.135618788,15.9901260067,16.0323658464,4903216,0.0,1.0\nPDBC,2017-03-01,17.23,17.31,17.2,17.26,817631,16.1731653123,16.2482583607,16.145005419100002,16.2013252054,817631,0.0,1.0\nPDBC,2017-03-02,16.97,17.0801,16.96,17.03,173109,15.9291129048,16.0324597127,15.919726273699998,15.9854326911,173109,0.0,1.0\nPDBC,2017-03-03,17.06,17.08,16.97,16.99,160283,16.0135925843,16.0323658464,15.9291129048,15.9478861669,160283,0.0,1.0\nPDBC,2017-03-06,17.06,17.13,17.04,17.11,172174,16.0135925843,16.0792990017,15.9948193222,16.0605257396,172174,0.0,1.0\nPDBC,2017-03-07,16.97,17.11,16.95,17.11,100331,15.9291129048,16.0605257396,15.9103396427,16.0605257396,100331,0.0,1.0\nPDBC,2017-03-08,16.58,16.96,16.565,16.93,119834,15.5630342935,15.919726273699998,15.5489543469,15.891566380499999,119834,0.0,1.0\nPDBC,2017-03-09,16.42,16.53,16.3101,16.53,2721619,15.4128481966,15.5161011382,15.309689121300002,15.5161011382,2721619,0.0,1.0\nPDBC,2017-03-10,16.29,16.4468,16.26,16.34,308196,15.2908219929,15.4380043678,15.2626620997,15.3377551481,308196,0.0,1.0\nPDBC,2017-03-13,16.25,16.35,16.24,16.35,246379,15.253275468599998,15.347141779200001,15.2438888376,15.347141779200001,246379,0.0,1.0\nPDBC,2017-03-14,16.2,16.21,16.08,16.16,250163,15.206342313299999,15.2157289444,15.0937027406,15.1687957891,250163,0.0,1.0\nPDBC,2017-03-15,16.35,16.35,16.2301,16.3,186961,15.347141779200001,15.347141779200001,15.2345960728,15.300208623900001,186961,0.0,1.0\nPDBC,2017-03-16,16.34,16.39,16.31,16.39,104170,15.3377551481,15.384688303399999,15.309595255,15.384688303399999,104170,0.0,1.0\nPDBC,2017-03-17,16.36,16.42,16.31,16.31,99793,15.3565284103,15.4128481966,15.309595255,15.309595255,99793,0.0,1.0\nPDBC,2017-03-20,16.37,16.42,16.33,16.35,220967,15.365915041300001,15.4128481966,15.328368517100001,15.347141779200001,220967,0.0,1.0\nPDBC,2017-03-21,16.28,16.4312,16.2704,16.41,304485,15.2814353618,15.423361223399999,15.272424196,15.403461565599999,304485,0.0,1.0\nPDBC,2017-03-22,16.27,16.32,16.15,16.25,270868,15.2720487307,15.318981886,15.159409158099999,15.253275468599998,270868,0.0,1.0\nPDBC,2017-03-23,16.22,16.25,16.2,16.22,89692,15.2251155755,15.253275468599998,15.206342313299999,15.2251155755,89692,0.0,1.0\nPDBC,2017-03-24,16.31,16.31,16.2301,16.24,386589,15.309595255,15.309595255,15.2345960728,15.2438888376,386589,0.0,1.0\nPDBC,2017-03-27,16.29,16.34,16.06,16.34,102169,15.2908219929,15.3377551481,15.0749294785,15.3377551481,102169,0.0,1.0\nPDBC,2017-03-28,16.345,16.42,16.32,16.33,237287,15.342448463699998,15.4128481966,15.318981886,15.328368517100001,237287,0.0,1.0\nPDBC,2017-03-29,16.505,16.52,16.41,16.421,122932,15.492634560599999,15.5067145072,15.403461565599999,15.4137868597,122932,0.0,1.0\nPDBC,2017-03-30,16.55,16.61,16.48,16.48,147966,15.5348744004,15.591194186700001,15.469167983,15.469167983,147966,0.0,1.0\nPDBC,2017-03-31,16.63,16.65,16.53,16.53,131849,15.6099674488,15.628740710899999,15.5161011382,15.5161011382,131849,0.0,1.0\nPDBC,2017-04-03,16.5599,16.67,16.5366,16.65,155349,15.5441671651,15.647513972999999,15.5222963147,15.628740710899999,155349,0.0,1.0\nPDBC,2017-04-04,16.71,16.74,16.49,16.49,1078071,15.6850604973,15.7132203905,15.478554614,15.478554614,1078071,0.0,1.0\nPDBC,2017-04-05,16.76,16.89,16.73,16.87,180924,15.7319936526,15.8540198563,15.7038337594,15.835246594200001,180924,0.0,1.0\nPDBC,2017-04-06,16.8,16.8613,16.8,16.83,82822,15.769540176800001,15.8270802252,15.769540176800001,15.79770007,82822,0.0,1.0\nPDBC,2017-04-07,16.85,16.89,16.8,16.82,305215,15.816473332100001,15.8540198563,15.769540176800001,15.788313438900001,305215,0.0,1.0\nPDBC,2017-04-10,16.94,16.95,16.87,16.89,136668,15.9009530116,15.9103396427,15.835246594200001,15.8540198563,136668,0.0,1.0\nPDBC,2017-04-11,16.99,17.02,16.87,16.98,177866,15.9478861669,15.9760460601,15.835246594200001,15.9384995358,177866,0.0,1.0\nPDBC,2017-04-12,16.97,17.07,16.91,16.91,1090392,15.9291129048,16.0229792154,15.872793118399999,15.872793118399999,1090392,0.0,1.0\nPDBC,2017-04-13,17.03,17.09,17.0,17.07,179802,15.9854326911,16.0417524775,15.9572727979,16.0229792154,179802,0.0,1.0\nPDBC,2017-04-17,16.96,17.07,16.96,17.01,170970,15.919726273699998,16.0229792154,15.919726273699998,15.966659429000002,170970,0.0,1.0\nPDBC,2017-04-18,16.87,16.92,16.79,16.86,278667,15.835246594200001,15.8821797495,15.7601535457,15.8258599631,278667,0.0,1.0\nPDBC,2017-04-19,16.6,16.92,16.53,16.88,273106,15.5818075556,15.8821797495,15.5161011382,15.844633225299999,273106,0.0,1.0\nPDBC,2017-04-20,16.53,16.6536,16.52,16.64,101904,15.5161011382,15.6321198981,15.5067145072,15.6193540799,101904,0.0,1.0\nPDBC,2017-04-21,16.37,16.56,16.345,16.56,115596,15.365915041300001,15.544261031400001,15.342448463699998,15.544261031400001,115596,0.0,1.0\nPDBC,2017-04-24,16.31,16.37,16.2818,16.37,589820,15.309595255,15.365915041300001,15.2831249554,15.365915041300001,589820,0.0,1.0\nPDBC,2017-04-25,16.4199,16.42,16.27,16.3,120037,15.4127543303,15.4128481966,15.2720487307,15.300208623900001,120037,0.0,1.0\nPDBC,2017-04-26,16.28,16.42,16.28,16.34,187960,15.2814353618,15.4128481966,15.2814353618,15.3377551481,187960,0.0,1.0\nPDBC,2017-04-27,16.19,16.21,16.07,16.18,137895,15.1969556823,15.2157289444,15.0843161096,15.187569051199999,137895,0.0,1.0\nPDBC,2017-04-28,16.21,16.27,16.16,16.25,134431,15.2157289444,15.2720487307,15.1687957891,15.253275468599998,134431,0.0,1.0\nPDBC,2017-05-01,16.2,16.26,16.18,16.19,4178182,15.206342313299999,15.2626620997,15.187569051199999,15.1969556823,4178182,0.0,1.0\nPDBC,2017-05-02,16.02,16.2,15.99,16.18,1292996,15.0373829543,15.206342313299999,15.009223061099998,15.187569051199999,1292996,0.0,1.0\nPDBC,2017-05-03,15.99,16.045,15.94,16.03,151372,15.009223061099998,15.0608495319,14.962289905799999,15.0467695854,151372,0.0,1.0\nPDBC,2017-05-04,15.57,15.84,15.56,15.84,191113,14.614984556700001,14.8684235953,14.6055979257,14.8684235953,191113,0.0,1.0\nPDBC,2017-05-05,15.8,15.8101,15.63,15.64,256364,14.830877071,14.8403575684,14.6713043431,14.680690974100001,256364,0.0,1.0\nPDBC,2017-05-08,15.73,15.7714,15.63,15.74,3583381,14.765170653599998,14.804031306199999,14.6713043431,14.7745572847,3583381,0.0,1.0\nPDBC,2017-05-09,15.66,15.76,15.62,15.75,1794306,14.699464236199999,14.7933305468,14.661917712000001,14.7839439157,1794306,0.0,1.0\nPDBC,2017-05-10,15.9,15.95,15.7,15.7,1008639,14.9247433816,14.9716765369,14.737010760499999,14.737010760499999,1008639,0.0,1.0\nPDBC,2017-05-11,15.99,16.02,15.95,15.99,245884,15.009223061099998,15.0373829543,14.9716765369,15.009223061099998,245884,0.0,1.0\nPDBC,2017-05-12,15.99,16.0,15.935,15.99,126930,15.009223061099998,15.0186096922,14.9575965903,15.009223061099998,126930,0.0,1.0\nPDBC,2017-05-15,16.11,16.19,16.06,16.19,114980,15.121862633800001,15.1969556823,15.0749294785,15.1969556823,114980,0.0,1.0\nPDBC,2017-05-16,16.1,16.16,16.07,16.12,127784,15.1124760028,15.1687957891,15.0843161096,15.1312492649,127784,0.0,1.0\nPDBC,2017-05-17,16.2,16.25,16.14,16.21,143119,15.206342313299999,15.253275468599998,15.150022527,15.2157289444,143119,0.0,1.0\nPDBC,2017-05-18,16.16,16.22,16.0798,16.1,106663,15.1687957891,15.2251155755,15.093515007999999,15.1124760028,106663,0.0,1.0\nPDBC,2017-05-19,16.48,16.48,16.34,16.34,477582,15.469167983,15.469167983,15.3377551481,15.3377551481,477582,0.0,1.0\nIAU,2017-01-03,11.16,11.22,11.05,11.09,7937709,11.16,11.22,11.05,11.09,7937709,0.0,1.0\nIAU,2017-01-04,11.21,11.24,11.18,11.23,8860361,11.21,11.24,11.18,11.23,8860361,0.0,1.0\nIAU,2017-01-05,11.38,11.415,11.325,11.33,14333931,11.38,11.415,11.325,11.33,14333931,0.0,1.0\nIAU,2017-01-06,11.3,11.3599,11.27,11.3,10424990,11.3,11.3599,11.27,11.3,10424990,0.0,1.0\nIAU,2017-01-09,11.38,11.42,11.34,11.35,6615967,11.38,11.42,11.34,11.35,6615967,0.0,1.0\nIAU,2017-01-10,11.44,11.47,11.38,11.41,6144327,11.44,11.47,11.38,11.41,6144327,0.0,1.0\nIAU,2017-01-11,11.46,11.54,11.33,11.41,10125476,11.46,11.54,11.33,11.41,10125476,0.0,1.0\nIAU,2017-01-12,11.51,11.62,11.5,11.58,7555113,11.51,11.62,11.5,11.58,7555113,0.0,1.0\nIAU,2017-01-13,11.55,11.55,11.44,11.49,5288265,11.55,11.55,11.44,11.49,5288265,0.0,1.0\nIAU,2017-01-17,11.71,11.72,11.67,11.71,6359304,11.71,11.72,11.67,11.71,6359304,0.0,1.0\nIAU,2017-01-18,11.6,11.719,11.5701,11.7,7732090,11.6,11.719,11.5701,11.7,7732090,0.0,1.0\nIAU,2017-01-19,11.59,11.62,11.51,11.56,9849442,11.59,11.62,11.51,11.56,9849442,0.0,1.0\nIAU,2017-01-20,11.62,11.7,11.55,11.59,5982044,11.62,11.7,11.55,11.59,5982044,0.0,1.0\nIAU,2017-01-23,11.7,11.74,11.6431,11.68,6801599,11.7,11.74,11.6431,11.68,6801599,0.0,1.0\nIAU,2017-01-24,11.65,11.73,11.62,11.69,6298510,11.65,11.73,11.62,11.69,6298510,0.0,1.0\nIAU,2017-01-25,11.56,11.57,11.49,11.54,6912066,11.56,11.57,11.49,11.54,6912066,0.0,1.0\nIAU,2017-01-26,11.44,11.47,11.4,11.45,9303593,11.44,11.47,11.4,11.45,9303593,0.0,1.0\nIAU,2017-01-27,11.46,11.48,11.4,11.41,8744621,11.46,11.48,11.4,11.41,8744621,0.0,1.0\nIAU,2017-01-30,11.52,11.5463,11.47,11.48,8660388,11.52,11.5463,11.47,11.48,8660388,0.0,1.0\nIAU,2017-01-31,11.67,11.71,11.64,11.65,11920097,11.67,11.71,11.64,11.65,11920097,0.0,1.0\nIAU,2017-02-01,11.64,11.67,11.54,11.59,9800007,11.64,11.67,11.54,11.59,9800007,0.0,1.0\nIAU,2017-02-02,11.7,11.78,11.68,11.75,7043752,11.7,11.78,11.68,11.75,7043752,0.0,1.0\nIAU,2017-02-03,11.74,11.76,11.69,11.7,6555145,11.74,11.76,11.69,11.7,6555145,0.0,1.0\nIAU,2017-02-06,11.9,11.9,11.8,11.83,5639706,11.9,11.9,11.8,11.83,5639706,0.0,1.0\nIAU,2017-02-07,11.87,11.9,11.84,11.86,5765219,11.87,11.9,11.84,11.86,5765219,0.0,1.0\nIAU,2017-02-08,11.94,11.99,11.9,11.94,6623471,11.94,11.99,11.9,11.94,6623471,0.0,1.0\nIAU,2017-02-09,11.86,11.98,11.84,11.94,7884906,11.86,11.98,11.84,11.94,7884906,0.0,1.0\nIAU,2017-02-10,11.89,11.91,11.79,11.8,7489365,11.89,11.91,11.79,11.8,7489365,0.0,1.0\nIAU,2017-02-13,11.81,11.82,11.74,11.8,5365418,11.81,11.82,11.74,11.8,5365418,0.0,1.0\nIAU,2017-02-14,11.82,11.88,11.76,11.88,8850069,11.82,11.88,11.76,11.88,8850069,0.0,1.0\nIAU,2017-02-15,11.87,11.88,11.75,11.75,7483706,11.87,11.88,11.75,11.75,7483706,0.0,1.0\nIAU,2017-02-16,11.93,11.96,11.91,11.92,13999799,11.93,11.96,11.91,11.92,13999799,0.0,1.0\nIAU,2017-02-17,11.89,11.97,11.89,11.95,5523224,11.89,11.97,11.89,11.95,5523224,0.0,1.0\nIAU,2017-02-21,11.9,11.93,11.8,11.83,5147996,11.9,11.93,11.8,11.83,5147996,0.0,1.0\nIAU,2017-02-22,11.91,11.93,11.85,11.92,7404628,11.91,11.93,11.85,11.92,7404628,0.0,1.0\nIAU,2017-02-23,12.03,12.05,11.99,12.0,5688125,12.03,12.05,11.99,12.0,5688125,0.0,1.0\nIAU,2017-02-24,12.09,12.12,12.05,12.1,4833489,12.09,12.12,12.05,12.1,4833489,0.0,1.0\nIAU,2017-02-27,12.04,12.17,12.04,12.1,6367492,12.04,12.17,12.04,12.1,6367492,0.0,1.0\nIAU,2017-02-28,12.04,12.12,12.01,12.11,6151837,12.04,12.12,12.01,12.11,6151837,0.0,1.0\nIAU,2017-03-01,12.04,12.04,11.92,11.93,7396838,12.04,12.04,11.92,11.93,7396838,0.0,1.0\nIAU,2017-03-02,11.89,11.96,11.85,11.91,28204041,11.89,11.96,11.85,11.91,28204041,0.0,1.0\nIAU,2017-03-03,11.88,11.9,11.77,11.83,7965528,11.88,11.9,11.77,11.83,7965528,0.0,1.0\nIAU,2017-03-06,11.8,11.87,11.79,11.86,5144996,11.8,11.87,11.79,11.86,5144996,0.0,1.0\nIAU,2017-03-07,11.71,11.75,11.68,11.73,6028823,11.71,11.75,11.68,11.73,6028823,0.0,1.0\nIAU,2017-03-08,11.63,11.66,11.62,11.63,8427233,11.63,11.66,11.62,11.63,8427233,0.0,1.0\nIAU,2017-03-09,11.57,11.62,11.56,11.6,4983171,11.57,11.62,11.56,11.6,4983171,0.0,1.0\nIAU,2017-03-10,11.59,11.6,11.53,11.57,5657059,11.59,11.6,11.53,11.57,5657059,0.0,1.0\nIAU,2017-03-13,11.59,11.62,11.57,11.59,4695421,11.59,11.62,11.57,11.59,4695421,0.0,1.0\nIAU,2017-03-14,11.54,11.62,11.52,11.57,4593580,11.54,11.62,11.52,11.57,4593580,0.0,1.0\nIAU,2017-03-15,11.74,11.75,11.53,11.56,8413996,11.74,11.75,11.53,11.56,8413996,0.0,1.0\nIAU,2017-03-16,11.81,11.86,11.79,11.86,5049313,11.81,11.86,11.79,11.86,5049313,0.0,1.0\nIAU,2017-03-17,11.82,11.86,11.82,11.83,5558617,11.82,11.86,11.82,11.83,5558617,0.0,1.0\nIAU,2017-03-20,11.87,11.89,11.85,11.86,3620513,11.87,11.89,11.85,11.86,3620513,0.0,1.0\nIAU,2017-03-21,11.98,12.01,11.9,11.9,8375542,11.98,12.01,11.9,11.9,8375542,0.0,1.0\nIAU,2017-03-22,12.02,12.05,12.0,12.02,5795112,12.02,12.05,12.0,12.02,5795112,0.0,1.0\nIAU,2017-03-23,11.99,12.06,11.96,12.05,5571714,11.99,12.06,11.96,12.05,5571714,0.0,1.0\nIAU,2017-03-24,12.01,12.05,11.97,11.99,5207326,12.01,12.05,11.97,11.99,5207326,0.0,1.0\nIAU,2017-03-27,12.08,12.14,12.05,12.13,6528379,12.08,12.14,12.05,12.13,6528379,0.0,1.0\nIAU,2017-03-28,12.04,12.11,12.0,12.11,5150201,12.04,12.11,12.0,12.11,5150201,0.0,1.0\nIAU,2017-03-29,12.07,12.08,12.03,12.05,4121615,12.07,12.08,12.03,12.05,4121615,0.0,1.0\nIAU,2017-03-30,11.97,12.04,11.96,12.0,4187669,11.97,12.04,11.96,12.0,4187669,0.0,1.0\nIAU,2017-03-31,12.01,12.04,11.97,11.99,5233055,12.01,12.04,11.97,11.99,5233055,0.0,1.0\nIAU,2017-04-03,12.07,12.07,11.99,12.0,4738115,12.07,12.07,11.99,12.0,4738115,0.0,1.0\nIAU,2017-04-04,12.09,12.11,12.07,12.09,4650722,12.09,12.11,12.07,12.09,4650722,0.0,1.0\nIAU,2017-04-05,12.1,12.1,11.97,11.99,7201904,12.1,12.1,11.97,11.99,7201904,0.0,1.0\nIAU,2017-04-06,12.05,12.07,12.03,12.05,6280696,12.05,12.07,12.03,12.05,6280696,0.0,1.0\nIAU,2017-04-07,12.08,12.2,12.04,12.16,7501537,12.08,12.2,12.04,12.16,7501537,0.0,1.0\nIAU,2017-04-10,12.08,12.1,12.02,12.04,9857611,12.08,12.1,12.02,12.04,9857611,0.0,1.0\nIAU,2017-04-11,12.24,12.27,12.16,12.16,7803358,12.24,12.27,12.16,12.16,7803358,0.0,1.0\nIAU,2017-04-12,12.34,12.36,12.24,12.27,13581260,12.34,12.36,12.24,12.27,13581260,0.0,1.0\nIAU,2017-04-13,12.4,12.4,12.33,12.39,4541407,12.4,12.4,12.33,12.39,4541407,0.0,1.0\nIAU,2017-04-17,12.35,12.44,12.33,12.39,5767192,12.35,12.44,12.33,12.39,5767192,0.0,1.0\nIAU,2017-04-18,12.41,12.44,12.31,12.38,8307456,12.41,12.44,12.31,12.38,8307456,0.0,1.0\nIAU,2017-04-19,12.31,12.36,12.26,12.36,7782175,12.31,12.36,12.26,12.36,7782175,0.0,1.0\nIAU,2017-04-20,12.33,12.35,12.28,12.31,5126305,12.33,12.35,12.28,12.31,5126305,0.0,1.0\nIAU,2017-04-21,12.37,12.4,12.31,12.35,8352730,12.37,12.4,12.31,12.35,8352730,0.0,1.0\nIAU,2017-04-24,12.29,12.29,12.2,12.21,5454472,12.29,12.29,12.2,12.21,5454472,0.0,1.0\nIAU,2017-04-25,12.16,12.22,12.14,12.19,7624509,12.16,12.22,12.14,12.19,7624509,0.0,1.0\nIAU,2017-04-26,12.22,12.23,12.12,12.16,7094617,12.22,12.23,12.12,12.16,7094617,0.0,1.0\nIAU,2017-04-27,12.18,12.19,12.13,12.18,7420745,12.18,12.19,12.13,12.18,7420745,0.0,1.0\nIAU,2017-04-28,12.21,12.21,12.16,12.16,7477191,12.21,12.21,12.16,12.16,7477191,0.0,1.0\nIAU,2017-05-01,12.09,12.21,12.07,12.16,8020570,12.09,12.21,12.07,12.16,8020570,0.0,1.0\nIAU,2017-05-02,12.1,12.1,12.05,12.06,5347623,12.1,12.1,12.05,12.06,5347623,0.0,1.0\nIAU,2017-05-03,11.92,12.06,11.92,12.05,28859724,11.92,12.06,11.92,12.05,28859724,0.0,1.0\nIAU,2017-05-04,11.81,11.86,11.79,11.8,7499030,11.81,11.86,11.79,11.8,7499030,0.0,1.0\nIAU,2017-05-05,11.82,11.84,11.79,11.81,4265861,11.82,11.84,11.79,11.81,4265861,0.0,1.0\nIAU,2017-05-08,11.8,11.85,11.79,11.83,4139587,11.8,11.85,11.79,11.83,4139587,0.0,1.0\nIAU,2017-05-09,11.73,11.75,11.68,11.75,9599366,11.73,11.75,11.68,11.75,9599366,0.0,1.0\nIAU,2017-05-10,11.74,11.78,11.71,11.77,5605514,11.74,11.78,11.71,11.77,5605514,0.0,1.0\nIAU,2017-05-11,11.77,11.81,11.74,11.75,5806127,11.77,11.81,11.74,11.75,5806127,0.0,1.0\nIAU,2017-05-12,11.81,11.85,11.8,11.83,3596295,11.81,11.85,11.8,11.83,3596295,0.0,1.0\nIAU,2017-05-15,11.85,11.89,11.83,11.88,4323273,11.85,11.89,11.83,11.88,4323273,0.0,1.0\nIAU,2017-05-16,11.89,11.93,11.87,11.88,4855073,11.89,11.93,11.87,11.88,4855073,0.0,1.0\nIAU,2017-05-17,12.12,12.14,12.06,12.07,8534265,12.12,12.14,12.06,12.07,8534265,0.0,1.0\nIAU,2017-05-18,12.02,12.12,11.98,12.11,6349296,12.02,12.12,11.98,12.11,6349296,0.0,1.0\nIAU,2017-05-19,12.08,12.09,12.02,12.07,7532037,12.08,12.09,12.02,12.07,7532037,0.0,1.0\nVNQI,2017-01-03,49.86,49.8615,49.62,49.73,512706,42.517266873400004,42.5185459729,42.3126109559,42.4064115848,512706,0.0,1.0\nVNQI,2017-01-04,50.52,50.5299,50.16,50.16,468586,43.0800706467,43.0885127033,42.7730867704,42.7730867704,468586,0.0,1.0\nVNQI,2017-01-05,51.12,51.16,50.67,50.67,1449652,43.5917104406,43.6258197602,43.207980595200006,43.207980595200006,1449652,0.0,1.0\nVNQI,2017-01-06,51.07,51.25,51.015,51.12,2760414,43.5490737911,43.702565729300005,43.5021734767,43.5917104406,2760414,0.0,1.0\nVNQI,2017-01-09,50.97,51.04,50.9,50.94,334101,43.4638004922,43.523491801400006,43.4041091829,43.4382185025,334101,0.0,1.0\nVNQI,2017-01-10,51.08,51.2096,51.05,51.11,353081,43.557601121000005,43.66811531649999,43.532019131300004,43.5831831107,353081,0.0,1.0\nVNQI,2017-01-11,51.14,51.21,50.6826,50.87,547448,43.6087651004,43.6684564097,43.2187250309,43.3785271932,547448,0.0,1.0\nVNQI,2017-01-12,50.85,51.04,50.7675,51.04,6141626,43.361472533400004,43.523491801400006,43.2911220617,43.523491801400006,6141626,0.0,1.0\nVNQI,2017-01-13,50.89,50.94,50.7,50.7,284764,43.395581853,43.4382185025,43.2335625849,43.2335625849,284764,0.0,1.0\nVNQI,2017-01-17,50.84,50.85,50.6506,50.85,253329,43.352945203500006,43.361472533400004,43.191437575200005,43.361472533400004,253329,0.0,1.0\nVNQI,2017-01-18,50.84,50.949,50.7,50.78,416798,43.352945203500006,43.445893099399996,43.2335625849,43.3017812241,416798,0.0,1.0\nVNQI,2017-01-19,50.49,50.5815,50.2928,50.51,262290,43.054488657,43.132513725600006,42.8863297114,43.0715433168,262290,0.0,1.0\nVNQI,2017-01-20,50.59,50.62,50.42,50.42,311393,43.139761956,43.165343945699995,42.99479734770001,42.99479734770001,311393,0.0,1.0\nVNQI,2017-01-23,51.0,51.04,50.67,50.73,477814,43.4893824818,43.523491801400006,43.207980595200006,43.2591445746,477814,0.0,1.0\nVNQI,2017-01-24,51.13,51.19,50.91,50.91,444378,43.6002377705,43.651401749899996,43.4126365128,43.4126365128,444378,0.0,1.0\nVNQI,2017-01-25,51.21,51.25,50.92,51.02,499880,43.6684564097,43.702565729300005,43.421163842700004,43.506437141599996,499880,0.0,1.0\nVNQI,2017-01-26,51.03,51.19,50.98,51.16,235578,43.514964471499994,43.651401749899996,43.4723278221,43.6258197602,235578,0.0,1.0\nVNQI,2017-01-27,50.94,51.1,50.92,51.1,362788,43.4382185025,43.5746557808,43.421163842700004,43.5746557808,362788,0.0,1.0\nVNQI,2017-01-30,50.84,50.86,50.59,50.8,356612,43.352945203500006,43.3699998633,43.139761956,43.3188358839,356612,0.0,1.0\nVNQI,2017-01-31,51.23,51.27,51.02,51.12,570838,43.685511069499995,43.7196203891,43.506437141599996,43.5917104406,570838,0.0,1.0\nVNQI,2017-02-01,51.35,51.4399,51.23,51.34,303528,43.7878390283,43.8644997241,43.685511069499995,43.7793116984,303528,0.0,1.0\nVNQI,2017-02-02,51.35,51.46,51.2258,51.43,264911,43.7878390283,43.881639657200004,43.68192959100001,43.8560576675,264911,0.0,1.0\nVNQI,2017-02-03,51.62,51.67,51.422,51.54,331740,44.01807693550001,44.060713585,43.849235803599996,43.9498582964,331740,0.0,1.0\nVNQI,2017-02-06,51.42,51.46,51.3001,51.46,310387,43.84753033760001,43.881639657200004,43.745287652100004,43.881639657200004,310387,0.0,1.0\nVNQI,2017-02-07,51.6,51.67,51.464,51.48,228057,44.0010222758,44.060713585,43.8850505891,43.898694317,228057,0.0,1.0\nVNQI,2017-02-08,52.08,52.11,51.96,51.98,240449,44.4103341109,44.435916100600004,44.3080061521,44.325060811899995,240449,0.0,1.0\nVNQI,2017-02-09,52.36,52.4,52.1932,52.25,222349,44.649099348,44.6832086676,44.506863485299995,44.5552987191,222349,0.0,1.0\nVNQI,2017-02-10,52.42,52.46,52.1,52.1,320362,44.700263327399995,44.734372647,44.4273887707,44.4273887707,320362,0.0,1.0\nVNQI,2017-02-13,52.25,52.35,52.1845,52.3,229521,44.5552987191,44.6405720181,44.4994447083,44.5979353686,229521,0.0,1.0\nVNQI,2017-02-14,52.3,52.3996,52.0404,52.22,366477,44.5979353686,44.68286757439999,44.376565884499996,44.529716729499995,366477,0.0,1.0\nVNQI,2017-02-15,52.58,52.61,52.1246,52.2,1757769,44.8367006058,44.86228259550001,44.448366002200004,44.5126620697,1757769,0.0,1.0\nVNQI,2017-02-16,52.55,52.57,52.38,52.49,356842,44.811118616099996,44.8281732759,44.666154007799996,44.7599546367,356842,0.0,1.0\nVNQI,2017-02-17,52.43,52.46,52.28,52.3,345238,44.70879065729999,44.734372647,44.5808807088,44.5979353686,345238,0.0,1.0\nVNQI,2017-02-21,52.55,52.5695,52.3524,52.42,1447484,44.811118616099996,44.8277469094,44.6426185773,44.700263327399995,1447484,0.0,1.0\nVNQI,2017-02-22,52.78,52.79,52.5701,52.7,861251,45.0072472038,45.0157745337,44.8282585492,44.9390285646,861251,0.0,1.0\nVNQI,2017-02-23,52.98,53.07,52.87,52.99,235586,45.1777938017,45.2545397708,45.0839931728,45.186321131599996,235586,0.0,1.0\nVNQI,2017-02-24,52.88,52.9,52.72,52.8,258541,45.092520502700005,45.1095751625,44.9560832244,45.024301863599995,258541,0.0,1.0\nVNQI,2017-02-27,52.62,52.74,52.55,52.74,392496,44.870809925399996,44.9731378842,44.811118616099996,44.9731378842,392496,0.0,1.0\nVNQI,2017-02-28,52.6,52.73,52.55,52.63,396901,44.85375526560001,44.9646105543,44.811118616099996,44.879337255299994,396901,0.0,1.0\nVNQI,2017-03-01,52.8,52.9199,52.57,52.6,379620,45.024301863599995,45.126544548999995,44.8281732759,44.85375526560001,379620,0.0,1.0\nVNQI,2017-03-02,52.1,52.35,52.09,52.3,321334,44.4273887707,44.6405720181,44.4188614408,44.5979353686,321334,0.0,1.0\nVNQI,2017-03-03,52.25,52.3,52.02,52.05,294611,44.5552987191,44.5979353686,44.359170131499994,44.3847521212,294611,0.0,1.0\nVNQI,2017-03-06,52.2,52.23,52.08,52.22,560767,44.5126620697,44.53824405939999,44.4103341109,44.529716729499995,560767,0.0,1.0\nVNQI,2017-03-07,52.09,52.2,52.0478,52.07,779591,44.4188614408,44.5126620697,44.382876108599994,44.401806781000005,779591,0.0,1.0\nVNQI,2017-03-08,51.83,52.1399,51.83,52.08,330500,44.19715086340001,44.461412816999996,44.19715086340001,44.4103341109,330500,0.0,1.0\nVNQI,2017-03-09,51.8,51.8799,51.66,51.85,196455,44.1715688737,44.2397022396,44.052186255100004,44.2142055232,196455,0.0,1.0\nVNQI,2017-03-10,52.04,52.0461,51.86,51.98,248513,44.376224791300004,44.3814264625,44.222732853100005,44.325060811899995,248513,0.0,1.0\nVNQI,2017-03-13,52.39,52.42,52.2476,52.25,831369,44.67468133770001,44.700263327399995,44.55325216,44.5552987191,831369,0.0,1.0\nVNQI,2017-03-14,51.99,52.08,51.94,52.05,206221,44.3335881418,44.4103341109,44.290951492299996,44.3847521212,206221,0.0,1.0\nVNQI,2017-03-15,52.91,52.94,52.13,52.13,313061,45.1181024924,45.1436844821,44.4529707604,44.4529707604,313061,0.0,1.0\nVNQI,2017-03-16,53.27,53.32,53.2,53.24,196247,45.4250863688,45.4677230183,45.365395059499996,45.3995043791,196247,0.0,1.0\nVNQI,2017-03-17,53.31,53.43,53.23,53.31,174923,45.4591956884,45.56152364720001,45.3909770492,45.4591956884,174923,0.0,1.0\nVNQI,2017-03-20,53.35,53.46,53.25,53.28,479080,45.493305008,45.5871056369,45.408031709,45.4336136987,479080,0.0,1.0\nVNQI,2017-03-21,53.11,53.59,53.02,53.49,512827,45.2886490904,45.6979609255,45.211903121300004,45.6126876265,512827,0.0,1.0\nVNQI,2017-03-22,53.3,53.35,53.09,53.09,875002,45.4506683585,45.493305008,45.271594430600004,45.271594430600004,875002,0.0,1.0\nVNQI,2017-03-23,53.53,53.604,53.32,53.32,168865,45.6467969461,45.7098991874,45.4677230183,45.4677230183,168865,0.0,1.0\nVNQI,2017-03-24,53.63,53.72,53.46,53.5,202052,45.73207024510001,45.8088162142,45.5871056369,45.6212149564,202052,0.0,1.0\nVNQI,2017-03-27,53.43,53.505,53.3161,53.39,479647,45.56152364720001,45.625478621400006,45.4643973596,45.5274143276,479647,0.0,1.0\nVNQI,2017-03-28,53.45,53.5686,53.35,53.35,731410,45.578578307,45.6797124396,45.493305008,45.493305008,731410,0.0,1.0\nVNQI,2017-03-29,53.5,53.52,53.32,53.35,214544,45.6212149564,45.6382696162,45.4677230183,45.493305008,214544,0.0,1.0\nVNQI,2017-03-30,53.2,53.31,53.16,53.24,474361,45.365395059499996,45.4591956884,45.3312857399,45.3995043791,474361,0.0,1.0\nVNQI,2017-03-31,53.26,53.34,53.0744,53.11,263703,45.416559038900004,45.4847776781,45.258291796,45.2886490904,263703,0.0,1.0\nVNQI,2017-04-03,53.38,53.38,53.06,53.21,392540,45.51888699770001,45.51888699770001,45.2460124409,45.37392238939999,392540,0.0,1.0\nVNQI,2017-04-04,53.51,53.54,53.31,53.36,197565,45.6297422863,45.655324276,45.4591956884,45.5018323379,197565,0.0,1.0\nVNQI,2017-04-05,53.57,53.73,53.525,53.56,387404,45.680906265699996,45.8173435441,45.6425332812,45.6723789358,387404,0.0,1.0\nVNQI,2017-04-06,53.75,53.816,53.66,53.72,256005,45.8343982039,45.8906785812,45.7576522348,45.8088162142,256005,0.0,1.0\nVNQI,2017-04-07,53.88,53.97,53.84,53.84,237436,45.9452534926,46.0219994617,45.911144173000004,45.911144173000004,237436,0.0,1.0\nVNQI,2017-04-10,53.73,53.8,53.62,53.71,380200,45.8173435441,45.87703485340001,45.72354291520001,45.8002888843,380200,0.0,1.0\nVNQI,2017-04-11,54.1,54.12,53.77,53.94,355067,46.1328547504,46.1499094101,45.8514528637,45.996417472,355067,0.0,1.0\nVNQI,2017-04-12,54.42,54.42,54.16,54.24,552881,46.4057293071,46.4057293071,46.1840187297,46.252237368900005,552881,0.0,1.0\nVNQI,2017-04-13,54.36,54.59,54.33,54.57,261817,46.3545653277,46.5506939154,46.328983338,46.5336392556,261817,0.0,1.0\nVNQI,2017-04-17,54.9,54.97,54.73,54.75,354111,46.8150411422,46.874732451499995,46.670076533999996,46.6871311937,354111,0.0,1.0\nVNQI,2017-04-18,54.75,54.77,54.56,54.57,230334,46.6871311937,46.7041858535,46.525111925699996,46.5336392556,230334,0.0,1.0\nVNQI,2017-04-19,54.37,54.67,54.3,54.63,327225,46.3630926576,46.6189125546,46.3034013483,46.584803235,327225,0.0,1.0\nVNQI,2017-04-20,54.56,54.7,54.54,54.57,231223,46.525111925699996,46.6444945443,46.5080572659,46.5336392556,231223,0.0,1.0\nVNQI,2017-04-21,54.52,54.53,54.32,54.39,2718009,46.4910026061,46.499529936,46.3204560081,46.380147317399995,2718009,0.0,1.0\nVNQI,2017-04-24,54.7,54.82,54.58,54.69,405595,46.6444945443,46.746822503000004,46.5421665855,46.6359672144,405595,0.0,1.0\nVNQI,2017-04-25,54.96,55.03,54.88,54.91,314791,46.8662051216,46.9258964309,46.7979864824,46.8235684721,314791,0.0,1.0\nVNQI,2017-04-26,54.81,54.94,54.71,54.79,1011644,46.738295173100006,46.8491504618,46.6530218742,46.721240513299996,1011644,0.0,1.0\nVNQI,2017-04-27,54.76,54.79,54.61,54.74,234075,46.695658523599995,46.721240513299996,46.56774857520001,46.6786038639,234075,0.0,1.0\nVNQI,2017-04-28,54.46,54.61,54.43,54.61,379344,46.4398386267,46.56774857520001,46.414256637,46.56774857520001,379344,0.0,1.0\nVNQI,2017-05-01,54.63,54.72,54.55,54.68,340989,46.584803235,46.6615492041,46.5165845958,46.6274398845,340989,0.0,1.0\nVNQI,2017-05-02,55.1,55.1,54.8478,54.89,378019,46.9855877402,46.9855877402,46.77052848020001,46.8065138123,378019,0.0,1.0\nVNQI,2017-05-03,54.83,55.01,54.77,54.94,275774,46.7553498329,46.90884177109999,46.7041858535,46.8491504618,275774,0.0,1.0\nVNQI,2017-05-04,54.62,54.69,54.51,54.69,245537,46.57627590510001,46.6359672144,46.4824752762,46.6359672144,245537,0.0,1.0\nVNQI,2017-05-05,54.99,55.0,54.57,54.58,195108,46.89178711130001,46.900314441199995,46.5336392556,46.5421665855,195108,0.0,1.0\nVNQI,2017-05-08,54.99,55.14,54.9,55.11,260020,46.89178711130001,47.019697059799995,46.8150411422,46.9941150701,260020,0.0,1.0\nVNQI,2017-05-09,55.1,55.16,55.005,55.09,240568,46.9855877402,47.0367517196,46.9045781062,46.9770604103,240568,0.0,1.0\nVNQI,2017-05-10,55.19,55.34,55.02,55.34,347240,47.062333709300006,47.190243657799996,46.917369101000006,47.190243657799996,347240,0.0,1.0\nVNQI,2017-05-11,55.19,55.19,55.01,55.06,354360,47.062333709300006,47.062333709300006,46.90884177109999,46.951478420600004,354360,0.0,1.0\nVNQI,2017-05-12,55.26,55.28,55.0,55.0,420873,47.1220250186,47.1390796784,46.900314441199995,46.900314441199995,420873,0.0,1.0\nVNQI,2017-05-15,55.51,55.5499,55.4,55.42,318888,47.335208266,47.36923231229999,47.241407637100004,47.25846229689999,318888,0.0,1.0\nVNQI,2017-05-16,55.55,55.55,55.419,55.52,292744,47.3693175856,47.3693175856,47.257609564,47.3437355959,292744,0.0,1.0\nVNQI,2017-05-17,55.39,55.55,55.345,55.5,755465,47.23288030720001,47.3693175856,47.194507322700005,47.3266809361,755465,0.0,1.0\nVNQI,2017-05-18,55.07,55.37,54.95,55.21,228509,46.9600057505,47.215825647399996,46.8576777917,47.0793883691,228509,0.0,1.0\nVNQI,2017-05-19,55.58,55.605,55.375,55.5,510562,47.394899575299995,47.4162179001,47.2200893124,47.3266809361,510562,0.0,1.0\n"
  },
  {
    "path": "tests/test_deep_analytics_convexity.py",
    "content": "\"\"\"Deep analytics, convexity, dispatch, and data provider tests.\n\nCovers:\n- BacktestStats edge cases (empty, single-row, lookback, period stats)\n- Convexity scoring internals (_find_target_put, _convexity_ratio)\n- Convexity backtest helpers (_monthly_rebalance_dates, _stock_price_on, _find_date_range)\n- Convexity allocator strategies\n- Dispatch layer\n- Data schema and filter DSL\n\"\"\"\n\nimport math\nimport os\n\nimport numpy as np\nimport pandas as pd\nimport pytest\n\nfrom options_portfolio_backtester.analytics.stats import (\n    BacktestStats,\n    PeriodStats,\n    LookbackReturns,\n)\nfrom options_portfolio_backtester.convexity.scoring import (\n    _find_target_put,\n    _convexity_ratio,\n)\nfrom options_portfolio_backtester.convexity.backtest import (\n    _monthly_rebalance_dates,\n    _stock_price_on,\n    _find_date_range,\n    _close_position,\n    run_unhedged,\n    BacktestResult,\n)\nfrom options_portfolio_backtester.convexity.config import (\n    BacktestConfig,\n    InstrumentConfig,\n    default_config,\n)\nfrom options_portfolio_backtester.convexity.allocator import (\n    pick_cheapest,\n    allocate_equal_weight,\n    allocate_inverse_vol,\n)\nfrom options_portfolio_backtester import _ob_rust\nfrom options_portfolio_backtester.data.schema import Schema, Field, Filter\n\n\n# ===========================================================================\n# BacktestStats edge cases\n# ===========================================================================\n\n\ndef _make_balance(n_days=252, start_capital=100_000, daily_return=0.0004):\n    \"\"\"Create a synthetic balance DataFrame with realistic structure.\"\"\"\n    dates = pd.bdate_range(\"2020-01-01\", periods=n_days, freq=\"B\")\n    capital = [start_capital]\n    for i in range(1, n_days):\n        capital.append(capital[-1] * (1 + daily_return + np.random.normal(0, 0.005)))\n    df = pd.DataFrame({\"total capital\": capital}, index=dates)\n    df[\"% change\"] = df[\"total capital\"].pct_change()\n    return df\n\n\nclass TestStatsEmpty:\n    def test_empty_balance(self):\n        stats = BacktestStats.from_balance(pd.DataFrame())\n        assert stats.total_return == 0.0\n        assert stats.sharpe_ratio == 0.0\n\n    def test_single_row_balance(self):\n        df = pd.DataFrame(\n            {\"total capital\": [100_000], \"% change\": [np.nan]},\n            index=[pd.Timestamp(\"2020-01-01\")],\n        )\n        stats = BacktestStats.from_balance(df)\n        assert stats.total_return == 0.0\n\n\nclass TestStatsAccuracy:\n    def test_total_return_positive_market(self):\n        np.random.seed(42)\n        balance = _make_balance(n_days=252, daily_return=0.001)\n        stats = BacktestStats.from_balance(balance)\n        # With strong positive daily return and fixed seed, total return should be positive\n        assert stats.total_return > 0\n\n    def test_total_return_negative_market(self):\n        np.random.seed(42)\n        balance = _make_balance(n_days=252, daily_return=-0.002)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.total_return < 0\n\n    def test_sharpe_positive_for_good_market(self):\n        np.random.seed(42)\n        balance = _make_balance(n_days=252, daily_return=0.001)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.sharpe_ratio > 0\n\n    def test_max_drawdown_non_negative(self):\n        np.random.seed(42)\n        balance = _make_balance(n_days=252)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.max_drawdown >= 0\n\n    def test_volatility_non_negative(self):\n        np.random.seed(42)\n        balance = _make_balance()\n        stats = BacktestStats.from_balance(balance)\n        assert stats.volatility >= 0\n\n    def test_calmar_ratio_computed(self):\n        np.random.seed(42)\n        balance = _make_balance(n_days=252, daily_return=0.001)\n        stats = BacktestStats.from_balance(balance)\n        if stats.max_drawdown > 0:\n            assert abs(stats.calmar_ratio - stats.annualized_return / stats.max_drawdown) < 0.01\n\n\nclass TestPeriodStatsDetail:\n    def test_daily_stats_populated(self):\n        np.random.seed(42)\n        balance = _make_balance(n_days=252)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.daily.vol > 0\n        assert stats.daily.best > stats.daily.worst\n\n    def test_monthly_stats_populated(self):\n        np.random.seed(42)\n        balance = _make_balance(n_days=504)  # ~2 years\n        stats = BacktestStats.from_balance(balance)\n        assert stats.monthly.vol > 0\n\n    def test_yearly_stats_with_enough_data(self):\n        np.random.seed(42)\n        balance = _make_balance(n_days=756)  # ~3 years\n        stats = BacktestStats.from_balance(balance)\n        assert stats.yearly.mean != 0.0 or stats.yearly.vol != 0.0\n\n    def test_skew_kurtosis_need_8_samples(self):\n        # Only 5 daily returns → skew/kurtosis should be 0\n        balance = _make_balance(n_days=6)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.daily.skew == 0.0\n        assert stats.daily.kurtosis == 0.0\n\n\nclass TestLookbackReturns:\n    def test_lookback_mtd(self):\n        balance = _make_balance(n_days=252)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.lookback.mtd is not None\n\n    def test_lookback_ytd(self):\n        balance = _make_balance(n_days=252)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.lookback.ytd is not None\n\n    def test_lookback_one_year(self):\n        balance = _make_balance(n_days=300)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.lookback.one_year is not None\n\n    def test_lookback_short_data_still_computes(self):\n        \"\"\"Even short data computes lookback by finding closest available date.\"\"\"\n        np.random.seed(42)\n        balance = _make_balance(n_days=30)\n        stats = BacktestStats.from_balance(balance)\n        # With 30 days of data, the 10yr lookback uses the earliest available date\n        # so it returns the total return (not None)\n        assert stats.lookback.mtd is not None\n\n\nclass TestTradeStats:\n    def test_with_pnls(self):\n        balance = _make_balance(n_days=252)\n        pnls = np.array([100, -50, 200, -30, 150, -80, 50])\n        stats = BacktestStats.from_balance(balance, trade_pnls=pnls)\n        assert stats.total_trades == 7\n        assert stats.wins == 4\n        assert stats.losses == 3\n        assert stats.win_pct > 0\n        assert stats.profit_factor > 0\n        assert stats.largest_win == 200\n        assert stats.largest_loss == -80\n        assert stats.avg_trade > 0\n\n    def test_all_winners(self):\n        balance = _make_balance(n_days=252)\n        pnls = np.array([10, 20, 30])\n        stats = BacktestStats.from_balance(balance, trade_pnls=pnls)\n        assert stats.wins == 3\n        assert stats.losses == 0\n        assert stats.profit_factor == float(\"inf\")\n\n    def test_all_losers(self):\n        balance = _make_balance(n_days=252)\n        pnls = np.array([-10, -20, -30])\n        stats = BacktestStats.from_balance(balance, trade_pnls=pnls)\n        assert stats.wins == 0\n        assert stats.losses == 3\n\n    def test_no_pnls(self):\n        balance = _make_balance(n_days=252)\n        stats = BacktestStats.from_balance(balance, trade_pnls=None)\n        assert stats.total_trades == 0\n\n\nclass TestBalanceRange:\n    def test_slice_by_date(self):\n        balance = _make_balance(n_days=252)\n        stats = BacktestStats.from_balance_range(\n            balance, start=\"2020-03-01\", end=\"2020-06-30\"\n        )\n        assert stats.total_return != 0.0 or len(balance) < 10\n\n    def test_empty_slice(self):\n        balance = _make_balance(n_days=10)\n        stats = BacktestStats.from_balance_range(balance, start=\"2025-01-01\")\n        assert stats.total_return == 0.0\n\n\nclass TestToDataframe:\n    def test_output_is_dataframe(self):\n        balance = _make_balance()\n        stats = BacktestStats.from_balance(balance)\n        df = stats.to_dataframe()\n        assert isinstance(df, pd.DataFrame)\n        assert \"Total return\" in df.index\n\n    def test_summary_string(self):\n        balance = _make_balance()\n        stats = BacktestStats.from_balance(balance)\n        s = stats.summary()\n        assert \"Total Return\" in s\n        assert \"Sharpe\" in s\n\n\nclass TestTurnoverAndHerfindahl:\n    def test_turnover_with_stocks(self):\n        dates = pd.bdate_range(\"2020-01-01\", periods=10)\n        df = pd.DataFrame({\n            \"total capital\": np.linspace(100_000, 110_000, 10),\n            \"AAPL\": np.linspace(50_000, 55_000, 10),\n            \"AAPL qty\": [100] * 10,\n            \"GOOG\": np.linspace(50_000, 55_000, 10),\n            \"GOOG qty\": [50] * 10,\n        }, index=dates)\n        df[\"% change\"] = df[\"total capital\"].pct_change()\n        stats = BacktestStats.from_balance(df)\n        assert stats.turnover >= 0\n\n    def test_turnover_no_stocks(self):\n        dates = pd.bdate_range(\"2020-01-01\", periods=5)\n        df = pd.DataFrame({\"total capital\": [100_000] * 5}, index=dates)\n        df[\"% change\"] = df[\"total capital\"].pct_change()\n        stats = BacktestStats.from_balance(df)\n        assert stats.turnover == 0.0\n\n    def test_herfindahl_single_stock(self):\n        dates = pd.bdate_range(\"2020-01-01\", periods=5)\n        df = pd.DataFrame({\n            \"total capital\": [100_000] * 5,\n            \"AAPL\": [100_000] * 5,\n            \"AAPL qty\": [100] * 5,\n        }, index=dates)\n        df[\"% change\"] = df[\"total capital\"].pct_change()\n        stats = BacktestStats.from_balance(df)\n        assert abs(stats.herfindahl - 1.0) < 0.01\n\n    def test_herfindahl_two_equal_stocks(self):\n        dates = pd.bdate_range(\"2020-01-01\", periods=5)\n        df = pd.DataFrame({\n            \"total capital\": [100_000] * 5,\n            \"AAPL\": [50_000] * 5,\n            \"AAPL qty\": [100] * 5,\n            \"GOOG\": [50_000] * 5,\n            \"GOOG qty\": [50] * 5,\n        }, index=dates)\n        df[\"% change\"] = df[\"total capital\"].pct_change()\n        stats = BacktestStats.from_balance(df)\n        assert abs(stats.herfindahl - 0.5) < 0.01\n\n\n# ===========================================================================\n# Sharpe / Sortino helpers\n# ===========================================================================\n\n\nclass TestSharpeViaStats:\n    def test_positive_returns(self):\n        dates = pd.bdate_range(\"2020-01-01\", periods=251)\n        rets = [0.01, 0.02, 0.015, 0.01, 0.005] * 50\n        capital = [100_000.0]\n        for r in rets:\n            capital.append(capital[-1] * (1 + r))\n        df = pd.DataFrame({\"total capital\": capital}, index=dates)\n        df[\"% change\"] = df[\"total capital\"].pct_change()\n        stats = BacktestStats.from_balance(df)\n        assert stats.sharpe_ratio > 0\n\n    def test_single_value_finite(self):\n        dates = pd.bdate_range(\"2020-01-01\", periods=2)\n        df = pd.DataFrame({\"total capital\": [100_000, 101_000]}, index=dates)\n        df[\"% change\"] = df[\"total capital\"].pct_change()\n        stats = BacktestStats.from_balance(df)\n        assert np.isfinite(stats.sharpe_ratio)\n\n\nclass TestSortinoViaStats:\n    def test_no_downside_returns_zero(self):\n        rets = [0.01, 0.02, 0.03] * 10\n        dates = pd.bdate_range(\"2020-01-01\", periods=len(rets) + 1)\n        capital = [100_000.0]\n        for r in rets:\n            capital.append(capital[-1] * (1 + r))\n        df = pd.DataFrame({\"total capital\": capital}, index=dates)\n        df[\"% change\"] = df[\"total capital\"].pct_change()\n        stats = BacktestStats.from_balance(df)\n        assert stats.sortino_ratio == 0.0\n\n    def test_with_downside(self):\n        rets = [0.01, -0.02, 0.015, -0.01, 0.005] * 50\n        dates = pd.bdate_range(\"2020-01-01\", periods=len(rets) + 1)\n        capital = [100_000.0]\n        for r in rets:\n            capital.append(capital[-1] * (1 + r))\n        df = pd.DataFrame({\"total capital\": capital}, index=dates)\n        df[\"% change\"] = df[\"total capital\"].pct_change()\n        stats = BacktestStats.from_balance(df)\n        assert isinstance(stats.sortino_ratio, float)\n\n\n# ===========================================================================\n# Convexity scoring internals\n# ===========================================================================\n\n\nclass TestFindTargetPut:\n    def test_exact_match(self):\n        deltas = np.array([-0.05, -0.10, -0.15, -0.20, -0.25])\n        dtes = np.array([30, 30, 30, 30, 30], dtype=np.int32)\n        asks = np.array([1.0, 1.5, 2.0, 2.5, 3.0])\n        idx = _find_target_put(deltas, dtes, asks, -0.15, 14, 60)\n        assert idx == 2\n\n    def test_closest_match(self):\n        deltas = np.array([-0.05, -0.10, -0.20, -0.25])\n        dtes = np.array([30, 30, 30, 30], dtype=np.int32)\n        asks = np.array([1.0, 1.5, 2.5, 3.0])\n        idx = _find_target_put(deltas, dtes, asks, -0.15, 14, 60)\n        # -0.10 and -0.20 are equidistant; should pick one\n        assert idx in {1, 2}\n\n    def test_dte_filter_excludes_short(self):\n        deltas = np.array([-0.15])\n        dtes = np.array([10], dtype=np.int32)\n        asks = np.array([1.0])\n        idx = _find_target_put(deltas, dtes, asks, -0.15, 14, 60)\n        assert idx is None\n\n    def test_dte_filter_excludes_long(self):\n        deltas = np.array([-0.15])\n        dtes = np.array([90], dtype=np.int32)\n        asks = np.array([1.0])\n        idx = _find_target_put(deltas, dtes, asks, -0.15, 14, 60)\n        assert idx is None\n\n    def test_zero_ask_excluded(self):\n        deltas = np.array([-0.15])\n        dtes = np.array([30], dtype=np.int32)\n        asks = np.array([0.0])\n        assert _find_target_put(deltas, dtes, asks, -0.15, 14, 60) is None\n\n    def test_nan_delta_excluded(self):\n        deltas = np.array([np.nan, -0.20])\n        dtes = np.array([30, 30], dtype=np.int32)\n        asks = np.array([1.0, 2.0])\n        idx = _find_target_put(deltas, dtes, asks, -0.15, 14, 60)\n        assert idx == 1\n\n    def test_all_excluded(self):\n        deltas = np.array([np.nan])\n        dtes = np.array([5], dtype=np.int32)\n        asks = np.array([0.0])\n        assert _find_target_put(deltas, dtes, asks, -0.15, 14, 60) is None\n\n    def test_empty_arrays(self):\n        idx = _find_target_put(\n            np.array([]), np.array([], dtype=np.int32), np.array([]),\n            -0.15, 14, 60,\n        )\n        assert idx is None\n\n\nclass TestConvexityRatio:\n    def test_basic_computation(self):\n        # strike=100, underlying=110, ask=2, tail_drop=0.20\n        # tail_price = 110 * 0.8 = 88\n        # tail_payoff = max(100 - 88, 0) * 100 = 1200\n        # annual_cost = 2 * 100 * 12 = 2400\n        # ratio = 1200 / 2400 = 0.5\n        ratio, payoff, cost = _convexity_ratio(100, 110, 2, 0.20)\n        assert abs(ratio - 0.5) < 0.001\n        assert abs(payoff - 1200.0) < 0.01\n        assert abs(cost - 2400.0) < 0.01\n\n    def test_otm_put_zero_payoff(self):\n        # strike=80, underlying=110, tail_price=88 → payoff=max(80-88,0)=0\n        ratio, payoff, cost = _convexity_ratio(80, 110, 2, 0.20)\n        assert ratio == 0.0\n        assert payoff == 0.0\n\n    def test_zero_ask(self):\n        ratio, _, cost = _convexity_ratio(100, 110, 0, 0.20)\n        assert ratio == 0.0\n        assert cost == 0.0\n\n    def test_deep_itm(self):\n        ratio, payoff, cost = _convexity_ratio(200, 110, 1, 0.20)\n        # tail_price=88, payoff=(200-88)*100=11200\n        assert payoff == 11200.0\n        assert ratio > 0\n\n\n# ===========================================================================\n# Convexity backtest helpers\n# ===========================================================================\n\n\nclass TestMonthlyRebalanceDates:\n    def test_basic(self):\n        dates = pd.DatetimeIndex([\"2020-01-02\", \"2020-01-03\", \"2020-02-03\", \"2020-02-04\"])\n        indices = _monthly_rebalance_dates(dates)\n        assert indices == [0, 2]\n\n    def test_single_month(self):\n        dates = pd.DatetimeIndex([\"2020-01-02\", \"2020-01-03\", \"2020-01-06\"])\n        indices = _monthly_rebalance_dates(dates)\n        assert indices == [0]\n\n    def test_empty(self):\n        assert _monthly_rebalance_dates(pd.DatetimeIndex([])) == []\n\n\nclass TestStockPriceOn:\n    def test_exact_match(self):\n        dates_ns = np.array([100, 200, 300], dtype=np.int64)\n        prices = np.array([10.0, 20.0, 30.0])\n        assert _stock_price_on(dates_ns, prices, 200) == 20.0\n\n    def test_between_dates(self):\n        dates_ns = np.array([100, 300], dtype=np.int64)\n        prices = np.array([10.0, 30.0])\n        # 200 is between 100 and 300, should return price at 100\n        assert _stock_price_on(dates_ns, prices, 200) == 10.0\n\n    def test_before_first_date(self):\n        dates_ns = np.array([100, 200], dtype=np.int64)\n        prices = np.array([10.0, 20.0])\n        assert _stock_price_on(dates_ns, prices, 50) is None\n\n\nclass TestFindDateRange:\n    def test_exact_match(self):\n        dates_ns = np.array([100, 100, 100, 200, 200, 300], dtype=np.int64)\n        start, end = _find_date_range(dates_ns, 100)\n        assert start == 0\n        assert end == 3\n\n    def test_no_match(self):\n        dates_ns = np.array([100, 200, 300], dtype=np.int64)\n        start, end = _find_date_range(dates_ns, 150)\n        assert start == end\n\n\n# ===========================================================================\n# Convexity config\n# ===========================================================================\n\n\nclass TestConvexityConfig:\n    def test_instrument_config_defaults(self):\n        ic = InstrumentConfig(symbol=\"SPY\", options_file=\"x\", stocks_file=\"y\")\n        assert ic.target_delta == -0.10\n        assert ic.dte_min == 14\n        assert ic.dte_max == 60\n        assert ic.tail_drop == 0.20\n\n    def test_backtest_config_defaults(self):\n        bc = BacktestConfig()\n        assert bc.initial_capital == 1_000_000.0\n        assert bc.budget_pct == 0.005\n\n    def test_default_config(self):\n        cfg = default_config()\n        assert len(cfg.instruments) == 1\n        assert cfg.instruments[0].symbol == \"SPY\"\n\n    def test_frozen(self):\n        ic = InstrumentConfig(symbol=\"SPY\", options_file=\"x\", stocks_file=\"y\")\n        with pytest.raises(AttributeError):\n            ic.symbol = \"QQQ\"\n\n\n# ===========================================================================\n# Convexity allocator\n# ===========================================================================\n\n\nclass TestAllocator:\n    def test_pick_cheapest(self):\n        scores = {\"SPY\": 1.5, \"QQQ\": 2.0, \"IWM\": 0.8}\n        assert pick_cheapest(scores) == \"QQQ\"\n\n    def test_pick_cheapest_empty_raises(self):\n        with pytest.raises(ValueError):\n            pick_cheapest({})\n\n    def test_equal_weight(self):\n        alloc = allocate_equal_weight([\"SPY\", \"QQQ\"], 10_000)\n        assert abs(alloc[\"SPY\"] - 5_000) < 0.01\n        assert abs(alloc[\"QQQ\"] - 5_000) < 0.01\n\n    def test_equal_weight_empty(self):\n        assert allocate_equal_weight([], 10_000) == {}\n\n    def test_inverse_vol(self):\n        alloc = allocate_inverse_vol({\"SPY\": 0.15, \"QQQ\": 0.30}, 10_000)\n        # SPY has lower vol → gets more budget\n        assert alloc[\"SPY\"] > alloc[\"QQQ\"]\n        assert abs(sum(alloc.values()) - 10_000) < 0.01\n\n    def test_inverse_vol_zero_vol(self):\n        \"\"\"Zero vol should fall back to equal weight.\"\"\"\n        alloc = allocate_inverse_vol({\"SPY\": 0.0, \"QQQ\": 0.0}, 10_000)\n        assert abs(alloc[\"SPY\"] - 5_000) < 0.01\n\n    def test_inverse_vol_mixed_zero(self):\n        alloc = allocate_inverse_vol({\"SPY\": 0.15, \"QQQ\": 0.0}, 10_000)\n        # QQQ has zero vol → excluded from inv-vol, only SPY gets budget\n        assert alloc[\"SPY\"] == 10_000\n\n\n# ===========================================================================\n# Dispatch layer\n# ===========================================================================\n\n\nclass TestRustExtension:\n    def test_rust_extension_importable(self):\n        assert _ob_rust is not None\n\n\n# ===========================================================================\n# Schema / Filter DSL\n# ===========================================================================\n\n\nclass TestSchemaBasic:\n    def test_stocks_schema(self):\n        s = Schema.stocks()\n        assert s[\"symbol\"] == \"symbol\"\n        assert s[\"date\"] == \"date\"\n\n    def test_options_schema(self):\n        s = Schema.options()\n        assert s[\"type\"] == \"type\"\n        assert s[\"strike\"] == \"strike\"\n\n    def test_update(self):\n        s = Schema.stocks()\n        s.update({\"custom\": \"custom_col\"})\n        assert s[\"custom\"] == \"custom_col\"\n\n    def test_contains(self):\n        s = Schema.stocks()\n        assert \"symbol\" in s\n        assert \"nonexistent\" not in s\n\n    def test_equality(self):\n        s1 = Schema.stocks()\n        s2 = Schema.stocks()\n        assert s1 == s2\n\n    def test_inequality_different_mappings(self):\n        s1 = Schema.stocks()\n        s2 = Schema.stocks()\n        s2.update({\"symbol\": \"ticker\"})\n        assert s1 != s2\n\n\nclass TestFilterDSL:\n    def test_field_comparison(self):\n        s = Schema.options()\n        f = s.strike > 100\n        assert isinstance(f, Filter)\n        assert \"strike > 100\" in f.query\n\n    def test_field_equality_string(self):\n        s = Schema.options()\n        f = s.type == \"put\"\n        assert \"'put'\" in f.query\n\n    def test_filter_and(self):\n        s = Schema.options()\n        f = (s.strike > 100) & (s.type == \"put\")\n        assert \"&\" in f.query\n\n    def test_filter_or(self):\n        s = Schema.options()\n        f = (s.strike > 100) | (s.strike < 50)\n        assert \"|\" in f.query\n\n    def test_filter_invert(self):\n        s = Schema.options()\n        f = ~(s.strike > 100)\n        assert \"!\" in f.query\n\n    def test_filter_call_on_dataframe(self):\n        s = Schema.options()\n        f = s.strike > 100\n        df = pd.DataFrame({\"strike\": [50, 100, 150, 200]})\n        result = f(df)\n        assert result.sum() == 2\n\n    def test_field_arithmetic(self):\n        s = Schema.options()\n        f = s.strike * 1.1\n        assert isinstance(f, Field)\n        assert \"1.1\" in f.mapping\n\n    def test_field_subtraction(self):\n        s = Schema.options()\n        f = s.strike - 10\n        assert \"- 10\" in f.mapping\n\n    def test_field_comparison_between_fields(self):\n        s = Schema.options()\n        f = s.strike > s.underlying_last\n        assert \"strike\" in f.query and \"underlying_last\" in f.query\n"
  },
  {
    "path": "tests/test_intrinsic_sign.py",
    "content": "\"\"\"Tests that intrinsic-value fallback produces correct sign in _current_options_capital.\n\nBUY legs are assets  → positive capital\nSELL legs are liabilities → negative capital\nOTM options (zero intrinsic) → zero capital\n\"\"\"\n\nimport numpy as np\nimport pandas as pd\nimport pytest\n\nfrom options_portfolio_backtester.core.types import Direction, OptionType\nfrom options_portfolio_backtester.data.schema import Schema\nfrom options_portfolio_backtester.engine.engine import BacktestEngine\nfrom options_portfolio_backtester.strategy.strategy import Strategy\nfrom options_portfolio_backtester.strategy.strategy_leg import StrategyLeg\n\n\ndef _make_engine_with_position(direction: Direction, option_type: OptionType,\n                               strike: float, spot: float, qty: int = 1):\n    \"\"\"Build a minimal engine with one inventory position and NaN option quotes.\n\n    Returns (engine, empty_options_df, stocks_df) ready for _current_options_capital().\n    \"\"\"\n    opt_schema = Schema.options()\n    stk_schema = Schema.stocks()\n\n    leg = StrategyLeg(\"leg_1\", opt_schema, option_type=option_type, direction=direction)\n    strategy = Strategy(opt_schema)\n    strategy.legs.append(leg)\n\n    engine = BacktestEngine(\n        allocation={\"stocks\": 0.9, \"options\": 0.1, \"cash\": 0.0},\n        initial_capital=1_000_000,\n        shares_per_contract=100,\n    )\n    engine.options_strategy = strategy\n    engine._stocks_schema = stk_schema._mappings\n    engine._options_schema = opt_schema._mappings\n\n    # Build a one-row inventory with the position\n    inventory = pd.DataFrame({\n        (\"leg_1\", \"contract\"): [\"SPY_TEST_001\"],\n        (\"leg_1\", \"type\"): [option_type.value],\n        (\"leg_1\", \"strike\"): [strike],\n        (\"leg_1\", \"underlying\"): [\"SPY\"],\n        (\"totals\", \"qty\"): [qty],\n    })\n    inventory.columns = pd.MultiIndex.from_tuples(inventory.columns)\n    engine._options_inventory = inventory\n\n    # Empty options frame → all quotes will be NaN after left-merge\n    options = pd.DataFrame(columns=[\n        \"underlying\", \"underlying_last\", \"date\", \"contract\",\n        \"type\", \"expiration\", \"strike\", \"bid\", \"ask\",\n        \"volume\", \"open_interest\",\n    ])\n\n    # Stock frame with known spot\n    stocks = pd.DataFrame({\"symbol\": [\"SPY\"], \"adjClose\": [spot]})\n\n    return engine, options, stocks\n\n\nclass TestBuyPutItmPositiveCapital:\n    \"\"\"BUY put ITM: the position is an asset → capital > 0.\"\"\"\n\n    def test_buy_put_itm_positive_capital(self):\n        engine, options, stocks = _make_engine_with_position(\n            Direction.BUY, OptionType.PUT, strike=400.0, spot=380.0,\n        )\n        capital = engine._current_options_capital(options, stocks)\n        # intrinsic = 400 - 380 = 20, scaled by 100 spc, qty=1, BUY = asset\n        assert capital > 0\n        assert capital == pytest.approx(20.0 * 100)\n\n\nclass TestSellPutItmNegativeCapital:\n    \"\"\"SELL put ITM: the position is a liability → capital < 0.\"\"\"\n\n    def test_sell_put_itm_negative_capital(self):\n        engine, options, stocks = _make_engine_with_position(\n            Direction.SELL, OptionType.PUT, strike=400.0, spot=380.0,\n        )\n        capital = engine._current_options_capital(options, stocks)\n        # intrinsic = 20 * 100, SELL = liability → negative\n        assert capital < 0\n        assert capital == pytest.approx(-20.0 * 100)\n\n\nclass TestBuyCallItmPositiveCapital:\n    \"\"\"BUY call ITM: asset → capital > 0.\"\"\"\n\n    def test_buy_call_itm_positive_capital(self):\n        engine, options, stocks = _make_engine_with_position(\n            Direction.BUY, OptionType.CALL, strike=380.0, spot=400.0,\n        )\n        capital = engine._current_options_capital(options, stocks)\n        assert capital > 0\n        assert capital == pytest.approx(20.0 * 100)\n\n\nclass TestSellCallItmNegativeCapital:\n    \"\"\"SELL call ITM: liability → capital < 0.\"\"\"\n\n    def test_sell_call_itm_negative_capital(self):\n        engine, options, stocks = _make_engine_with_position(\n            Direction.SELL, OptionType.CALL, strike=380.0, spot=400.0,\n        )\n        capital = engine._current_options_capital(options, stocks)\n        assert capital < 0\n        assert capital == pytest.approx(-20.0 * 100)\n\n\nclass TestOtmCapitalIsZero:\n    \"\"\"OTM options have zero intrinsic → capital = 0.\"\"\"\n\n    def test_otm_put(self):\n        engine, options, stocks = _make_engine_with_position(\n            Direction.SELL, OptionType.PUT, strike=380.0, spot=400.0,\n        )\n        capital = engine._current_options_capital(options, stocks)\n        assert capital == pytest.approx(0.0)\n\n    def test_otm_call(self):\n        engine, options, stocks = _make_engine_with_position(\n            Direction.BUY, OptionType.CALL, strike=400.0, spot=380.0,\n        )\n        capital = engine._current_options_capital(options, stocks)\n        assert capital == pytest.approx(0.0)\n"
  },
  {
    "path": "tests/test_intrinsic_value.py",
    "content": "\"\"\"Tests for intrinsic value fallback when options expire/go missing.\"\"\"\n\nimport numpy as np\nimport pandas as pd\nimport pytest\n\nfrom options_portfolio_backtester.engine.engine import _intrinsic_value\nfrom options_portfolio_backtester.core.types import OptionType\n\n\n# ---------------------------------------------------------------------------\n# Unit tests for _intrinsic_value helper\n# ---------------------------------------------------------------------------\n\n\nclass TestIntrinsicValue:\n    def test_put_itm(self):\n        \"\"\"Put with strike > spot → intrinsic = strike - spot.\"\"\"\n        assert _intrinsic_value(\"put\", 400.0, 380.0) == pytest.approx(20.0)\n\n    def test_put_otm(self):\n        \"\"\"Put with strike < spot → intrinsic = 0.\"\"\"\n        assert _intrinsic_value(\"put\", 400.0, 420.0) == pytest.approx(0.0)\n\n    def test_call_itm(self):\n        \"\"\"Call with spot > strike → intrinsic = spot - strike.\"\"\"\n        assert _intrinsic_value(\"call\", 400.0, 420.0) == pytest.approx(20.0)\n\n    def test_call_otm(self):\n        \"\"\"Call with spot < strike → intrinsic = 0.\"\"\"\n        assert _intrinsic_value(\"call\", 400.0, 380.0) == pytest.approx(0.0)\n\n    def test_put_atm(self):\n        \"\"\"ATM put → intrinsic = 0.\"\"\"\n        assert _intrinsic_value(\"put\", 400.0, 400.0) == pytest.approx(0.0)\n\n    def test_call_atm(self):\n        \"\"\"ATM call → intrinsic = 0.\"\"\"\n        assert _intrinsic_value(\"call\", 400.0, 400.0) == pytest.approx(0.0)\n\n    def test_deep_itm_put(self):\n        \"\"\"Deep ITM put — large intrinsic.\"\"\"\n        assert _intrinsic_value(\"put\", 500.0, 300.0) == pytest.approx(200.0)\n\n    def test_uses_option_type_enum_values(self):\n        \"\"\"Works with OptionType enum .value strings.\"\"\"\n        assert _intrinsic_value(OptionType.PUT.value, 400.0, 380.0) == pytest.approx(20.0)\n        assert _intrinsic_value(OptionType.CALL.value, 400.0, 420.0) == pytest.approx(20.0)\n"
  },
  {
    "path": "tests/test_property_based.py",
    "content": "\"\"\"Property-based and fuzz tests for core components.\n\nUses hypothesis to generate random inputs and verify invariants hold.\n\"\"\"\n\nimport math\n\nimport numpy as np\nimport pandas as pd\nimport pytest\nfrom hypothesis import given, settings, assume, HealthCheck\nfrom hypothesis import strategies as st\n\nfrom options_portfolio_backtester.analytics.stats import BacktestStats, PeriodStats\nfrom options_portfolio_backtester.engine.pipeline import (\n    AlgoPipelineBacktester,\n    PipelineContext,\n    Rebalance,\n    RunDaily,\n    RunMonthly,\n    RunWeekly,\n    SelectAll,\n    SelectThese,\n    SelectRegex,\n    WeighEqually,\n    WeighSpecified,\n    LimitWeights,\n    ScaleWeights,\n    StepDecision,\n)\nfrom options_portfolio_backtester.execution.cost_model import (\n    NoCosts, PerContractCommission, TieredCommission,\n)\nfrom options_portfolio_backtester.execution.fill_model import (\n    MarketAtBidAsk, MidPrice, VolumeAwareFill,\n)\nfrom options_portfolio_backtester.execution.signal_selector import (\n    FirstMatch, NearestDelta, MaxOpenInterest,\n)\n\n\n# ---------------------------------------------------------------------------\n# Strategies (hypothesis)\n# ---------------------------------------------------------------------------\n\ndaily_return = st.floats(min_value=-0.20, max_value=0.20, allow_nan=False, allow_infinity=False)\npositive_float = st.floats(min_value=0.01, max_value=1e8, allow_nan=False, allow_infinity=False)\nprice = st.floats(min_value=0.01, max_value=10000.0, allow_nan=False, allow_infinity=False)\nquantity = st.floats(min_value=0.0, max_value=100000.0, allow_nan=False, allow_infinity=False)\nrate = st.floats(min_value=0.0, max_value=10.0, allow_nan=False, allow_infinity=False)\nweight = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)\n\n\ndef _make_balance(returns: list[float], initial: float = 100_000.0) -> pd.DataFrame:\n    dates = pd.date_range(\"2020-01-01\", periods=len(returns) + 1, freq=\"B\")\n    capital = [initial]\n    for r in returns:\n        capital.append(capital[-1] * (1 + r))\n    df = pd.DataFrame({\"total capital\": capital}, index=dates)\n    df[\"% change\"] = df[\"total capital\"].pct_change()\n    return df\n\n\n# ---------------------------------------------------------------------------\n# BacktestStats invariants\n# ---------------------------------------------------------------------------\n\nclass TestStatsInvariants:\n    @given(st.lists(daily_return, min_size=10, max_size=500))\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_max_drawdown_non_negative(self, returns):\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.max_drawdown >= 0\n\n    @given(st.lists(daily_return, min_size=10, max_size=500))\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_max_drawdown_at_most_one(self, returns):\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.max_drawdown <= 1.0 + 1e-10\n\n    @given(st.lists(daily_return, min_size=10, max_size=500))\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_avg_drawdown_leq_max(self, returns):\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.avg_drawdown <= stats.max_drawdown + 1e-10\n\n    @given(st.lists(daily_return, min_size=10, max_size=500))\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_volatility_non_negative(self, returns):\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance)\n        assert stats.volatility >= 0\n\n    @given(st.lists(daily_return, min_size=10, max_size=500))\n    @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])\n    def test_total_return_consistent(self, returns):\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance)\n        # total_return = (final / initial) - 1\n        expected = balance[\"total capital\"].iloc[-1] / balance[\"total capital\"].iloc[0] - 1\n        assert abs(stats.total_return - expected) < 1e-10\n\n    @given(\n        st.lists(daily_return, min_size=10, max_size=100),\n        st.lists(st.floats(min_value=-1000, max_value=1000, allow_nan=False, allow_infinity=False), min_size=1, max_size=50),\n    )\n    @settings(max_examples=30, suppress_health_check=[HealthCheck.too_slow])\n    def test_trade_stats_consistent(self, returns, pnls):\n        balance = _make_balance(returns)\n        trade_pnls = np.array(pnls)\n        stats = BacktestStats.from_balance(balance, trade_pnls)\n        assert stats.wins + stats.losses == stats.total_trades\n        if stats.total_trades > 0:\n            assert 0 <= stats.win_pct <= 100\n        assert stats.profit_factor >= 0\n\n    @given(st.lists(daily_return, min_size=10, max_size=500))\n    @settings(max_examples=30, suppress_health_check=[HealthCheck.too_slow])\n    def test_dataframe_has_all_rows(self, returns):\n        balance = _make_balance(returns)\n        stats = BacktestStats.from_balance(balance)\n        df = stats.to_dataframe()\n        assert df.shape[0] >= 30\n        assert df.shape[1] == 1\n\n\n# ---------------------------------------------------------------------------\n# Cost model invariants\n# ---------------------------------------------------------------------------\n\nclass TestCostModelInvariants:\n    @given(price, quantity)\n    @settings(max_examples=100)\n    def test_no_costs_always_zero(self, p, q):\n        assert NoCosts().option_cost(p, q, 100) == 0.0\n        assert NoCosts().stock_cost(p, q) == 0.0\n\n    @given(price, quantity, rate)\n    @settings(max_examples=100)\n    def test_per_contract_non_negative(self, p, q, r):\n        model = PerContractCommission(rate=r)\n        assert model.option_cost(p, q, 100) >= 0\n        assert model.stock_cost(p, q) >= 0\n\n    @given(price, quantity, rate)\n    @settings(max_examples=100)\n    def test_per_contract_proportional(self, p, q, r):\n        model = PerContractCommission(rate=r)\n        cost = model.option_cost(p, q, 100)\n        expected = r * abs(q)\n        assert abs(cost - expected) < 1e-8\n\n    @given(price, quantity)\n    @settings(max_examples=100)\n    def test_per_contract_symmetric(self, p, q):\n        \"\"\"Commission should be same for buy (+q) and sell (-q).\"\"\"\n        model = PerContractCommission(rate=0.65)\n        assert model.option_cost(p, q, 100) == model.option_cost(p, -q, 100)\n\n\n# ---------------------------------------------------------------------------\n# Fill model invariants\n# ---------------------------------------------------------------------------\n\nclass TestFillModelInvariants:\n    @given(\n        st.floats(min_value=0.01, max_value=100, allow_nan=False),\n        st.floats(min_value=0.01, max_value=100, allow_nan=False),\n    )\n    @settings(max_examples=100)\n    def test_mid_price_between_bid_ask(self, bid, ask):\n        assume(bid <= ask)\n        from options_portfolio_backtester.core.types import Direction\n        row = pd.Series({\"bid\": bid, \"ask\": ask, \"volume\": 100})\n        model = MidPrice()\n        mid = model.get_fill_price(row, Direction.BUY)\n        assert bid - 1e-10 <= mid <= ask + 1e-10\n\n    @given(\n        st.floats(min_value=0.01, max_value=100, allow_nan=False),\n        st.floats(min_value=0.01, max_value=100, allow_nan=False),\n    )\n    @settings(max_examples=100)\n    def test_market_bid_ask_buy_at_ask(self, bid, ask):\n        assume(bid <= ask)\n        from options_portfolio_backtester.core.types import Direction\n        row = pd.Series({\"bid\": bid, \"ask\": ask, \"volume\": 100})\n        model = MarketAtBidAsk()\n        assert model.get_fill_price(row, Direction.BUY) == ask\n        assert model.get_fill_price(row, Direction.SELL) == bid\n\n\n# ---------------------------------------------------------------------------\n# Pipeline algo invariants\n# ---------------------------------------------------------------------------\n\nclass TestWeightInvariants:\n    @given(st.integers(min_value=2, max_value=20))\n    @settings(max_examples=30)\n    def test_weigh_equally_sums_to_one(self, n):\n        symbols = [f\"S{i}\" for i in range(n)]\n        prices = pd.Series({s: 100.0 for s in symbols})\n        ctx = PipelineContext(\n            date=pd.Timestamp(\"2024-01-01\"),\n            prices=prices,\n            total_capital=1_000_000.0,\n            cash=1_000_000.0,\n            positions={},\n        )\n        ctx.selected_symbols = symbols\n        WeighEqually()(ctx)\n        total = sum(ctx.target_weights.values())\n        assert abs(total - 1.0) < 1e-10\n\n    def test_limit_weights_caps_with_many_assets(self):\n        \"\"\"With enough assets, LimitWeights caps each at the limit.\"\"\"\n        ctx = PipelineContext(\n            date=pd.Timestamp(\"2024-01-01\"),\n            prices=pd.Series({f\"S{i}\": 100 for i in range(10)}),\n            total_capital=1_000_000.0,\n            cash=1_000_000.0,\n            positions={},\n        )\n        # One huge weight, rest small\n        ctx.target_weights = {\"S0\": 0.91}\n        for i in range(1, 10):\n            ctx.target_weights[f\"S{i}\"] = 0.01\n        LimitWeights(0.20)(ctx)\n        # After clip+renormalize, S0 should be capped at 0.20\n        assert ctx.target_weights[\"S0\"] <= 0.20 + 1e-10\n\n    @given(st.floats(min_value=0.01, max_value=5.0, allow_nan=False))\n    @settings(max_examples=30)\n    def test_scale_weights_multiplies(self, scale):\n        ctx = PipelineContext(\n            date=pd.Timestamp(\"2024-01-01\"),\n            prices=pd.Series({\"A\": 100, \"B\": 100}),\n            total_capital=1_000_000.0,\n            cash=1_000_000.0,\n            positions={},\n        )\n        ctx.target_weights = {\"A\": 0.3, \"B\": 0.2}\n        ScaleWeights(scale)(ctx)\n        assert abs(ctx.target_weights[\"A\"] - 0.3 * scale) < 1e-10\n        assert abs(ctx.target_weights[\"B\"] - 0.2 * scale) < 1e-10\n\n\n# ---------------------------------------------------------------------------\n# Pipeline end-to-end fuzz: random prices, verify no crashes + capital > 0\n# ---------------------------------------------------------------------------\n\nclass TestPipelineFuzz:\n    @given(\n        st.lists(\n            st.floats(min_value=10.0, max_value=500.0, allow_nan=False),\n            min_size=5,\n            max_size=50,\n        ),\n        st.floats(min_value=1000.0, max_value=1e7, allow_nan=False, allow_infinity=False),\n    )\n    @settings(max_examples=30, suppress_health_check=[HealthCheck.too_slow])\n    def test_pipeline_no_crash_on_random_prices(self, spy_prices, capital):\n        dates = pd.date_range(\"2024-01-01\", periods=len(spy_prices), freq=\"B\")\n        prices = pd.DataFrame({\"SPY\": spy_prices}, index=dates)\n        bt = AlgoPipelineBacktester(\n            prices=prices,\n            initial_capital=capital,\n            algos=[RunDaily(), SelectAll(), WeighEqually(), Rebalance()],\n        )\n        bal = bt.run()\n        assert len(bal) > 0\n        assert bal[\"total capital\"].iloc[-1] > 0\n\n    @given(\n        st.lists(\n            st.tuples(\n                st.floats(min_value=10, max_value=500, allow_nan=False),\n                st.floats(min_value=10, max_value=500, allow_nan=False),\n            ),\n            min_size=5,\n            max_size=30,\n        ),\n    )\n    @settings(max_examples=20, suppress_health_check=[HealthCheck.too_slow])\n    def test_pipeline_multi_asset_no_crash(self, price_pairs):\n        spy_prices = [p[0] for p in price_pairs]\n        qqq_prices = [p[1] for p in price_pairs]\n        dates = pd.date_range(\"2024-01-01\", periods=len(price_pairs), freq=\"B\")\n        prices = pd.DataFrame({\"SPY\": spy_prices, \"QQQ\": qqq_prices}, index=dates)\n        bt = AlgoPipelineBacktester(\n            prices=prices,\n            initial_capital=100_000.0,\n            algos=[RunDaily(), SelectAll(), WeighEqually(), Rebalance()],\n        )\n        bal = bt.run()\n        assert len(bal) > 0\n        assert bal[\"total capital\"].iloc[-1] > 0\n\n    @given(\n        st.lists(\n            st.floats(min_value=10.0, max_value=500.0, allow_nan=False),\n            min_size=10,\n            max_size=50,\n        ),\n    )\n    @settings(max_examples=20, suppress_health_check=[HealthCheck.too_slow])\n    def test_stats_from_pipeline_no_crash(self, spy_prices):\n        dates = pd.date_range(\"2024-01-01\", periods=len(spy_prices), freq=\"B\")\n        prices = pd.DataFrame({\"SPY\": spy_prices}, index=dates)\n        bt = AlgoPipelineBacktester(\n            prices=prices,\n            initial_capital=100_000.0,\n            algos=[RunDaily(), SelectAll(), WeighEqually(), Rebalance()],\n        )\n        bal = bt.run()\n        stats = BacktestStats.from_balance(bal)\n        assert stats.max_drawdown >= 0\n        assert stats.volatility >= 0\n        df = stats.to_dataframe()\n        assert df.shape[0] >= 30\n\n\n# ---------------------------------------------------------------------------\n# to_rust_config round-trip invariants\n# ---------------------------------------------------------------------------\n\nclass TestRustConfigRoundTrip:\n    def test_cost_models_have_rust_config(self):\n        for model in [NoCosts(), PerContractCommission(0.65), TieredCommission([(10000, 0.65)])]:\n            cfg = model.to_rust_config()\n            assert isinstance(cfg, dict)\n            assert \"type\" in cfg\n\n    def test_fill_models_have_rust_config(self):\n        for model in [MarketAtBidAsk(), MidPrice(), VolumeAwareFill(full_volume_threshold=100)]:\n            cfg = model.to_rust_config()\n            assert isinstance(cfg, dict)\n            assert \"type\" in cfg\n\n    def test_signal_selectors_have_rust_config(self):\n        for model in [FirstMatch(), NearestDelta(-0.30), MaxOpenInterest()]:\n            cfg = model.to_rust_config()\n            assert isinstance(cfg, dict)\n            assert \"type\" in cfg\n"
  },
  {
    "path": "tests/test_smoke.py",
    "content": "\"\"\"Smoke tests — verify all public imports work.\"\"\"\n\n\ndef test_top_level_imports():\n    \"\"\"All public symbols importable from options_portfolio_backtester.\"\"\"\n    from options_portfolio_backtester import (\n        # Core\n        Direction, OptionType, Type, Order, Signal, Fill, Greeks,\n        OptionContract, StockAllocation, Stock, get_order,\n        # Data\n        Schema, Field, Filter, CsvOptionsProvider, CsvStocksProvider,\n        TiingoData, HistoricalOptionsData,\n        # Strategy\n        Strategy, StrategyLeg, Strangle,\n        # Execution\n        NoCosts, PerContractCommission, TieredCommission, SpreadSlippage,\n        MarketAtBidAsk, MidPrice, VolumeAwareFill,\n        CapitalBased, FixedQuantity, FixedDollar, PercentOfPortfolio,\n        FirstMatch, NearestDelta, MaxOpenInterest,\n        # Portfolio\n        Portfolio, OptionPosition, aggregate_greeks,\n        RiskManager, MaxDelta, MaxVega, MaxDrawdown,\n        # Engine\n        BacktestEngine, TradingClock,\n        # Analytics\n        BacktestStats, PeriodStats, LookbackReturns,\n        TradeLog, TearsheetReport, build_tearsheet,\n        summary,\n    )\n    # Quick sanity: verify some aren't None\n    assert Direction is not None\n    assert BacktestEngine is not None\n    assert BacktestStats is not None\n\n\ndef test_strategy_presets_import():\n    \"\"\"Strategy presets importable.\"\"\"\n    from options_portfolio_backtester.strategy.presets import (\n        strangle, iron_condor, covered_call, cash_secured_put, collar, butterfly,\n    )\n    assert callable(strangle)\n    assert callable(iron_condor)\n    assert callable(covered_call)\n    assert callable(cash_secured_put)\n    assert callable(collar)\n    assert callable(butterfly)\n"
  }
]