Repository: lambdaclass/options_backtester Branch: master Commit: 09514782c168 Files: 181 Total size: 1.5 MB Directory structure: gitextract_dxl517zn/ ├── .github/ │ └── workflows/ │ └── ci.yml ├── .gitignore ├── .python-version ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── benchmarks/ │ ├── benchmark_large_pipeline.py │ ├── benchmark_matrix.py │ ├── benchmark_rust_vs_python.py │ ├── benchmark_sweep.py │ └── compare_with_bt.py ├── data/ │ ├── README.md │ ├── convert_optionsdx.py │ ├── fetch_data.py │ └── fetch_signals.py ├── flake.nix ├── options_portfolio_backtester/ │ ├── __init__.py │ ├── analytics/ │ │ ├── __init__.py │ │ ├── charts.py │ │ ├── optimization.py │ │ ├── stats.py │ │ ├── summary.py │ │ ├── tearsheet.py │ │ └── trade_log.py │ ├── convexity/ │ │ ├── __init__.py │ │ ├── _utils.py │ │ ├── allocator.py │ │ ├── backtest.py │ │ ├── config.py │ │ ├── scoring.py │ │ └── viz.py │ ├── core/ │ │ ├── __init__.py │ │ └── types.py │ ├── data/ │ │ ├── __init__.py │ │ ├── providers.py │ │ └── schema.py │ ├── engine/ │ │ ├── __init__.py │ │ ├── algo_adapters.py │ │ ├── clock.py │ │ ├── engine.py │ │ ├── multi_strategy.py │ │ ├── pipeline.py │ │ └── strategy_tree.py │ ├── execution/ │ │ ├── __init__.py │ │ ├── _rust_bridge.py │ │ ├── cost_model.py │ │ ├── fill_model.py │ │ ├── signal_selector.py │ │ └── sizer.py │ ├── portfolio/ │ │ ├── __init__.py │ │ ├── greeks.py │ │ ├── portfolio.py │ │ ├── position.py │ │ └── risk.py │ └── strategy/ │ ├── __init__.py │ ├── presets.py │ ├── strategy.py │ └── strategy_leg.py ├── pyproject.toml ├── rust/ │ ├── .cargo/ │ │ └── config.toml │ ├── Cargo.toml │ ├── ob_core/ │ │ ├── Cargo.toml │ │ ├── benches/ │ │ │ └── hot_paths.rs │ │ └── src/ │ │ ├── backtest.rs │ │ ├── balance.rs │ │ ├── convexity_backtest.rs │ │ ├── convexity_scoring.rs │ │ ├── cost_model.rs │ │ ├── entries.rs │ │ ├── exits.rs │ │ ├── fill_model.rs │ │ ├── filter.rs │ │ ├── inventory.rs │ │ ├── lib.rs │ │ ├── risk.rs │ │ ├── signal_selector.rs │ │ ├── stats.rs │ │ └── types.rs │ └── ob_python/ │ ├── Cargo.toml │ └── src/ │ ├── arrow_bridge.rs │ ├── lib.rs │ ├── py_backtest.rs │ ├── py_balance.rs │ ├── py_convexity.rs │ ├── py_entries.rs │ ├── py_execution.rs │ ├── py_exits.rs │ ├── py_filter.rs │ ├── py_stats.rs │ └── py_sweep.rs ├── setup.cfg └── tests/ ├── __init__.py ├── analytics/ │ ├── __init__.py │ ├── test_analytics_pbt.py │ ├── test_charts.py │ ├── test_optimization.py │ ├── test_stats.py │ ├── test_stats_python_path.py │ ├── test_summary.py │ ├── test_tearsheet.py │ └── test_trade_log.py ├── bench/ │ ├── __init__.py │ ├── _test_helpers.py │ ├── extract_prod_slices.py │ ├── generate_test_data.py │ ├── test_edge_cases.py │ ├── test_execution_models.py │ ├── test_invariants.py │ ├── test_multi_leg.py │ ├── test_partial_exits.py │ └── test_sweep.py ├── compat/ │ ├── __init__.py │ └── test_bt_overlap_gate.py ├── conftest.py ├── convexity/ │ ├── __init__.py │ ├── conftest.py │ ├── test_allocator.py │ ├── test_backtest.py │ └── test_config.py ├── core/ │ ├── __init__.py │ ├── test_types.py │ └── test_types_pbt.py ├── data/ │ ├── __init__.py │ ├── test_filter.py │ ├── test_property_based.py │ ├── test_providers.py │ ├── test_providers_extended.py │ └── test_schema.py ├── engine/ │ ├── __init__.py │ ├── test_algo_adapters.py │ ├── test_capital_conservation.py │ ├── test_chaos.py │ ├── test_clock.py │ ├── test_engine.py │ ├── test_engine_deep.py │ ├── test_engine_unit.py │ ├── test_full_liquidation.py │ ├── test_max_notional.py │ ├── test_multi_strategy.py │ ├── test_multi_strategy_engine.py │ ├── test_per_leg_overrides.py │ ├── test_pipeline.py │ ├── test_portfolio_integration.py │ ├── test_regression_snapshots.py │ ├── test_risk_wiring.py │ ├── test_rust_parity.py │ ├── test_signal_selector_wiring.py │ └── test_strategy_tree.py ├── execution/ │ ├── __init__.py │ ├── test_cost_model.py │ ├── test_execution_deep.py │ ├── test_execution_pbt.py │ ├── test_fill_model.py │ ├── test_rust_parity_execution.py │ ├── test_signal_selector.py │ └── test_sizer.py ├── portfolio/ │ ├── __init__.py │ ├── test_greeks_aggregation.py │ ├── test_portfolio.py │ ├── test_position.py │ ├── test_property_based.py │ └── test_risk.py ├── strategy/ │ ├── __init__.py │ ├── test_presets.py │ ├── test_strangle.py │ ├── test_strategy.py │ ├── test_strategy_deep.py │ ├── test_strategy_leg.py │ └── test_strategy_pbt.py ├── test_cleanup.py ├── test_data/ │ ├── ivy_5assets_data.csv │ ├── ivy_portfolio.csv │ ├── options_data.csv │ ├── test_data_options.csv │ └── test_data_stocks.csv ├── test_deep_analytics_convexity.py ├── test_intrinsic_sign.py ├── test_intrinsic_value.py ├── test_property_based.py └── test_smoke.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/ci.yml ================================================ name: Test on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: cachix/install-nix-action@v30 - name: Download data run: nix develop --command python data/fetch_data.py all --symbols SPY --force - name: Run tests run: nix develop --command python -m pytest -v tests/ --ignore=tests/bench --ignore=tests/compat - name: Type check run: nix develop --command python -m mypy options_portfolio_backtester --ignore-missing-imports ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv .venv-bt/ env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ # Generated charts/plots *.png # Mac OS-specific storage files .DS_Store # VS Code .vscode/ _ob_rust.so mutants/ # Large regenerable test data (from tests/bench/extract_prod_slices.py) tests/data/*.csv # exclude data from source control by default data/* !data/README.md !data/fetch_data.py !data/fetch_signals.py !data/convert_optionsdx.py !data/raw/ data/raw/* !data/raw/.gitkeep !data/processed/ data/processed/* !data/processed/.gitkeep !data/processed/signals.csv ================================================ FILE: .python-version ================================================ 3.12 ================================================ FILE: CONTRIBUTING.md ================================================ Contributing ============ Contributions are welcome and very much appreciated. Credit will be appropriately given. ## License By contributing, you agree that your contributions will be licensed under its MIT License. ## Code contributions To begin contributing to the project, please follow these steps. 1. [Fork](https://help.github.com/en/articles/fork-a-repo) the repo. 2. Clone your fork locally: ```shell $ git clone git@github.com:your_username/backtester_options.git ``` 3. Create the environment and install dependencies: ```shell $ make init ``` 4. Create your development branch from `master` ```shell $ git checkout -b your_branch master ``` 5. Start coding your contribution (Thanks!) 6. Make sure your code passes all tests, lints and is formatted correctly (`TODO`: Add linting, code formatting to Travis.) 6. Submit a pull request with a brief explanation of your work. ## Types of Contributions ### Bug reports Make 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: - Operating System and version. - Steps taken to replicate the bug. - What was the expected output and what actually happend. - Any details of your local environment that might be helpful for troubleshooting. ### Bug fixes If 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. ### Proposing Features Create 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. ### Implementing Features Find an issue with the label `help wanted` or `improvement` and [start coding](#code-contributions). When 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. ### Documentation We 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. Let us know via issues labeled `docs` and we'll credit you appropriately. ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2019 Federico Carrone Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ NIX_CMD := XDG_CACHE_HOME=$(CURDIR)/.cache nix --extra-experimental-features 'nix-command flakes' develop --command RUNCMD := $(NIX_CMD) PYTHON := python .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 .DEFAULT_GOAL := help test: ## Run all tests $(RUNCMD) $(PYTHON) -m pytest -v tests test-bench: ## Run benchmark/property tests $(RUNCMD) $(PYTHON) -m pytest -v -m bench tests/bench lint: ## Run ruff linter $(RUNCMD) $(PYTHON) -m ruff check options_portfolio_backtester typecheck: ## Run mypy type checker $(RUNCMD) $(PYTHON) -m mypy options_portfolio_backtester --ignore-missing-imports notebooks: ## Execute all notebooks @for nb in notebooks/*.ipynb; do \ echo "Running $$nb..."; \ $(RUNCMD) $(PYTHON) -m jupyter nbconvert --to notebook --execute "$$nb" \ --output "$$(basename $$nb)" --ExecutePreprocessor.timeout=600 || true; \ done rust-build: ## Build Rust extension with maturin (release) $(RUNCMD) bash -c 'cd rust && maturin develop --manifest-path ob_python/Cargo.toml --release' rust-test: ## Run Rust unit tests $(RUNCMD) bash -c 'cd rust && cargo test' rust-bench: ## Run Rust benchmarks (criterion) $(RUNCMD) bash -c 'cd rust && cargo bench' bench: rust-build ## Run Python benchmarks (requires Rust build) $(RUNCMD) $(PYTHON) -m pytest tests/bench/ -v -m bench --benchmark-only 2>/dev/null || \ echo "Install pytest-benchmark for Python benchmarks" install-dev: ## Install local dev deps into active nix dev environment $(PYTHON) -m pip install -e '.[dev,charts,notebooks,rust]' compare-bt: ## Compare stock-only monthly rebalance vs bt library $(RUNCMD) $(PYTHON) scripts/compare_with_bt.py benchmark-matrix: ## Run standardized runtime/accuracy matrix vs bt $(RUNCMD) $(PYTHON) scripts/benchmark_matrix.py walk-forward-report: ## Run walk-forward/OOS harness and save report $(RUNCMD) $(PYTHON) scripts/walk_forward_report.py bench-rust-vs-python: ## Benchmark Rust vs Python vs bt (options + stock-only) $(RUNCMD) $(PYTHON) scripts/benchmark_rust_vs_python.py --stock-only parity-gate: ## Run bt overlap parity CI gate (bench marker) $(RUNCMD) $(PYTHON) -m pytest -v tests/compat/test_bt_overlap_gate.py -m bench help: @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) |\ awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' ================================================ FILE: README.md ================================================ Options Portfolio Backtester ============================ Backtest 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. ## Get started ### Install With Nix: ```shell nix develop ``` Without Nix (Python >= 3.12): ```shell python -m venv .venv && source .venv/bin/activate make install-dev ``` ### Get data ```shell python data/fetch_data.py all --symbols SPY ``` Downloads SPY stock prices and options chains to `data/processed/`. Supports 104+ symbols. See [`data/README.md`](data/README.md) for details. ### Run your first backtest ```python from options_portfolio_backtester import ( BacktestEngine, Stock, Type, Direction, HistoricalOptionsData, TiingoData, Strategy, StrategyLeg, NearestDelta, PerContractCommission, RiskManager, MaxDelta, MaxDrawdown, ) # Load data options_data = HistoricalOptionsData("data/processed/options.csv") stocks_data = TiingoData("data/processed/stocks.csv") schema = options_data.schema # Define strategy: buy OTM puts on SPY, exit when DTE drops below 30 strategy = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) leg.entry_filter = ( (schema.underlying == "SPY") & (schema.dte >= 60) & (schema.dte <= 120) & (schema.delta >= -0.25) & (schema.delta <= -0.10) ) leg.exit_filter = schema.dte <= 30 strategy.add_leg(leg) # Run backtest: 97% stocks, 3% options engine = BacktestEngine( allocation={"stocks": 0.97, "options": 0.03, "cash": 0.0}, initial_capital=1_000_000, cost_model=PerContractCommission(rate=0.65), signal_selector=NearestDelta(target_delta=-0.20), risk_manager=RiskManager([MaxDelta(100.0), MaxDrawdown(0.20)]), ) engine.stocks = [Stock("SPY", 1.0)] engine.stocks_data = stocks_data engine.options_data = options_data engine.options_strategy = strategy engine.run(rebalance_freq=1) # Results print(engine.balance["total capital"].iloc[-1]) # final capital print(len(engine.trade_log)) # number of trades ``` ### Strategy presets Instead of building legs manually: ```python from options_portfolio_backtester import Strangle strangle = Strangle(schema, "short", "SPY", dte_entry_range=(30, 60), dte_exit=7, otm_pct=5, pct_tolerance=1, exit_thresholds=(0.2, 0.2)) ``` Available presets: `Strangle`, `IronCondor`, `CoveredCall`, `CashSecuredPut`, `Collar`, `Butterfly`. ### Stock-only backtest with algo pipeline For equity portfolios without options, use the pipeline API: ```python from options_portfolio_backtester.engine.pipeline import ( AlgoPipelineBacktester, RunMonthly, SelectAll, WeighInvVol, LimitWeights, Rebalance, ) import pandas as pd prices = pd.read_csv("data/processed/stocks.csv", parse_dates=["date"]) prices = prices.pivot(index="date", columns="symbol", values="adjClose") bt = AlgoPipelineBacktester( prices=prices, initial_capital=1_000_000, algos=[ RunMonthly(), SelectAll(), WeighInvVol(lookback=252), LimitWeights(limit=0.25), Rebalance(), ], ) bt.run() ``` ## Execution models Every component is swappable. Pass them to `BacktestEngine(...)` or override per-leg. **Signal selectors** — which contract to pick from candidates: `FirstMatch()`, `NearestDelta(target)`, `MaxOpenInterest()` **Cost models** — commissions and fees: `NoCosts()`, `PerContractCommission(rate)`, `TieredCommission(tiers)`, `SpreadSlippage(pct)` **Fill models** — execution price: `MarketAtBidAsk()`, `MidPrice()`, `VolumeAwareFill(threshold)` **Position sizers** — how many contracts: `CapitalBased()`, `FixedQuantity(qty)`, `FixedDollar(amount)`, `PercentOfPortfolio(pct)` **Risk constraints** — pre-trade gating: `MaxDelta(limit)`, `MaxVega(limit)`, `MaxDrawdown(max_dd_pct)` ## Rebalancing model At each rebalance date, the engine follows a **full liquidation** approach: 1. **Liquidate all options** — every open option position is sold at current market price (bid for long, ask for short) 2. **Compute total capital** — cash + stock value (options are zero after liquidation) 3. **Rebalance stocks** — sell all stocks, buy fresh at target allocation (e.g. 97%) 4. **Buy new options** — use the full options allocation (e.g. 3%) to purchase contracts matching entry criteria (DTE, delta, etc.) This ensures: - **Clean accounting** — no stale option value carried across rebalances, no money creation - **Fresh positions** — every rebalance picks the best available contracts for current market conditions - **Simple math** — `total_capital = cash + stocks` at the point of redeployment, no complex delta tracking Between 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. For 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. ## Rust acceleration Optional. Falls back to Python when not installed. ```shell make rust-build ``` | Benchmark | Python | Rust | |-----------|--------|------| | Full options backtest (24.7M rows) | 10.0s | **4.2s** | | Stock-only monthly rebalance | 3.7s | **0.6s** | | Parallel grid sweep (100 configs) | — | **5-8x** faster (Rayon, bypasses GIL) | ## Data ```shell # SPY stock + options data python data/fetch_data.py all --symbols SPY # Multiple symbols python data/fetch_data.py all --symbols SPY IWM QQQ --start 2020-01-01 --end 2023-01-01 # FRED macro signals (VIX, GDP, Buffett Indicator, etc.) python data/fetch_signals.py # Convert OptionsDX format python data/convert_optionsdx.py data/raw/spx_eod_2020.csv --output data/processed/spx_options.csv ``` You can also bring your own CSVs. Required columns: - **Stocks**: `date`, `symbol`, `adjClose` - **Options**: `quotedate`, `underlying`, `type`, `strike`, `expiration`, `dte`, `bid`, `ask`, `volume`, `openinterest`, `delta` ## Tests ```shell make test # all tests (1300+) make test-regression # regression snapshots (locked golden values) make test-chaos # fault injection (corrupted/adversarial data) make muttest # mutation testing on core modules make lint # ruff make typecheck # mypy make rust-test # Rust unit tests ``` ## Architecture ``` options_portfolio_backtester/ ├── core/ # Types: Direction, OptionType, Greeks, Fill, Order ├── data/ # Schema DSL, CSV providers ├── strategy/ # Strategy, StrategyLeg, presets ├── execution/ # CostModel, FillModel, Sizer, SignalSelector ├── portfolio/ # Portfolio, OptionPosition, RiskManager ├── engine/ # BacktestEngine, AlgoPipelineBacktester, StrategyTreeEngine └── analytics/ # BacktestStats, TradeLog, TearsheetReport, charts rust/ ├── ob_core/ # Backtest loop, stats, execution models, filter parser └── ob_python/ # PyO3 bindings, parallel sweep, Arrow bridge ``` ## Pipeline algos 40+ composable algos for the `AlgoPipelineBacktester`. All follow `__call__(ctx) -> StepDecision`. **Scheduling**: `RunDaily`, `RunWeekly`, `RunMonthly`, `RunQuarterly`, `RunYearly`, `RunOnce`, `RunOnDate`, `RunAfterDate`, `RunAfterDays`, `RunEveryNPeriods`, `RunIfOutOfBounds`, `Or`, `Not`, `Require` **Selection**: `SelectAll`, `SelectThese`, `SelectHasData`, `SelectN`, `SelectMomentum`, `SelectWhere`, `SelectRandomly`, `SelectActive`, `SelectRegex` **Weighting**: `WeighEqually`, `WeighSpecified`, `WeighTarget`, `WeighInvVol`, `WeighMeanVar`, `WeighERC`, `TargetVol`, `WeighRandomly` **Risk & rebalancing**: `LimitWeights`, `LimitDeltas`, `ScaleWeights`, `HedgeRisks`, `Margin`, `MaxDrawdownGuard`, `Rebalance`, `RebalanceOverTime`, `CapitalFlow`, `CloseDead`, `ClosePositionsAfterDates`, `ReplayTransactions`, `CouponPayingPosition` ## Research Research notebooks and analysis: [finance_research](https://github.com/unbalancedparentheses/finance_research). ================================================ FILE: benchmarks/benchmark_large_pipeline.py ================================================ """Large-scale performance benchmark: Rust vs Python on production data. Runs the same strategy through Rust full-loop and Python BacktestEngine on the full SPY options dataset (24.7M rows, 4500+ trading days) with frequent rebalancing to produce thousands of trades. Usage: python scripts/benchmark_large_pipeline.py python scripts/benchmark_large_pipeline.py --rebalance-freq 2 --runs 3 """ from __future__ import annotations import argparse import gc import os import sys import time from dataclasses import dataclass, field from pathlib import Path import numpy as np import pandas as pd REPO_ROOT = Path(__file__).resolve().parents[1] from options_portfolio_backtester import BacktestEngine as LegacyBacktest from options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData from options_portfolio_backtester.core.types import Direction, Stock, OptionType as Type from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.engine.engine import BacktestEngine from options_portfolio_backtester.engine._dispatch import use_rust from options_portfolio_backtester.engine import _dispatch as _rust_dispatch from options_portfolio_backtester.execution.cost_model import NoCosts STOCKS_FILE = os.path.join(REPO_ROOT, "data", "processed", "stocks.csv") OPTIONS_FILE = os.path.join(REPO_ROOT, "data", "processed", "options.csv") @dataclass class BenchResult: name: str runtime_s: float final_capital: float total_return_pct: float n_trades: int n_balance_rows: int dispatch_mode: str peak_mem_mb: float = 0.0 per_run_times: list[float] = field(default_factory=list) def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description="Large-scale Rust vs Python benchmark.") p.add_argument("--runs", type=int, default=3, help="Number of timing runs (default: 3).") p.add_argument("--rebalance-freq", type=int, default=1, help="Rebalance frequency in business months (1=monthly).") p.add_argument("--dte-min", type=int, default=20, help="Min DTE for entry filter.") p.add_argument("--dte-max", type=int, default=60, help="Max DTE for entry filter.") p.add_argument("--dte-exit", type=int, default=10, help="DTE threshold for exit.") p.add_argument("--initial-capital", type=int, default=1_000_000, help="Initial capital.") p.add_argument("--options-pct", type=float, default=0.10, help="Options allocation pct (0.10 = 10%%).") return p.parse_args() def _load_data(): print("Loading data...") t0 = time.perf_counter() stocks_data = TiingoData(STOCKS_FILE) options_data = HistoricalOptionsData(OPTIONS_FILE) load_time = time.perf_counter() - t0 n_opt = len(options_data._data) n_stk = len(stocks_data._data) n_dates = options_data._data["quotedate"].nunique() print(f" Loaded in {load_time:.2f}s") print(f" Options: {n_opt:,} rows, {n_dates:,} trading days") print(f" Stocks: {n_stk:,} rows") return stocks_data, options_data def _strategy(schema, dte_min, dte_max, dte_exit): strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) leg.entry_filter = ( (schema.underlying == "SPY") & (schema.dte >= dte_min) & (schema.dte <= dte_max) ) leg.exit_filter = schema.dte <= dte_exit strat.add_legs([leg]) return strat def _copy_data(stocks_data, options_data): """Deep-copy data handlers to avoid cross-run contamination.""" sd = TiingoData.__new__(TiingoData) sd.__dict__.update(stocks_data.__dict__) sd._data = stocks_data._data.copy() od = HistoricalOptionsData.__new__(HistoricalOptionsData) od.__dict__.update(options_data.__dict__) od._data = options_data._data.copy() return sd, od def run_engine( stocks_data, options_data, args, runs, force_python=False, ) -> BenchResult: """Run BacktestEngine. If force_python, temporarily disable Rust dispatch.""" label = "Python BacktestEngine" if force_python else "Rust BacktestEngine" times = [] engine = None for i in range(runs): sd, od = _copy_data(stocks_data, options_data) engine = BacktestEngine( {"stocks": 1.0 - args.options_pct, "options": args.options_pct, "cash": 0.0}, cost_model=NoCosts(), initial_capital=args.initial_capital, ) engine.stocks = [Stock("SPY", 1.0)] engine.stocks_data = sd engine.options_data = od engine.options_strategy = _strategy(od.schema, args.dte_min, args.dte_max, args.dte_exit) gc.collect() saved_rust = _rust_dispatch.RUST_AVAILABLE if force_python: _rust_dispatch.RUST_AVAILABLE = False try: t0 = time.perf_counter() engine.run(rebalance_freq=args.rebalance_freq) elapsed = time.perf_counter() - t0 finally: _rust_dispatch.RUST_AVAILABLE = saved_rust times.append(elapsed) print(f" {label} run {i+1}/{runs}: {elapsed:.3f}s") assert engine is not None mode = engine.run_metadata.get("dispatch_mode", "unknown") final = float(engine.balance["total capital"].iloc[-1]) n_trades = len(engine.trade_log) if not engine.trade_log.empty else 0 total_ret = (final / args.initial_capital - 1.0) * 100.0 return BenchResult( name=label, runtime_s=float(np.mean(times)), final_capital=final, total_return_pct=total_ret, n_trades=n_trades, n_balance_rows=len(engine.balance), dispatch_mode=mode, per_run_times=times, ) def run_legacy(stocks_data, options_data, args, runs) -> BenchResult: """Run legacy Backtest class.""" times = [] bt = None for i in range(runs): sd, od = _copy_data(stocks_data, options_data) bt = LegacyBacktest( {"stocks": 1.0 - args.options_pct, "options": args.options_pct, "cash": 0.0}, initial_capital=args.initial_capital, ) bt.stocks = [Stock("SPY", 1.0)] bt.stocks_data = sd bt.options_data = od bt.options_strategy = _strategy(od.schema, args.dte_min, args.dte_max, args.dte_exit) gc.collect() t0 = time.perf_counter() bt.run(rebalance_freq=args.rebalance_freq) elapsed = time.perf_counter() - t0 times.append(elapsed) print(f" Legacy Python run {i+1}/{runs}: {elapsed:.3f}s") assert bt is not None final = float(bt.balance["total capital"].iloc[-1]) n_trades = len(bt.trade_log) if not bt.trade_log.empty else 0 total_ret = (final / bt.initial_capital - 1.0) * 100.0 return BenchResult( name="Legacy Python Backtest", runtime_s=float(np.mean(times)), final_capital=final, total_return_pct=total_ret, n_trades=n_trades, n_balance_rows=len(bt.balance), dispatch_mode="python-legacy", per_run_times=times, ) def print_result(r: BenchResult, indent: str = " ") -> None: print(f"{indent}{r.name}") print(f"{indent} dispatch: {r.dispatch_mode}") print(f"{indent} avg runtime: {r.runtime_s:.3f}s") print(f"{indent} per-run times: [{', '.join(f'{t:.3f}s' for t in r.per_run_times)}]") print(f"{indent} final capital: ${r.final_capital:,.2f}") print(f"{indent} total return: {r.total_return_pct:.4f}%") print(f"{indent} trades: {r.n_trades:,}") print(f"{indent} balance rows: {r.n_balance_rows:,}") def print_comparison(a: BenchResult, b: BenchResult) -> None: if a.runtime_s > 0: speedup = b.runtime_s / a.runtime_s else: speedup = float("nan") cap_delta = abs(a.final_capital - b.final_capital) ret_delta = a.total_return_pct - b.total_return_pct cap_pct = (cap_delta / max(a.final_capital, 1)) * 100 print(f" {a.name} vs {b.name}:") print(f" speedup: {speedup:.2f}x ({a.name} is {'faster' if speedup > 1 else 'slower'})") print(f" capital delta: ${cap_delta:,.2f} ({cap_pct:.4f}%)") print(f" return delta: {ret_delta:+.4f} pct-pts") if a.n_trades > 0 and b.n_trades > 0: print(f" trade count: {a.n_trades:,} vs {b.n_trades:,} ({'match' if a.n_trades == b.n_trades else 'MISMATCH'})") def main() -> None: args = parse_args() for f in (STOCKS_FILE, OPTIONS_FILE): if not Path(f).exists(): print(f"ERROR: Missing data file: {f}") print("Run this benchmark from the repo root with production data in data/processed/") sys.exit(1) print(f"\n{'='*65}") print("Large-Scale Performance Benchmark: Rust vs Python") print(f"{'='*65}") print(f" Rust available: {use_rust()}") print(f" runs per backend: {args.runs}") print(f" rebalance freq: {args.rebalance_freq} BMS") print(f" strategy: BUY PUT, DTE {args.dte_min}-{args.dte_max}, exit DTE <= {args.dte_exit}") print(f" allocation: {(1-args.options_pct)*100:.0f}% stocks / {args.options_pct*100:.0f}% options") print(f" initial capital: ${args.initial_capital:,}") print() stocks_data, options_data = _load_data() print() # -- Run all backends -- results = [] if use_rust(): print("Running Rust BacktestEngine...") rust_result = run_engine(stocks_data, options_data, args, args.runs, force_python=False) results.append(rust_result) print() print("Running Python BacktestEngine...") python_result = run_engine(stocks_data, options_data, args, args.runs, force_python=True) results.append(python_result) print() print("Running Legacy Python Backtest...") legacy_result = run_legacy(stocks_data, options_data, args, args.runs) results.append(legacy_result) print() # -- Report -- print(f"{'='*65}") print("Results") print(f"{'='*65}") for r in results: print_result(r) print() print(f"{'='*65}") print("Comparisons") print(f"{'='*65}") if use_rust(): print_comparison(rust_result, python_result) print() print_comparison(rust_result, legacy_result) print() print_comparison(python_result, legacy_result) print() # -- Summary table -- print(f"{'='*65}") print("Summary Table") print(f"{'='*65}") rows = [] for r in results: rows.append({ "Backend": r.name, "Dispatch": r.dispatch_mode, "Avg Time (s)": f"{r.runtime_s:.3f}", "Trades": f"{r.n_trades:,}", "Final Capital": f"${r.final_capital:,.0f}", "Return %": f"{r.total_return_pct:.2f}", }) df = pd.DataFrame(rows) print(df.to_string(index=False)) if use_rust() and rust_result.runtime_s > 0: print(f"\n Rust speedup over Python Engine: {python_result.runtime_s / rust_result.runtime_s:.2f}x") print(f" Rust speedup over Legacy: {legacy_result.runtime_s / rust_result.runtime_s:.2f}x") print("\nDone.") if __name__ == "__main__": main() ================================================ FILE: benchmarks/benchmark_matrix.py ================================================ """Standardized benchmark matrix for options_portfolio_backtester vs bt. Runs multiple scenarios over date ranges/rebalance frequencies and writes a CSV scorecard with runtime and parity metrics. """ from __future__ import annotations import argparse import sys import time from dataclasses import dataclass from pathlib import Path import numpy as np import pandas as pd REPO_ROOT = Path(__file__).resolve().parents[1] from options_portfolio_backtester import BacktestEngine as Backtest from options_portfolio_backtester.data.providers import TiingoData from options_portfolio_backtester.core.types import Stock @dataclass(frozen=True) class Scenario: label: str start: pd.Timestamp end: pd.Timestamp rebalance_months: int initial_capital: float def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description="Benchmark matrix vs bt.") p.add_argument("--stocks-file", default="data/processed/stocks.csv") p.add_argument("--symbols", default="SPY") p.add_argument("--weights", default=None) p.add_argument("--date-ranges", default="2008-01-01:2025-12-12,2016-01-01:2025-12-12") p.add_argument("--rebalance-months", default="1,3") p.add_argument("--initial-capitals", default="1000000") p.add_argument("--runs", type=int, default=3) p.add_argument("--output", default="data/processed/benchmark_matrix.csv") return p.parse_args() def parse_csv_list(s: str, cast): return [cast(x.strip()) for x in s.split(",") if x.strip()] def normalize_weights(symbols: list[str], raw_weights: str | None) -> list[float]: if raw_weights is None: return [1.0 / len(symbols)] * len(symbols) vals = [float(x) for x in raw_weights.split(",")] if len(vals) != len(symbols): raise ValueError("--weights length must match --symbols length") total = float(sum(vals)) if total <= 0: raise ValueError("--weights must sum to > 0") return [v / total for v in vals] def compute_metrics(total_capital: pd.Series) -> tuple[float, float, float, float, float]: total_capital = total_capital.dropna() if total_capital.empty: return 0.0, 0.0, 0.0, 0.0, 0.0 rets = total_capital.pct_change().dropna() total_return = total_capital.iloc[-1] / total_capital.iloc[0] - 1.0 n_years = len(total_capital) / 252.0 cagr = (total_capital.iloc[-1] / total_capital.iloc[0]) ** (1.0 / n_years) - 1.0 if n_years > 0 else 0.0 peak = total_capital.cummax() dd = total_capital / peak - 1.0 max_dd = float(dd.min()) if not dd.empty else 0.0 vol = float(rets.std(ddof=1) * np.sqrt(252)) if len(rets) > 1 else 0.0 sharpe = float((rets.mean() / rets.std(ddof=1)) * np.sqrt(252)) if len(rets) > 1 and rets.std(ddof=1) > 0 else 0.0 return total_return, cagr, max_dd, vol, sharpe def slice_stocks_data(stocks_file: str, start: pd.Timestamp, end: pd.Timestamp) -> TiingoData: d = TiingoData(stocks_file) m = (d._data["date"] >= start) & (d._data["date"] <= end) d._data = d._data.loc[m].copy() d.start_date = d._data["date"].min() d.end_date = d._data["date"].max() return d def run_options_portfolio_backtester( stocks_file: str, symbols: list[str], weights: list[float], scenario: Scenario, runs: int, ) -> tuple[dict[str, float], pd.Series]: stocks = [Stock(sym, w) for sym, w in zip(symbols, weights)] runtimes = [] last_eq = pd.Series(dtype=float) for _ in range(runs): stocks_data = slice_stocks_data(stocks_file, scenario.start, scenario.end) bt = Backtest({"stocks": 1.0, "options": 0.0, "cash": 0.0}, initial_capital=int(scenario.initial_capital)) bt.stocks = stocks bt.stocks_data = stocks_data t0 = time.perf_counter() bt.run(rebalance_freq=scenario.rebalance_months, rebalance_unit="BMS") runtimes.append(time.perf_counter() - t0) last_eq = bt.balance["total capital"].dropna() tr, cagr, mdd, vol, sharpe = compute_metrics(last_eq) return ({ "ob_runtime_s": float(np.mean(runtimes)), "ob_total_return_pct": tr * 100.0, "ob_cagr_pct": cagr * 100.0, "ob_max_drawdown_pct": mdd * 100.0, "ob_vol_annual_pct": vol * 100.0, "ob_sharpe": sharpe, "ob_rows": float(len(last_eq)), }, last_eq) def run_bt( stocks_file: str, symbols: list[str], weights: list[float], scenario: Scenario, runs: int, ) -> tuple[dict[str, float], pd.Series | None]: try: import bt # type: ignore except Exception: return ({"bt_available": 0.0}, None) prices = pd.read_csv(stocks_file, parse_dates=["date"]) m = (prices["date"] >= scenario.start) & (prices["date"] <= scenario.end) & (prices["symbol"].isin(symbols)) prices = prices.loc[m].copy() px = prices.pivot(index="date", columns="symbol", values="adjClose").sort_index().dropna() px = px[symbols] runtimes = [] last_eq = None for _ in range(runs): algos = [ bt.algos.RunMonthly(), bt.algos.SelectThese(symbols), bt.algos.WeighSpecified(**{s: w for s, w in zip(symbols, weights)}), bt.algos.Rebalance(), ] test = bt.Backtest(bt.Strategy("bench_matrix", algos), px, initial_capital=scenario.initial_capital) t0 = time.perf_counter() res = bt.run(test) runtimes.append(time.perf_counter() - t0) last_eq = res.prices.iloc[:, 0] assert last_eq is not None tr, cagr, mdd, vol, sharpe = compute_metrics(last_eq) return ({ "bt_available": 1.0, "bt_runtime_s": float(np.mean(runtimes)), "bt_total_return_pct": tr * 100.0, "bt_cagr_pct": cagr * 100.0, "bt_max_drawdown_pct": mdd * 100.0, "bt_vol_annual_pct": vol * 100.0, "bt_sharpe": sharpe, "bt_rows": float(len(last_eq)), }, last_eq) def overlap_parity(ob_eq: pd.Series, bt_eq: pd.Series | None) -> dict[str, float]: if bt_eq is None: return {"overlap_rows": 0.0, "overlap_end_delta": np.nan, "overlap_max_abs_delta": np.nan} common = ob_eq.index.intersection(bt_eq.index) if len(common) == 0: return {"overlap_rows": 0.0, "overlap_end_delta": np.nan, "overlap_max_abs_delta": np.nan} ob_n = ob_eq.loc[common] / ob_eq.loc[common].iloc[0] bt_n = bt_eq.loc[common] / bt_eq.loc[common].iloc[0] d = ob_n - bt_n return { "overlap_rows": float(len(common)), "overlap_end_delta": float(d.iloc[-1]), "overlap_max_abs_delta": float(d.abs().max()), } def build_scenarios(args: argparse.Namespace) -> list[Scenario]: date_ranges = [] for chunk in args.date_ranges.split(","): chunk = chunk.strip() if not chunk: continue s, e = chunk.split(":") date_ranges.append((pd.Timestamp(s), pd.Timestamp(e))) rebal = parse_csv_list(args.rebalance_months, int) capitals = parse_csv_list(args.initial_capitals, float) scenarios = [] idx = 1 for s, e in date_ranges: for r in rebal: for c in capitals: scenarios.append(Scenario( label=f"S{idx}", start=s, end=e, rebalance_months=r, initial_capital=c, )) idx += 1 return scenarios def main() -> None: args = parse_args() symbols = [s.strip().upper() for s in args.symbols.split(",") if s.strip()] if not symbols: raise ValueError("No symbols provided") weights = normalize_weights(symbols, args.weights) scenarios = build_scenarios(args) rows = [] for sc in scenarios: ob_stats, ob_eq = run_options_portfolio_backtester( stocks_file=args.stocks_file, symbols=symbols, weights=weights, scenario=sc, runs=args.runs, ) bt_stats, bt_eq = run_bt( stocks_file=args.stocks_file, symbols=symbols, weights=weights, scenario=sc, runs=args.runs, ) parity = overlap_parity(ob_eq, bt_eq) row = { "scenario": sc.label, "start": sc.start.date().isoformat(), "end": sc.end.date().isoformat(), "rebalance_months": sc.rebalance_months, "initial_capital": sc.initial_capital, "symbols": ",".join(symbols), "weights": ",".join(f"{w:.6f}" for w in weights), **ob_stats, **bt_stats, **parity, } if bt_stats.get("bt_available", 0.0) == 1.0: row["speed_ratio_bt_over_ob"] = row["bt_runtime_s"] / row["ob_runtime_s"] if row["ob_runtime_s"] > 0 else np.nan row["return_delta_pct_pts"] = row["ob_total_return_pct"] - row["bt_total_return_pct"] row["maxdd_delta_pct_pts"] = row["ob_max_drawdown_pct"] - row["bt_max_drawdown_pct"] else: row["speed_ratio_bt_over_ob"] = np.nan row["return_delta_pct_pts"] = np.nan row["maxdd_delta_pct_pts"] = np.nan rows.append(row) out = pd.DataFrame(rows).sort_values(["start", "rebalance_months", "initial_capital"]) out_path = Path(args.output) out_path.parent.mkdir(parents=True, exist_ok=True) out.to_csv(out_path, index=False) print("\n=== Benchmark Matrix Summary ===") print(f"scenarios: {len(out)}") print(f"output: {out_path}") cols = [ "scenario", "start", "end", "rebalance_months", "ob_runtime_s", "bt_runtime_s", "speed_ratio_bt_over_ob", "return_delta_pct_pts", "maxdd_delta_pct_pts", "overlap_max_abs_delta", ] print(out[cols].to_string(index=False)) if __name__ == "__main__": main() ================================================ FILE: benchmarks/benchmark_rust_vs_python.py ================================================ """Benchmark: Rust full-loop vs Python BacktestEngine vs legacy Backtest vs bt. Runs options backtest (with options data) through Rust and Python paths, plus a stock-only comparison against bt if installed. Usage: python scripts/benchmark_rust_vs_python.py python scripts/benchmark_rust_vs_python.py --runs 5 --stock-only """ from __future__ import annotations import argparse import os import sys import time from dataclasses import dataclass from pathlib import Path import numpy as np import pandas as pd REPO_ROOT = Path(__file__).resolve().parents[1] from options_portfolio_backtester import BacktestEngine as LegacyBacktest from options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData from options_portfolio_backtester.core.types import Direction, Stock, OptionType as Type from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.engine.engine import BacktestEngine from options_portfolio_backtester.engine._dispatch import use_rust from options_portfolio_backtester.engine import _dispatch as _rust_dispatch from options_portfolio_backtester.execution.cost_model import NoCosts from options_portfolio_backtester.execution.fill_model import MarketAtBidAsk from options_portfolio_backtester.execution.signal_selector import FirstMatch TEST_DIR = os.path.join(REPO_ROOT, "backtester", "test") STOCKS_FILE = os.path.join(TEST_DIR, "test_data", "ivy_5assets_data.csv") OPTIONS_FILE = os.path.join(TEST_DIR, "test_data", "options_data.csv") PROD_STOCKS_FILE = os.path.join(REPO_ROOT, "data", "processed", "stocks.csv") PROD_OPTIONS_FILE = os.path.join(REPO_ROOT, "data", "processed", "options.csv") @dataclass class BenchResult: name: str runtime_s: float final_capital: float total_return_pct: float n_trades: int dispatch_mode: str def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description="Benchmark Rust vs Python backtest paths.") p.add_argument("--runs", type=int, default=3, help="Timing averaging repeats.") p.add_argument("--stock-only", action="store_true", help="Also run stock-only comparison vs bt.") p.add_argument("--use-prod-data", action="store_true", help="Use production data files if available.") p.add_argument("--rebalance-freq", type=int, default=1, help="Rebalance frequency.") return p.parse_args() def _stocks(use_prod: bool = False): if use_prod: return [Stock("SPY", 1.0)] return [Stock("VTI", 0.2), Stock("VEU", 0.2), Stock("BND", 0.2), Stock("VNQ", 0.2), Stock("DBC", 0.2)] def _load_data(use_prod: bool): if use_prod and Path(PROD_STOCKS_FILE).exists() and Path(PROD_OPTIONS_FILE).exists(): stocks_file, options_file = PROD_STOCKS_FILE, PROD_OPTIONS_FILE else: stocks_file, options_file = STOCKS_FILE, OPTIONS_FILE stocks_data = TiingoData(stocks_file) options_data = HistoricalOptionsData(options_file) if stocks_file == STOCKS_FILE: stocks_data._data["adjClose"] = 10 options_data._data.at[2, "ask"] = 1 options_data._data.at[2, "bid"] = 0.5 options_data._data.at[51, "ask"] = 1.5 options_data._data.at[50, "bid"] = 0.5 options_data._data.at[130, "bid"] = 0.5 options_data._data.at[131, "bid"] = 1.5 options_data._data.at[206, "bid"] = 0.5 options_data._data.at[207, "bid"] = 1.5 return stocks_data, options_data, stocks_file def _buy_strategy(schema): strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) leg.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat.add_legs([leg]) return strat # --------------------------------------------------------------------------- # Runners # --------------------------------------------------------------------------- def run_engine_python(stocks_data, options_data, stocks, rebalance_freq, runs) -> BenchResult: """Force Python path by temporarily disabling Rust dispatch.""" times = [] engine = None for _ in range(runs): sd = TiingoData.__new__(TiingoData) sd.__dict__.update(stocks_data.__dict__) sd._data = stocks_data._data.copy() od = HistoricalOptionsData.__new__(HistoricalOptionsData) od.__dict__.update(options_data.__dict__) od._data = options_data._data.copy() engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), ) engine.stocks = stocks engine.stocks_data = sd engine.options_data = od engine.options_strategy = _buy_strategy(od.schema) saved_rust = _rust_dispatch.RUST_AVAILABLE _rust_dispatch.RUST_AVAILABLE = False try: t0 = time.perf_counter() engine.run(rebalance_freq=rebalance_freq) times.append(time.perf_counter() - t0) finally: _rust_dispatch.RUST_AVAILABLE = saved_rust assert engine is not None final = float(engine.balance["total capital"].iloc[-1]) n_trades = len(engine.trade_log) if not engine.trade_log.empty else 0 total_ret = (final / engine.initial_capital - 1) * 100 return BenchResult( name="Python BacktestEngine", runtime_s=float(np.mean(times)), final_capital=final, total_return_pct=total_ret, n_trades=n_trades, dispatch_mode="python", ) def run_engine_rust(stocks_data, options_data, stocks, rebalance_freq, runs) -> BenchResult | None: """Let Rust dispatch happen naturally (default path).""" if not use_rust(): return None times = [] engine = None for _ in range(runs): sd = TiingoData.__new__(TiingoData) sd.__dict__.update(stocks_data.__dict__) sd._data = stocks_data._data.copy() od = HistoricalOptionsData.__new__(HistoricalOptionsData) od.__dict__.update(options_data.__dict__) od._data = options_data._data.copy() engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), ) engine.stocks = stocks engine.stocks_data = sd engine.options_data = od engine.options_strategy = _buy_strategy(od.schema) t0 = time.perf_counter() engine.run(rebalance_freq=rebalance_freq) times.append(time.perf_counter() - t0) assert engine is not None mode = engine.run_metadata.get("dispatch_mode", "unknown") final = float(engine.balance["total capital"].iloc[-1]) n_trades = len(engine.trade_log) if not engine.trade_log.empty else 0 total_ret = (final / engine.initial_capital - 1) * 100 return BenchResult( name="Rust BacktestEngine", runtime_s=float(np.mean(times)), final_capital=final, total_return_pct=total_ret, n_trades=n_trades, dispatch_mode=mode, ) def run_legacy_python(stocks_data, options_data, stocks, rebalance_freq, runs) -> BenchResult: """Legacy Backtest class.""" times = [] bt = None for _ in range(runs): sd = TiingoData.__new__(TiingoData) sd.__dict__.update(stocks_data.__dict__) sd._data = stocks_data._data.copy() od = HistoricalOptionsData.__new__(HistoricalOptionsData) od.__dict__.update(options_data.__dict__) od._data = options_data._data.copy() bt = LegacyBacktest({"stocks": 0.97, "options": 0.03, "cash": 0}) bt.stocks = stocks bt.stocks_data = sd bt.options_data = od bt.options_strategy = _buy_strategy(od.schema) t0 = time.perf_counter() bt.run(rebalance_freq=rebalance_freq) times.append(time.perf_counter() - t0) assert bt is not None final = float(bt.balance["total capital"].iloc[-1]) n_trades = len(bt.trade_log) if not bt.trade_log.empty else 0 total_ret = (final / bt.initial_capital - 1) * 100 return BenchResult( name="Legacy Python Backtest", runtime_s=float(np.mean(times)), final_capital=final, total_return_pct=total_ret, n_trades=n_trades, dispatch_mode="python-legacy", ) def run_bt_stock_only(stocks_file, symbols, weights, initial_capital, runs) -> BenchResult | None: """bt library stock-only benchmark.""" try: import bt except Exception: return None prices = pd.read_csv(stocks_file, parse_dates=["date"]) prices = prices[prices["symbol"].isin(symbols)].copy() px = prices.pivot(index="date", columns="symbol", values="adjClose").sort_index().dropna() px = px[symbols] times = [] last_res = None for _ in range(runs): algos = [ bt.algos.RunMonthly(), bt.algos.SelectThese(symbols), bt.algos.WeighSpecified(**dict(zip(symbols, weights))), bt.algos.Rebalance(), ] strat = bt.Strategy("bench", algos) test = bt.Backtest(strat, px, initial_capital=initial_capital) t0 = time.perf_counter() last_res = bt.run(test) times.append(time.perf_counter() - t0) assert last_res is not None series = last_res.prices.iloc[:, 0] # bt normalizes NAV to start at initial_capital final = float(series.iloc[-1]) start = float(series.iloc[0]) total_ret = (final / start - 1) * 100 return BenchResult( name="bt library", runtime_s=float(np.mean(times)), final_capital=final, total_return_pct=total_ret, n_trades=0, dispatch_mode="bt", ) def run_ob_stock_only(stocks_file, symbols, weights, initial_capital, runs) -> BenchResult: """options_portfolio_backtester stock-only benchmark.""" stocks = [Stock(sym, w) for sym, w in zip(symbols, weights)] times = [] bt_obj = None for _ in range(runs): stocks_data = TiingoData(stocks_file) bt_obj = LegacyBacktest({"stocks": 1.0, "options": 0.0, "cash": 0.0}, initial_capital=int(initial_capital)) bt_obj.stocks = stocks bt_obj.stocks_data = stocks_data t0 = time.perf_counter() bt_obj.run(rebalance_freq=1, rebalance_unit="BMS") times.append(time.perf_counter() - t0) assert bt_obj is not None bal = bt_obj.balance["total capital"].dropna() final = float(bal.iloc[-1]) total_ret = (final / initial_capital - 1) * 100 return BenchResult( name="options_portfolio_backtester (stock-only)", runtime_s=float(np.mean(times)), final_capital=final, total_return_pct=total_ret, n_trades=0, dispatch_mode="python-legacy-stock-only", ) # --------------------------------------------------------------------------- # Display # --------------------------------------------------------------------------- def print_result(r: BenchResult) -> None: print(f" {r.name}") print(f" dispatch: {r.dispatch_mode}") print(f" runtime: {r.runtime_s:.4f}s") print(f" final_capital: {r.final_capital:,.2f}") print(f" total_return: {r.total_return_pct:.4f}%") print(f" n_trades: {r.n_trades}") def print_comparison(a: BenchResult, b: BenchResult) -> None: speedup = b.runtime_s / a.runtime_s if a.runtime_s > 0 else float("nan") cap_delta = abs(a.final_capital - b.final_capital) ret_delta = a.total_return_pct - b.total_return_pct print(f" {a.name} vs {b.name}:") print(f" speedup: {speedup:.2f}x ({a.name} is {'faster' if speedup > 1 else 'slower'})") print(f" capital delta: ${cap_delta:,.2f}") print(f" return delta: {ret_delta:+.4f} pct-pts") if a.n_trades > 0 and b.n_trades > 0: print(f" trades match: {a.n_trades == b.n_trades} ({a.n_trades} vs {b.n_trades})") def main() -> None: args = parse_args() stocks_data, options_data, stocks_file = _load_data(args.use_prod_data) stocks = _stocks(use_prod=args.use_prod_data) print(f"\n{'='*60}") print("Benchmark: Rust vs Python vs Legacy") print(f"{'='*60}") print(f" Rust available: {use_rust()}") print(f" runs per backend: {args.runs}") print(f" rebalance_freq: {args.rebalance_freq}") print(f" data: {'production' if args.use_prod_data else 'test'}") print() # -- Options backtest benchmarks -- print("--- Options Backtest (with options data) ---") results = [] legacy = run_legacy_python(stocks_data, options_data, stocks, args.rebalance_freq, args.runs) results.append(legacy) print_result(legacy) python_engine = run_engine_python(stocks_data, options_data, stocks, args.rebalance_freq, args.runs) results.append(python_engine) print_result(python_engine) rust_engine = run_engine_rust(stocks_data, options_data, stocks, args.rebalance_freq, args.runs) if rust_engine: results.append(rust_engine) print_result(rust_engine) else: print(" Rust BacktestEngine: SKIPPED (Rust not available)") print() print("--- Comparisons ---") if rust_engine: print_comparison(rust_engine, python_engine) print_comparison(rust_engine, legacy) print_comparison(python_engine, legacy) # -- Stock-only benchmarks -- if args.stock_only and Path(PROD_STOCKS_FILE).exists(): print() print("--- Stock-Only Monthly Rebalance (vs bt) ---") symbols = ["SPY"] weights = [1.0] capital = 1_000_000.0 ob_stock = run_ob_stock_only(PROD_STOCKS_FILE, symbols, weights, capital, args.runs) print_result(ob_stock) bt_res = run_bt_stock_only(PROD_STOCKS_FILE, symbols, weights, capital, args.runs) if bt_res: print_result(bt_res) print() print_comparison(ob_stock, bt_res) else: print(" bt: SKIPPED (not installed)") print() print("Done.") if __name__ == "__main__": main() ================================================ FILE: benchmarks/benchmark_sweep.py ================================================ """Benchmark: Rust parallel_sweep vs Python sequential grid search. This is the PRIMARY benchmark for justifying the Rust backend. Single backtests have Pandas<->Polars conversion overhead, but parallel_sweep amortizes that cost over N grid points and runs all backtests on Rayon threads (no GIL, no pickle, zero-copy data). Usage: python scripts/benchmark_sweep.py python scripts/benchmark_sweep.py --grid-sizes 10 50 100 --runs 3 """ from __future__ import annotations import argparse import gc import math import os import sys import time from pathlib import Path import numpy as np import pandas as pd import polars as pl REPO_ROOT = Path(__file__).resolve().parents[1] from options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData from options_portfolio_backtester.core.types import Direction, Stock, OptionType as Type from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.engine.engine import BacktestEngine from options_portfolio_backtester.engine._dispatch import use_rust, rust from options_portfolio_backtester.engine import _dispatch as _rust_dispatch from options_portfolio_backtester.execution.cost_model import NoCosts TEST_DIR = os.path.join(REPO_ROOT, "backtester", "test") STOCKS_FILE = os.path.join(TEST_DIR, "test_data", "ivy_5assets_data.csv") OPTIONS_FILE = os.path.join(TEST_DIR, "test_data", "options_data.csv") PROD_STOCKS_FILE = os.path.join(REPO_ROOT, "data", "processed", "stocks.csv") PROD_OPTIONS_FILE = os.path.join(REPO_ROOT, "data", "processed", "options.csv") def parse_args(): p = argparse.ArgumentParser(description="Benchmark Rust parallel_sweep vs Python sequential") p.add_argument("--grid-sizes", nargs="+", type=int, default=[5, 10, 25, 50], help="Grid sizes to test (number of parameter combos)") p.add_argument("--runs", type=int, default=2, help="Timing runs per grid size") p.add_argument("--use-prod-data", action="store_true", help="Use production data") return p.parse_args() def _load_data(use_prod: bool): if use_prod and Path(PROD_STOCKS_FILE).exists() and Path(PROD_OPTIONS_FILE).exists(): sf, of = PROD_STOCKS_FILE, PROD_OPTIONS_FILE else: sf, of = STOCKS_FILE, OPTIONS_FILE stocks_data = TiingoData(sf) options_data = HistoricalOptionsData(of) if sf == STOCKS_FILE: stocks_data._data["adjClose"] = 10 options_data._data.at[2, "ask"] = 1 options_data._data.at[2, "bid"] = 0.5 options_data._data.at[51, "ask"] = 1.5 options_data._data.at[50, "bid"] = 0.5 options_data._data.at[130, "bid"] = 0.5 options_data._data.at[131, "bid"] = 1.5 options_data._data.at[206, "bid"] = 0.5 options_data._data.at[207, "bid"] = 1.5 return stocks_data, options_data, sf def _build_param_grid(n: int, underlying: str = "SPX") -> list[dict]: """Generate n parameter override dicts varying DTE thresholds. Returns list of dicts with both Rust filter strings AND raw dte values so both the Rust and Python paths can use the same grid. """ dte_mins = np.linspace(20, 90, max(int(n**0.5), 2)).astype(int) dte_exits = np.linspace(5, 45, max(int(n / len(dte_mins)) + 1, 2)).astype(int) grid = [] for dmin in dte_mins: for dex in dte_exits: if len(grid) >= n: break grid.append({ "label": f"dte_min={dmin}_exit={dex}", "leg_entry_filters": [ f"(underlying == '{underlying}') & (dte >= {dmin})", ], "leg_exit_filters": [ f"dte <= {dex}", ], # Raw params for Python path "_dte_min": int(dmin), "_dte_exit": int(dex), "_underlying": underlying, }) if len(grid) >= n: break return grid[:n] def _build_rust_config(stocks_data, options_data, stocks, underlying="SPX"): """Build the config dict for rust.parallel_sweep.""" schema = options_data.schema strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) leg.entry_filter = (schema.underlying == underlying) & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat.add_legs([leg]) date_fmt = "%Y-%m-%d %H:%M:%S" dates_df = ( pd.DataFrame(options_data._data[["quotedate", "volume"]]) .drop_duplicates("quotedate") .set_index("quotedate") ) rebalancing_days = pd.to_datetime( dates_df.groupby(pd.Grouper(freq="1BMS")) .apply(lambda x: x.index.min()) .values ) rb_dates = [d.strftime(date_fmt) for d in rebalancing_days] config = { "allocation": {"stocks": 0.97, "options": 0.03, "cash": 0.0}, "initial_capital": 1_000_000.0, "shares_per_contract": 100, "rebalance_dates": rb_dates, "legs": [{ "name": leg.name, "entry_filter": leg.entry_filter.query, "exit_filter": leg.exit_filter.query, "direction": leg.direction.value, "type": leg.type.value, "entry_sort_col": None, "entry_sort_asc": True, }], "profit_pct": None, "loss_pct": None, "stocks": [(s.symbol, s.percentage) for s in stocks], } stocks_schema = stocks_data.schema opts_schema = options_data.schema schema_mapping = { "contract": opts_schema["contract"], "date": opts_schema["date"], "stocks_date": stocks_schema["date"], "stocks_symbol": stocks_schema["symbol"], "stocks_price": stocks_schema["adjClose"], "underlying": opts_schema["underlying"], "expiration": opts_schema["expiration"], "type": opts_schema["type"], "strike": opts_schema["strike"], } # Convert datetime columns opts_copy = options_data._data.copy() for c in [opts_schema["date"], opts_schema["expiration"]]: if c in opts_copy.columns and pd.api.types.is_datetime64_any_dtype(opts_copy[c]): opts_copy[c] = opts_copy[c].dt.strftime(date_fmt) stocks_copy = stocks_data._data.copy() sc = stocks_schema["date"] if sc in stocks_copy.columns and pd.api.types.is_datetime64_any_dtype(stocks_copy[sc]): stocks_copy[sc] = stocks_copy[sc].dt.strftime(date_fmt) opts_pl = pl.from_pandas(opts_copy) stocks_pl = pl.from_pandas(stocks_copy) return config, schema_mapping, opts_pl, stocks_pl, strat def run_rust_sweep(opts_pl, stocks_pl, config, schema_mapping, param_grid, runs): """Run Rust parallel_sweep.""" times = [] last_results = None for _ in range(runs): gc.collect() t0 = time.perf_counter() results = rust.parallel_sweep( opts_pl, stocks_pl, config, schema_mapping, param_grid, None, ) elapsed = time.perf_counter() - t0 times.append(elapsed) last_results = results return times, last_results def run_python_sequential(stocks_data, options_data, stocks, param_grid, runs, underlying="SPX"): """Run sequential Python backtests for same grid.""" times = [] last_results = None for _ in range(runs): gc.collect() t0 = time.perf_counter() results = [] for params in param_grid: sd = TiingoData.__new__(TiingoData) sd.__dict__.update(stocks_data.__dict__) sd._data = stocks_data._data.copy() od = HistoricalOptionsData.__new__(HistoricalOptionsData) od.__dict__.update(options_data.__dict__) od._data = options_data._data.copy() schema = od.schema strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) # Construct filters from raw DTE params dte_min = params.get("_dte_min", 60) dte_exit = params.get("_dte_exit", 30) und = params.get("_underlying", underlying) leg.entry_filter = (schema.underlying == und) & (schema.dte >= dte_min) leg.exit_filter = schema.dte <= dte_exit strat.add_legs([leg]) engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), ) engine.stocks = stocks engine.stocks_data = sd engine.options_data = od engine.options_strategy = strat saved = _rust_dispatch.RUST_AVAILABLE _rust_dispatch.RUST_AVAILABLE = False try: engine.run(rebalance_freq=1) finally: _rust_dispatch.RUST_AVAILABLE = saved final = float(engine.balance["total capital"].iloc[-1]) n_trades = len(engine.trade_log) if not engine.trade_log.empty else 0 results.append({ "label": params.get("label", ""), "final_capital": final, "total_trades": n_trades, }) elapsed = time.perf_counter() - t0 times.append(elapsed) last_results = results return times, last_results def run_rust_single(opts_pl, stocks_pl, config, schema_mapping, runs): """Run a single Rust backtest (for overhead measurement).""" times = [] for _ in range(runs): gc.collect() t0 = time.perf_counter() rust.run_backtest_py(opts_pl, stocks_pl, config, schema_mapping) elapsed = time.perf_counter() - t0 times.append(elapsed) return times def run_python_single(stocks_data, options_data, stocks, runs, underlying="SPX"): """Run a single Python backtest.""" times = [] for _ in range(runs): sd = TiingoData.__new__(TiingoData) sd.__dict__.update(stocks_data.__dict__) sd._data = stocks_data._data.copy() od = HistoricalOptionsData.__new__(HistoricalOptionsData) od.__dict__.update(options_data.__dict__) od._data = options_data._data.copy() engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), ) engine.stocks = stocks engine.stocks_data = sd engine.options_data = od schema = od.schema strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) leg.entry_filter = (schema.underlying == underlying) & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat.add_legs([leg]) engine.options_strategy = strat saved = _rust_dispatch.RUST_AVAILABLE _rust_dispatch.RUST_AVAILABLE = False try: gc.collect() t0 = time.perf_counter() engine.run(rebalance_freq=1) elapsed = time.perf_counter() - t0 finally: _rust_dispatch.RUST_AVAILABLE = saved times.append(elapsed) return times def main(): args = parse_args() if not use_rust(): print("ERROR: Rust extension not available. Build with: make rust-build") sys.exit(1) stocks_data, options_data, sf = _load_data(args.use_prod_data) if args.use_prod_data and Path(PROD_STOCKS_FILE).exists(): stocks = [Stock("SPY", 1.0)] else: stocks = [Stock("VTI", 0.2), Stock("VEU", 0.2), Stock("BND", 0.2), Stock("VNQ", 0.2), Stock("DBC", 0.2)] underlying = "SPY" if args.use_prod_data else "SPX" n_rows = len(options_data._data) n_dates = options_data._data["quotedate"].nunique() print(f"\n{'='*65}") print("Benchmark: Rust parallel_sweep vs Python sequential") print(f"{'='*65}") print(f" Data: {'production' if args.use_prod_data else 'test'} ({n_rows:,} options rows, {n_dates} dates)") print(f" Underlying: {underlying}") print(f" Grid sizes: {args.grid_sizes}") print(f" Runs per test: {args.runs}") print(f" CPU cores: {os.cpu_count()}") print() # Build Rust config once (amortized over all grid sizes) config, schema_mapping, opts_pl, stocks_pl, strat = _build_rust_config( stocks_data, options_data, stocks, underlying=underlying ) # -- Single backtest comparison -- print("--- Single Backtest (1 run) ---") rust_single = run_rust_single(opts_pl, stocks_pl, config, schema_mapping, args.runs) python_single = run_python_single(stocks_data, options_data, stocks, args.runs, underlying=underlying) rust_avg = np.mean(rust_single) py_avg = np.mean(python_single) print(f" Rust single: {rust_avg:.4f}s (per-run: [{', '.join(f'{t:.4f}s' for t in rust_single)}])") print(f" Python single: {py_avg:.4f}s (per-run: [{', '.join(f'{t:.4f}s' for t in python_single)}])") print(f" Speedup: {py_avg/rust_avg:.2f}x {'(Rust faster)' if rust_avg < py_avg else '(Python faster)'}") print() # -- Grid sweep comparison -- print("--- Grid Sweep (N parallel Rust vs N sequential Python) ---") rows = [] for grid_size in args.grid_sizes: param_grid = _build_param_grid(grid_size, underlying=underlying) print(f"\n Grid size: {grid_size}") # Rust parallel_sweep rust_times, rust_results = run_rust_sweep( opts_pl, stocks_pl, config, schema_mapping, param_grid, args.runs ) rust_avg = np.mean(rust_times) print(f" Rust parallel: {rust_avg:.4f}s (per-run: [{', '.join(f'{t:.4f}s' for t in rust_times)}])") # Python sequential python_times, python_results = run_python_sequential( stocks_data, options_data, stocks, param_grid, args.runs, underlying=underlying ) py_avg = np.mean(python_times) print(f" Python seq: {py_avg:.4f}s (per-run: [{', '.join(f'{t:.4f}s' for t in python_times)}])") speedup = py_avg / rust_avg if rust_avg > 0 else float("nan") throughput_rust = grid_size / rust_avg if rust_avg > 0 else 0 throughput_py = grid_size / py_avg if py_avg > 0 else 0 print(f" Speedup: {speedup:.2f}x {'(Rust faster)' if speedup > 1 else '(Python faster)'}") print(f" Throughput: Rust={throughput_rust:.1f}/s, Python={throughput_py:.1f}/s") rows.append({ "Grid": grid_size, "Rust (s)": f"{rust_avg:.4f}", "Python (s)": f"{py_avg:.4f}", "Speedup": f"{speedup:.2f}x", "Rust runs/s": f"{throughput_rust:.1f}", "Python runs/s": f"{throughput_py:.1f}", }) # -- Summary Table -- print(f"\n{'='*65}") print("Summary") print(f"{'='*65}") df = pd.DataFrame(rows) print(df.to_string(index=False)) print(f"\n{'='*65}") print("Conclusion") print(f"{'='*65}") if rows: final_speedup = float(rows[-1]["Speedup"].replace("x", "")) if final_speedup > 1: print(f" Rust parallel_sweep is {final_speedup:.1f}x faster for {args.grid_sizes[-1]} grid points.") print(f" For optimization/grid search, Rust + Rayon provides real value.") else: print(f" Rust is {1/final_speedup:.1f}x slower even for parallel sweep.") print(f" The Pandas<->Polars conversion overhead dominates.") single_speedup = np.mean(python_single) / np.mean(rust_single) if single_speedup < 1: print(f" Single backtest: Rust is {1/single_speedup:.1f}x SLOWER (conversion overhead).") else: print(f" Single backtest: Rust is {single_speedup:.1f}x faster.") print("\nDone.") if __name__ == "__main__": main() ================================================ FILE: benchmarks/compare_with_bt.py ================================================ """Head-to-head comparison: options_portfolio_backtester stock-only mode vs bt. This harness runs the same monthly stock-rebalance policy in both frameworks: - options_portfolio_backtester (legacy Backtest with options allocation = 0) - bt (if installed) Outputs a small scorecard with performance and runtime metrics. """ from __future__ import annotations import argparse import sys import time from dataclasses import dataclass from pathlib import Path import numpy as np import pandas as pd REPO_ROOT = Path(__file__).resolve().parents[1] from options_portfolio_backtester import BacktestEngine as Backtest from options_portfolio_backtester.data.providers import TiingoData from options_portfolio_backtester.core.types import Stock @dataclass class RunResult: name: str total_return_pct: float cagr_pct: float max_drawdown_pct: float vol_annual_pct: float sharpe: float runtime_s: float start_date: str end_date: str n_days: int equity: pd.Series def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Compare options_portfolio_backtester vs bt on stock-only allocation.") parser.add_argument("--stocks-file", default="data/processed/stocks.csv") parser.add_argument("--options-file", default="data/processed/options.csv") parser.add_argument("--symbols", default="SPY", help="Comma-separated symbols. Example: SPY or SPY,TLT,GLD") parser.add_argument("--weights", default=None, help="Comma-separated weights matching symbols. Defaults equal.") parser.add_argument("--initial-capital", type=float, default=1_000_000.0) parser.add_argument("--rebalance-months", type=int, default=1, help="Business-month-start rebalance frequency.") parser.add_argument("--runs", type=int, default=3, help="Runtime averaging repeats.") return parser.parse_args() def normalize_weights(symbols: list[str], raw_weights: str | None) -> list[float]: if raw_weights is None: return [1.0 / len(symbols)] * len(symbols) vals = [float(x) for x in raw_weights.split(",")] if len(vals) != len(symbols): raise ValueError("--weights length must match --symbols length") total = float(sum(vals)) if total <= 0: raise ValueError("--weights must sum to > 0") return [v / total for v in vals] def compute_metrics(total_capital: pd.Series) -> tuple[float, float, float, float, float]: total_capital = total_capital.dropna() if total_capital.empty: return 0.0, 0.0, 0.0, 0.0, 0.0 rets = total_capital.pct_change().dropna() total_return = total_capital.iloc[-1] / total_capital.iloc[0] - 1.0 n_years = len(total_capital) / 252.0 cagr = (total_capital.iloc[-1] / total_capital.iloc[0]) ** (1.0 / n_years) - 1.0 if n_years > 0 else 0.0 peak = total_capital.cummax() dd = total_capital / peak - 1.0 max_dd = float(dd.min()) if not dd.empty else 0.0 vol = float(rets.std(ddof=1) * np.sqrt(252)) if len(rets) > 1 else 0.0 sharpe = float((rets.mean() / rets.std(ddof=1)) * np.sqrt(252)) if len(rets) > 1 and rets.std(ddof=1) > 0 else 0.0 return total_return, cagr, max_dd, vol, sharpe def run_options_portfolio_backtester( stocks_file: str, symbols: list[str], weights: list[float], initial_capital: float, rebalance_months: int, runs: int, ) -> RunResult: stocks_data = TiingoData(stocks_file) stocks = [Stock(sym, w) for sym, w in zip(symbols, weights)] times: list[float] = [] bt_obj = None for _ in range(runs): bt = Backtest({"stocks": 1.0, "options": 0.0, "cash": 0.0}, initial_capital=int(initial_capital)) bt.stocks = stocks bt.stocks_data = stocks_data t0 = time.perf_counter() bt.run(rebalance_freq=rebalance_months, rebalance_unit="BMS") times.append(time.perf_counter() - t0) bt_obj = bt assert bt_obj is not None bal = bt_obj.balance["total capital"].dropna() tr, cagr, mdd, vol, sharpe = compute_metrics(bal) return RunResult( name="options_portfolio_backtester", total_return_pct=tr * 100.0, cagr_pct=cagr * 100.0, max_drawdown_pct=mdd * 100.0, vol_annual_pct=vol * 100.0, sharpe=sharpe, runtime_s=float(np.mean(times)), start_date=str(bal.index.min().date()), end_date=str(bal.index.max().date()), n_days=int(len(bal)), equity=bal, ) def run_bt( stocks_file: str, symbols: list[str], weights: list[float], initial_capital: float, runs: int, ) -> RunResult | None: try: import bt # type: ignore except Exception: return None prices = pd.read_csv(stocks_file, parse_dates=["date"]) prices = prices[prices["symbol"].isin(symbols)].copy() px = prices.pivot(index="date", columns="symbol", values="adjClose").sort_index().dropna() px = px[symbols] times: list[float] = [] last_res = None for _ in range(runs): algos = [ bt.algos.RunMonthly(), bt.algos.SelectThese(symbols), bt.algos.WeighSpecified(**{s: w for s, w in zip(symbols, weights)}), bt.algos.Rebalance(), ] strat = bt.Strategy("bt_monthly_rebal", algos) test = bt.Backtest(strat, px, initial_capital=initial_capital) t0 = time.perf_counter() last_res = bt.run(test) times.append(time.perf_counter() - t0) assert last_res is not None series = last_res.prices.iloc[:, 0] tr, cagr, mdd, vol, sharpe = compute_metrics(series) return RunResult( name="bt", total_return_pct=tr * 100.0, cagr_pct=cagr * 100.0, max_drawdown_pct=mdd * 100.0, vol_annual_pct=vol * 100.0, sharpe=sharpe, runtime_s=float(np.mean(times)), start_date=str(series.index.min().date()), end_date=str(series.index.max().date()), n_days=int(len(series)), equity=series, ) def print_result(r: RunResult) -> None: print(f"{r.name}") print(f" period: {r.start_date} -> {r.end_date} ({r.n_days} rows)") print(f" total_return: {r.total_return_pct:8.2f}%") print(f" cagr: {r.cagr_pct:8.2f}%") print(f" max_drawdown: {r.max_drawdown_pct:8.2f}%") print(f" vol_annual: {r.vol_annual_pct:8.2f}%") print(f" sharpe: {r.sharpe:8.3f}") print(f" runtime: {r.runtime_s:8.4f}s") def print_overlap_parity(a: RunResult, b: RunResult) -> None: common = a.equity.index.intersection(b.equity.index) if len(common) == 0: print(" overlap: none") return a_n = a.equity.loc[common] / a.equity.loc[common].iloc[0] b_n = b.equity.loc[common] / b.equity.loc[common].iloc[0] diff = a_n - b_n print(f" overlap rows: {len(common)}") print(f" overlap end delta: {float(diff.iloc[-1]):.6e}") print(f" overlap max abs delta: {float(diff.abs().max()):.6e}") def main() -> None: args = parse_args() symbols = [s.strip().upper() for s in args.symbols.split(",") if s.strip()] if not symbols: raise ValueError("No symbols provided") weights = normalize_weights(symbols, args.weights) for file_path in (args.stocks_file,): if not Path(file_path).exists(): raise FileNotFoundError(f"Missing file: {file_path}") ob = run_options_portfolio_backtester( stocks_file=args.stocks_file, symbols=symbols, weights=weights, initial_capital=args.initial_capital, rebalance_months=args.rebalance_months, runs=args.runs, ) bt_res = run_bt( stocks_file=args.stocks_file, symbols=symbols, weights=weights, initial_capital=args.initial_capital, runs=args.runs, ) print("\n=== Comparison Scorecard ===") print_result(ob) if bt_res is None: print("\nbt") print(" not available (module 'bt' is not installed in this environment).") print(" install in nix shell and rerun:") print(" pip install bt") else: print() print_result(bt_res) speedup = bt_res.runtime_s / ob.runtime_s if ob.runtime_s > 0 else float("nan") print("\nsummary") print(f" speed ratio (bt / options_portfolio_backtester): {speedup:0.2f}x") print( f" return delta (options_portfolio_backtester - bt): " f"{(ob.total_return_pct - bt_res.total_return_pct):0.2f} pct-pts" ) print( f" maxDD delta (options_portfolio_backtester - bt): " f"{(ob.max_drawdown_pct - bt_res.max_drawdown_pct):0.2f} pct-pts" ) print(" overlap parity:") print_overlap_parity(ob, bt_res) if __name__ == "__main__": main() ================================================ FILE: data/README.md ================================================ # Data Scripts Scripts for fetching and converting market data into the formats expected by the backtester. ## Quick Start Fetch both stock and options data for SPY, aligned by date: ```bash python data/fetch_data.py all --symbols SPY --start 2020-01-01 --end 2023-01-01 ``` Data 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: - `data/processed/stocks.csv` — Tiingo-format stock data - `data/processed/options.csv` — options data with Greeks ## Subcommands ```bash # Stocks only (GitHub Release > options-data > yfinance) python data/fetch_data.py stocks --symbols SPY --start 2020-01-01 --end 2023-01-01 # Options only python data/fetch_data.py options --symbols SPY --start 2020-01-01 --end 2023-01-01 # Both + date alignment (default) python data/fetch_data.py all --symbols SPY --start 2020-01-01 --end 2023-01-01 # Multiple symbols python data/fetch_data.py all --symbols SPY IWM QQQ --start 2020-01-01 --end 2023-01-01 # Custom output paths python data/fetch_data.py all --symbols SPY --start 2020-01-01 --end 2023-01-01 \ --stocks-output data/processed/spy_stocks.csv \ --options-output data/processed/spy_options.csv # Force re-download (skip cache) python data/fetch_data.py all --symbols SPY --start 2020-01-01 --end 2023-01-01 --force ``` ## OptionsDX Conversion (separate) For SPX index options from [optionsdx.com](https://www.optionsdx.com/): ```bash python data/convert_optionsdx.py data/raw/spx_eod_2020.csv --output data/processed/spx_options.csv ``` ## Loading Data in the Backtester ```python from backtester.datahandler import HistoricalOptionsData, TiingoData options = HistoricalOptionsData("data/processed/options.csv") stocks = TiingoData("data/processed/stocks.csv") ``` The `all` subcommand automatically aligns stock and option dates so the backtester's `np.array_equal` assertion passes. ## Directory Structure - `raw/` — Cached parquet downloads (gitignored) - `processed/` — Converted CSV output ready for the backtester (gitignored) ================================================ FILE: data/convert_optionsdx.py ================================================ #!/usr/bin/env python3 """Convert OptionsDX wide-format CSV to backtester long-format CSV. OptionsDX provides one row per strike/date/expiry with both call and put data in wide format. The backtester expects one row per contract in long format. Usage: python data/convert_optionsdx.py data/raw/spx_eod.csv --output data/processed/spx_options.csv """ import argparse import sys import pandas as pd # OptionsDX columns we need (they have trailing spaces, stripped on read) CALL_COLS = { "C_BID": "bid", "C_ASK": "ask", "C_LAST": "last", "C_VOLUME": "volume", "C_IV": "impliedvol", "C_DELTA": "delta", "C_GAMMA": "gamma", "C_THETA": "theta", "C_VEGA": "vega", } PUT_COLS = { "P_BID": "bid", "P_ASK": "ask", "P_LAST": "last", "P_VOLUME": "volume", "P_IV": "impliedvol", "P_DELTA": "delta", "P_GAMMA": "gamma", "P_THETA": "theta", "P_VEGA": "vega", } OUTPUT_COLUMNS = [ "underlying", "underlying_last", "optionroot", "type", "expiration", "quotedate", "strike", "last", "bid", "ask", "volume", "openinterest", "impliedvol", "delta", "gamma", "theta", "vega", "optionalias", ] def make_optionroot(expire_dates, option_type, strikes): """Generate OCC-format option root symbols vectorized. Format: SPX{YYMMDD}{C|P}{strike*1000:08d} Example: SPX170317C00300000 """ date_str = expire_dates.dt.strftime("%y%m%d") type_char = "C" if option_type == "call" else "P" strike_str = (strikes * 1000).astype(int).astype(str).str.zfill(8) return "SPX" + date_str + type_char + strike_str def convert(input_path, output_path): df = pd.read_csv(input_path, parse_dates=["QUOTE_DATE", "EXPIRE_DATE"]) # Strip whitespace from column names (OptionsDX CSVs have trailing spaces) df.columns = df.columns.str.strip() shared = { "underlying": "SPX", "underlying_last": df["UNDERLYING_LAST"], "expiration": df["EXPIRE_DATE"], "quotedate": df["QUOTE_DATE"], "strike": df["STRIKE"], "openinterest": 0, } # Build call rows calls = pd.DataFrame(shared) calls["type"] = "call" for src, dst in CALL_COLS.items(): calls[dst] = df[src].values calls["optionroot"] = make_optionroot(df["EXPIRE_DATE"], "call", df["STRIKE"]) calls["optionalias"] = calls["optionroot"] # Build put rows puts = pd.DataFrame(shared) puts["type"] = "put" for src, dst in PUT_COLS.items(): puts[dst] = df[src].values puts["optionroot"] = make_optionroot(df["EXPIRE_DATE"], "put", df["STRIKE"]) puts["optionalias"] = puts["optionroot"] result = pd.concat([calls, puts], ignore_index=True) result = result[OUTPUT_COLUMNS] result = result.sort_values(["quotedate", "expiration", "strike", "type"]) result.to_csv(output_path, index=False) print(f"Wrote {len(result)} rows to {output_path}") def main(): parser = argparse.ArgumentParser( description="Convert OptionsDX wide CSV to backtester long CSV" ) parser.add_argument("input", help="Path to OptionsDX CSV file") parser.add_argument( "--output", default="data/processed/spx_options.csv", help="Output path (default: data/processed/spx_options.csv)", ) args = parser.parse_args() convert(args.input, args.output) if __name__ == "__main__": main() ================================================ FILE: data/fetch_data.py ================================================ #!/usr/bin/env python3 """Unified data fetch script for the options backtester. Downloads stock and options data, converts to backtester CSV formats, and aligns dates between datasets. Download priority (for each symbol): 1. Self-hosted GitHub Release (lambdaclass/options_backtester data-v1) 2. philippdubach/options-data CDN — 104 symbols 3. philippdubach/options-dataset-hist — SPY/IWM/QQQ underlying prices 4. yfinance (last resort, stocks only) Usage: python data/fetch_data.py all --symbols SPY --start 2020-01-01 --end 2023-01-01 python data/fetch_data.py stocks --symbols SPY --start 2020-01-01 --end 2023-01-01 python data/fetch_data.py options --symbols SPY --start 2020-01-01 --end 2023-01-01 python data/fetch_data.py all --symbols SPY --start 2020-01-01 --end 2023-01-01 --update """ import argparse import shutil import sys from pathlib import Path from urllib.request import Request, urlopen import pandas as pd BASE_DIR = Path(__file__).resolve().parent RAW_DIR = BASE_DIR / "raw" PROCESSED_DIR = BASE_DIR / "processed" # Self-hosted data on GitHub Releases (primary source) RELEASE_URL = "https://github.com/lambdaclass/options_backtester/releases/download/data-v1" # philippdubach/options-data — 104 symbols, options + underlying (underlying empty for some ETFs) OPTIONS_DATA_URL = "https://static.philippdubach.com/data/options" # philippdubach/options-dataset-hist — SPY/IWM/QQQ, proper underlying_prices via GitHub LFS HIST_REPO_RAW = "https://github.com/philippdubach/options-dataset-hist/raw/main/data" HIST_SYMBOLS = {"SPY", "IWM", "QQQ"} # --------------------------------------------------------------------------- # Download helpers # --------------------------------------------------------------------------- def _download(url, dest, force=False): """Download url to dest. Returns dest on success, None on failure.""" dest = Path(dest) dest.parent.mkdir(parents=True, exist_ok=True) if dest.exists() and not force: print(f" Using cached {dest}") return dest print(f" Downloading {url} ...") try: req = Request(url, headers={"User-Agent": "options-backtester/1.0"}) with urlopen(req) as resp, open(dest, "wb") as f: shutil.copyfileobj(resp, f) print(f" Saved to {dest}") except Exception as e: print(f" Error: {e}", file=sys.stderr) if dest.exists(): dest.unlink() return None return dest def download_options_parquet(symbol, force=False): """Download options parquet. Priority: GitHub Release > options-data CDN.""" sym = symbol.upper() # 1. Self-hosted GitHub Release dest = RAW_DIR / "release" / f"{sym}_options.parquet" url = f"{RELEASE_URL}/{sym}_options.parquet" result = _download(url, dest, force) if result is not None: return result # 2. options-data CDN dest = RAW_DIR / "options-data" / sym / "options.parquet" url = f"{OPTIONS_DATA_URL}/{sym.lower()}/options.parquet" return _download(url, dest, force) def download_underlying(symbol, force=False): """Download underlying prices. Priority: GitHub Release > options-dataset-hist > options-data > None (caller falls back to yfinance). """ sym = symbol.upper() # 1. Self-hosted GitHub Release dest = RAW_DIR / "release" / f"{sym}_underlying.parquet" url = f"{RELEASE_URL}/{sym}_underlying.parquet" result = _download(url, dest, force) if result is not None: df = pd.read_parquet(result) if not df.empty: return result print(f" Warning: release underlying empty for {sym}") # 2. options-dataset-hist has proper underlying for SPY/IWM/QQQ if sym in HIST_SYMBOLS: dest = RAW_DIR / "options-dataset-hist" / sym / "underlying_prices.parquet" url = f"{HIST_REPO_RAW}/parquet_{sym.lower()}/underlying_prices.parquet" result = _download(url, dest, force) if result is not None: df = pd.read_parquet(result) if not df.empty: return result print(f" Warning: options-dataset-hist underlying empty for {sym}") # 3. options-data underlying dest = RAW_DIR / "options-data" / sym / "underlying.parquet" url = f"{OPTIONS_DATA_URL}/{sym.lower()}/underlying.parquet" result = _download(url, dest, force) if result is not None: df = pd.read_parquet(result) if not df.empty: return result print(f" Warning: options-data underlying empty for {sym}") return None # --------------------------------------------------------------------------- # Underlying price reading # --------------------------------------------------------------------------- def read_underlying_prices(symbol, und_path, start, end): """Read underlying parquet and return (date, close) DataFrame for joining.""" und = pd.read_parquet(und_path) und["date"] = pd.to_datetime(und["date"]) und = und[(und["date"] >= start) & (und["date"] <= end)] if und.empty: return None return und[["date", "close"]].rename(columns={"close": "underlying_last"}) def underlying_to_tiingo(symbol, und_path, start, end): """Convert an underlying.parquet to Tiingo-format DataFrame.""" und = pd.read_parquet(und_path) und["date"] = pd.to_datetime(und["date"]) und = und[(und["date"] >= start) & (und["date"] <= end)] if und.empty: return pd.DataFrame() ratio = und["adjusted_close"] / und["close"] return pd.DataFrame({ "symbol": symbol, "date": und["date"].values, "close": und["close"].values, "high": und["high"].values, "low": und["low"].values, "open": und["open"].values, "volume": und["volume"].values, "adjClose": und["adjusted_close"].values, "adjHigh": (und["high"] * ratio).values, "adjLow": (und["low"] * ratio).values, "adjOpen": (und["open"] * ratio).values, "adjVolume": und["volume"].values, "divCash": und["dividend_amount"].values, "splitFactor": und["split_coefficient"].values, }) def fetch_yfinance(symbol, start, end): """Fetch one symbol via yfinance (last resort).""" try: import yfinance as yf except ImportError: print(f" yfinance not installed, cannot fetch {symbol}", file=sys.stderr) return pd.DataFrame() print(f" Last resort: fetching {symbol} from yfinance...") ticker = yf.Ticker(symbol) df = ticker.history(start=str(start.date()), end=str(end.date()), auto_adjust=False) if df.empty: return pd.DataFrame() if df.index.tz is not None: df.index = df.index.tz_localize(None) ratio = df["Adj Close"] / df["Close"] return pd.DataFrame({ "symbol": symbol, "date": df.index, "close": df["Close"].values, "high": df["High"].values, "low": df["Low"].values, "open": df["Open"].values, "volume": df["Volume"].values, "adjClose": df["Adj Close"].values, "adjHigh": (df["High"] * ratio).values, "adjLow": (df["Low"] * ratio).values, "adjOpen": (df["Open"] * ratio).values, "adjVolume": df["Volume"].values, "divCash": 0.0, "splitFactor": 1.0, }) # --------------------------------------------------------------------------- # Options # --------------------------------------------------------------------------- def fetch_options(symbols, start, end, output, force=False): """Download options parquets and convert to backtester CSV format.""" frames = [] for symbol in symbols: sym = symbol.upper() print(f"Fetching options for {sym}...") opt_path = download_options_parquet(sym, force) if opt_path is None: print(f" Skipping {sym} options (download failed)", file=sys.stderr) continue opts = pd.read_parquet(opt_path) opts["date"] = pd.to_datetime(opts["date"]) opts = opts[(opts["date"] >= start) & (opts["date"] <= end)] if opts.empty: print(f" No options data for {sym} in [{start}, {end}]") continue # Get underlying close prices for underlying_last und_path = download_underlying(sym, force) und_prices = None if und_path is not None: und_prices = read_underlying_prices(sym, und_path, start, end) if und_prices is None: yf_df = fetch_yfinance(sym, start, end) if not yf_df.empty: und_prices = pd.DataFrame({ "date": pd.to_datetime(yf_df["date"]), "underlying_last": yf_df["close"].values, }) if und_prices is not None: opts = opts.merge(und_prices, on="date", how="left") else: opts["underlying_last"] = float("nan") # Last price: use column if present, else mid if "last" in opts.columns: opts["_last"] = opts["last"].fillna((opts["bid"] + opts["ask"]) / 2) else: opts["_last"] = (opts["bid"] + opts["ask"]) / 2 out = pd.DataFrame({ "underlying": sym, "underlying_last": opts["underlying_last"].values, "optionroot": opts["contract_id"].values, "type": opts["type"].values, "expiration": pd.to_datetime(opts["expiration"]).values, "quotedate": opts["date"].values, "strike": opts["strike"].values, "last": opts["_last"].values, "bid": opts["bid"].values, "ask": opts["ask"].values, "volume": opts["volume"].values, "openinterest": opts["open_interest"].values, "impliedvol": opts["implied_volatility"].values, "delta": opts["delta"].values, "gamma": opts["gamma"].values, "theta": opts["theta"].values, "vega": opts["vega"].values, "optionalias": opts["contract_id"].values, }) frames.append(out) print(f" {len(out)} option rows for {sym}") if not frames: print("No options data fetched.", file=sys.stderr) return None result = pd.concat(frames, ignore_index=True) result = result.sort_values(["quotedate", "underlying", "expiration", "strike", "type"]) PROCESSED_DIR.mkdir(parents=True, exist_ok=True) result.to_csv(output, index=False) print(f"Wrote {len(result)} option rows to {output}") return result # --------------------------------------------------------------------------- # Stocks # --------------------------------------------------------------------------- def fetch_stocks(symbols, start, end, output, force=False): """Download stock data. Priority: options-dataset-hist > options-data > yfinance.""" frames = [] for symbol in symbols: sym = symbol.upper() print(f"Fetching stocks for {sym}...") und_path = download_underlying(sym, force) if und_path is not None: df = underlying_to_tiingo(sym, und_path, start, end) if not df.empty: source = "options-dataset-hist" if "options-dataset-hist" in str(und_path) else "options-data" frames.append(df) print(f" {len(df)} stock rows for {sym} (from {source})") continue df = fetch_yfinance(sym, start, end) if not df.empty: frames.append(df) print(f" {len(df)} stock rows for {sym} (from yfinance)") else: print(f" No stock data for {sym}", file=sys.stderr) if not frames: print("No stock data fetched.", file=sys.stderr) return None result = pd.concat(frames, ignore_index=True) PROCESSED_DIR.mkdir(parents=True, exist_ok=True) result.to_csv(output, index=False) print(f"Wrote {len(result)} stock rows to {output}") return result # --------------------------------------------------------------------------- # Date alignment # --------------------------------------------------------------------------- def align_dates(stocks_path, options_path): """Align stock and option dates to their intersection.""" stocks = pd.read_csv(stocks_path, parse_dates=["date"]) options = pd.read_csv(options_path, parse_dates=["quotedate", "expiration"]) stock_dates = set(stocks["date"].dt.normalize()) option_dates = set(options["quotedate"].dt.normalize()) shared = stock_dates & option_dates if not shared: print("Warning: no overlapping dates between stocks and options!", file=sys.stderr) return stocks_filtered = stocks[stocks["date"].dt.normalize().isin(shared)] options_filtered = options[options["quotedate"].dt.normalize().isin(shared)] stocks_filtered.to_csv(stocks_path, index=False) options_filtered.to_csv(options_path, index=False) dropped_stock = len(stocks) - len(stocks_filtered) dropped_opt = len(options) - len(options_filtered) print(f"Aligned dates: {len(shared)} shared trading days") if dropped_stock: print(f" Dropped {dropped_stock} stock rows without matching option dates") if dropped_opt: print(f" Dropped {dropped_opt} option rows without matching stock dates") # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def main(): parser = argparse.ArgumentParser( description="Fetch stock and options data for the backtester" ) parser.add_argument( "command", nargs="?", default="all", choices=["all", "stocks", "options"], help="What to fetch (default: all)", ) parser.add_argument( "--symbols", nargs="+", required=True, help="Ticker symbols (e.g. SPY IWM QQQ AAPL)", ) parser.add_argument("--start", required=True, help="Start date (YYYY-MM-DD)") parser.add_argument("--end", required=True, help="End date (YYYY-MM-DD)") parser.add_argument( "--stocks-output", default="data/processed/stocks.csv", help="Stock CSV output path", ) parser.add_argument( "--options-output", default="data/processed/options.csv", help="Options CSV output path", ) parser.add_argument( "--update", action="store_true", help="Re-download parquets to get latest data", ) args = parser.parse_args() start = pd.Timestamp(args.start) end = pd.Timestamp(args.end) force = args.update if args.command in ("all", "options"): fetch_options(args.symbols, start, end, args.options_output, force) if args.command in ("all", "stocks"): fetch_stocks(args.symbols, start, end, args.stocks_output, force) if args.command == "all": if Path(args.stocks_output).exists() and Path(args.options_output).exists(): print("\nAligning dates...") align_dates(args.stocks_output, args.options_output) print("\nDone!") if __name__ == "__main__": main() ================================================ FILE: data/fetch_signals.py ================================================ #!/usr/bin/env python3 """Download macro signal data from FRED for use in backtest signal filters. Downloads: - GDP (quarterly) — for Buffett Indicator proxy - VIX (daily) — CBOE Volatility Index - High Yield Spread (daily) — credit stress indicator - 10Y-2Y Yield Curve (daily) — recession predictor - Nonfinancial Corporate Equity Market Value (quarterly) — for Tobin's Q - Nonfinancial Corporate Net Worth (quarterly) — for Tobin's Q - Dollar Index (daily) — broad trade-weighted USD Outputs: data/processed/signals.csv — daily signal data, forward-filled from quarterly """ import io import urllib.request import pandas as pd FRED_SERIES = { 'gdp': 'GDP', 'vix': 'VIXCLS', 'hy_spread': 'BAMLH0A0HYM2', 'yield_curve_10y2y': 'T10Y2Y', 'nfc_equity_mv': 'NCBEILQ027S', 'nfc_net_worth': 'NCBCMDPMVCE', 'dollar_index': 'DTWEXBGS', } START = '2007-01-01' END = '2025-12-31' def fetch_fred(series_id: str) -> pd.Series: url = (f'https://fred.stlouisfed.org/graph/fredgraph.csv' f'?id={series_id}&cosd={START}&coed={END}') req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) resp = urllib.request.urlopen(req, timeout=30) data = resp.read().decode() df = pd.read_csv(io.StringIO(data), parse_dates=['observation_date'], index_col='observation_date') col = df.columns[0] s = pd.to_numeric(df[col], errors='coerce') s.index.name = 'date' return s.dropna() def main(): signals = {} for name, sid in FRED_SERIES.items(): print(f'Fetching {name} ({sid})...', end=' ', flush=True) try: s = fetch_fred(sid) signals[name] = s print(f'{len(s)} obs, {s.index[0].date()} to {s.index[-1].date()}') except Exception as e: print(f'FAILED: {e}') if not signals: print('No data fetched.') return # Build daily DataFrame all_dates = sorted(set().union(*(s.index for s in signals.values()))) daily = pd.DataFrame(index=pd.DatetimeIndex(all_dates, name='date')) for name, s in signals.items(): daily[name] = s.reindex(daily.index) # Forward-fill quarterly data to daily daily = daily.ffill() # Compute derived signals if 'gdp' in daily.columns: # Buffett Indicator proxy: we don't have total market cap, but # nfc_equity_mv is corporate equity market value (in millions) # GDP is in billions. Scale NFC equity to billions to match. if 'nfc_equity_mv' in daily.columns: daily['buffett_indicator'] = daily['nfc_equity_mv'] / (daily['gdp'] * 1000) * 100 print(f'Computed buffett_indicator (nfc_equity_mv / GDP)') if 'nfc_equity_mv' in daily.columns and 'nfc_net_worth' in daily.columns: # Tobin's Q proxy: market value / net worth # nfc_net_worth is in weird units (ratio), use nfc_equity_mv levels # Actually NCBCMDPMVCE is "market value / cost" already daily['tobin_q'] = daily['nfc_net_worth'] print(f'Computed tobin_q (NCBCMDPMVCE is already MV/replacement cost)') daily = daily.dropna(how='all') out = 'data/processed/signals.csv' daily.to_csv(out) print(f'\nSaved {len(daily)} rows to {out}') print(f'Columns: {list(daily.columns)}') print(f'Date range: {daily.index[0].date()} to {daily.index[-1].date()}') print(daily.describe().round(2)) if __name__ == '__main__': main() ================================================ FILE: flake.nix ================================================ { description = "Options backtester dev environment"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; rust-overlay.url = "github:oxalica/rust-overlay"; }; outputs = { self, nixpkgs, rust-overlay }: let supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; forAllSystems = nixpkgs.lib.genAttrs supportedSystems; in { devShells = forAllSystems (system: let pkgs = import nixpkgs { inherit system; overlays = [ rust-overlay.overlays.default ]; }; python = pkgs.python312; pythonPkgs = python.pkgs; rustToolchain = pkgs.rust-bin.stable.latest.default.override { extensions = [ "rust-src" "rust-analyzer" ]; }; in { default = pkgs.mkShell { packages = [ # Rust rustToolchain pkgs.maturin pkgs.cargo-nextest (python.withPackages (ps: [ # Runtime ps.pandas ps.numpy ps.altair ps.pyprind ps.seaborn ps.matplotlib ps.pyarrow ps.polars # Notebooks ps.jupyter ps.nbconvert ps.ipykernel # Testing ps.pytest ps.hypothesis ps.pytest-benchmark ps.mypy ps.pandas-stubs ps.ruff # Dev tools ps.yapf # Data fetching (optional, for data/ scripts) ps.yfinance ])) ]; shellHook = '' export PYO3_PYTHON=${python}/bin/python export PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 # Build Rust extension and symlink for Python import if [ -f rust/ob_python/Cargo.toml ]; then if [ ! -f rust/target/release/lib_ob_rust.dylib ] && [ ! -f rust/target/release/lib_ob_rust.so ]; then echo "Building Rust extension (first time only)..." cargo build --manifest-path rust/ob_python/Cargo.toml --release 2>&1 | tail -1 fi # Python needs _ob_rust.so, Rust produces lib_ob_rust.dylib/.so if [ -f rust/target/release/lib_ob_rust.dylib ] && [ ! -f _ob_rust.so ]; then ln -sf rust/target/release/lib_ob_rust.dylib _ob_rust.so elif [ -f rust/target/release/lib_ob_rust.so ] && [ ! -f _ob_rust.so ]; then ln -sf rust/target/release/lib_ob_rust.so _ob_rust.so fi fi ''; }; }); }; } ================================================ FILE: options_portfolio_backtester/__init__.py ================================================ """options_portfolio_backtester — the open-source options backtesting framework.""" # Core types from options_portfolio_backtester.core.types import ( Direction, OptionType, Type, Order, Signal, Fill, Greeks, OptionContract, StockAllocation, Stock, get_order, ) # Data from options_portfolio_backtester.data.schema import Schema, Field, Filter from options_portfolio_backtester.data.providers import ( CsvOptionsProvider, CsvStocksProvider, TiingoData, HistoricalOptionsData, ) # Strategy from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.strategy.presets import Strangle # Execution from options_portfolio_backtester.execution.cost_model import ( NoCosts, PerContractCommission, TieredCommission, SpreadSlippage, ) from options_portfolio_backtester.execution.fill_model import MarketAtBidAsk, MidPrice, VolumeAwareFill from options_portfolio_backtester.execution.sizer import ( CapitalBased, FixedQuantity, FixedDollar, PercentOfPortfolio, ) from options_portfolio_backtester.execution.signal_selector import ( FirstMatch, NearestDelta, MaxOpenInterest, ) # Portfolio from options_portfolio_backtester.portfolio.portfolio import Portfolio from options_portfolio_backtester.portfolio.position import OptionPosition from options_portfolio_backtester.portfolio.greeks import aggregate_greeks from options_portfolio_backtester.portfolio.risk import RiskManager, MaxDelta, MaxVega, MaxDrawdown # Engine from options_portfolio_backtester.engine.engine import BacktestEngine from options_portfolio_backtester.engine.clock import TradingClock # Analytics from options_portfolio_backtester.analytics.stats import BacktestStats, PeriodStats, LookbackReturns from options_portfolio_backtester.analytics.trade_log import TradeLog from options_portfolio_backtester.analytics.tearsheet import TearsheetReport, build_tearsheet from options_portfolio_backtester.analytics.summary import summary __all__ = [ # Core types "Direction", "OptionType", "Type", "Order", "Signal", "Fill", "Greeks", "OptionContract", "StockAllocation", "Stock", "get_order", # Data "Schema", "Field", "Filter", "CsvOptionsProvider", "CsvStocksProvider", "TiingoData", "HistoricalOptionsData", # Strategy "Strategy", "StrategyLeg", "Strangle", # Execution "NoCosts", "PerContractCommission", "TieredCommission", "SpreadSlippage", "MarketAtBidAsk", "MidPrice", "VolumeAwareFill", "CapitalBased", "FixedQuantity", "FixedDollar", "PercentOfPortfolio", "FirstMatch", "NearestDelta", "MaxOpenInterest", # Portfolio "Portfolio", "OptionPosition", "aggregate_greeks", "RiskManager", "MaxDelta", "MaxVega", "MaxDrawdown", # Engine "BacktestEngine", "TradingClock", # Analytics "BacktestStats", "PeriodStats", "LookbackReturns", "TradeLog", "TearsheetReport", "build_tearsheet", "summary", ] ================================================ FILE: options_portfolio_backtester/analytics/__init__.py ================================================ ================================================ FILE: options_portfolio_backtester/analytics/charts.py ================================================ """Charts — Altair charts + matplotlib additions.""" from __future__ import annotations import altair as alt import pandas as pd def returns_chart(report: pd.DataFrame) -> alt.VConcatChart: # Time interval selector time_interval = alt.selection_interval(encodings=['x']) # Area plot areas = alt.Chart().mark_area(opacity=0.7).encode(x='index:T', y=alt.Y('accumulated return:Q', axis=alt.Axis(format='%'))) # Nearest point selector nearest = alt.selection_point(nearest=True, on='mouseover', fields=['index']) points = areas.mark_point().encode(opacity=alt.condition(nearest, alt.value(1), alt.value(0))) # Transparent date selector selectors = alt.Chart().mark_point().encode( x='index:T', opacity=alt.value(0), ).add_params(nearest) text = areas.mark_text( align='left', dx=5, dy=-5).encode(text=alt.condition(nearest, 'accumulated return:Q', alt.value(' '), format='.2%')) layered = alt.layer(selectors, points, text, areas.encode( alt.X('index:T', axis=alt.Axis(title='date'), scale=alt.Scale(domain=time_interval))), width=700, height=350, title='Returns over time') lower = areas.properties(width=700, height=70).add_params(time_interval) return alt.vconcat(layered, lower, data=report.reset_index()) def returns_histogram(report: pd.DataFrame) -> alt.Chart: bar = alt.Chart(report).mark_bar().encode(x=alt.X('% change:Q', bin=alt.BinParams(maxbins=100), axis=alt.Axis(format='%')), y='count():Q') return bar def monthly_returns_heatmap(report: pd.DataFrame) -> alt.Chart: resample = report.resample('ME')['total capital'].last() monthly_returns = resample.pct_change().reset_index() monthly_returns.loc[monthly_returns.index[0], 'total capital'] = resample.iloc[0] / report.iloc[0]['total capital'] - 1 monthly_returns.columns = ['date', 'total capital'] chart = alt.Chart(monthly_returns).mark_rect().encode( alt.X('year(date):O', title='Year'), alt.Y('month(date):O', title='Month'), alt.Color('mean(total capital)', title='Return', scale=alt.Scale(scheme='redyellowgreen')), alt.Tooltip('mean(total capital)', format='.2f')).properties(title='Monthly Returns') return chart def weights_chart(balance: pd.DataFrame, figsize: tuple[float, float] = (12, 6)): """Stacked area chart of portfolio weights over time. Expects a balance DataFrame with ``{symbol} qty`` columns and a ``total capital`` column (as produced by ``AlgoPipelineBacktester``). Returns ``(fig, ax)`` from matplotlib. """ import matplotlib.pyplot as plt qty_cols = [c for c in balance.columns if c.endswith(" qty")] if not qty_cols: fig, ax = plt.subplots(figsize=figsize) ax.set_title("Portfolio Weights (no positions found)") return fig, ax symbols = [c.replace(" qty", "") for c in qty_cols] total = balance["total capital"] # Compute weights: qty * price / total_capital # We don't have price columns directly, but stocks capital is available. # Reconstruct per-symbol value: qty * (total - cash) is aggregate, # so we estimate from qty shares of total stock value. weights = pd.DataFrame(index=balance.index) for sym, col in zip(symbols, qty_cols): weights[sym] = balance[col].fillna(0) # Normalize to weights (proportional share of total qty-weighted value) row_sums = weights.abs().sum(axis=1) row_sums = row_sums.replace(0, 1) # avoid division by zero # If we have cash and total capital, use stock fraction if "cash" in balance.columns: stock_fraction = 1.0 - balance["cash"] / total.replace(0, 1) for sym in symbols: weights[sym] = (weights[sym] / row_sums) * stock_fraction else: weights = weights.div(row_sums, axis=0) fig, ax = plt.subplots(figsize=figsize) ax.stackplot(weights.index, *[weights[s] for s in symbols], labels=symbols, alpha=0.8) ax.set_title("Portfolio Weights Over Time") ax.set_ylabel("Weight") ax.set_xlabel("Date") ax.legend(loc="upper left", fontsize="small") ax.set_ylim(0, 1) fig.tight_layout() return fig, ax __all__ = ["returns_chart", "returns_histogram", "monthly_returns_heatmap", "weights_chart"] ================================================ FILE: options_portfolio_backtester/analytics/optimization.py ================================================ """Walk-forward optimization and parameter grid sweep.""" from __future__ import annotations import itertools from concurrent.futures import ProcessPoolExecutor, as_completed from dataclasses import dataclass from typing import Any, Callable import pandas as pd import numpy as np from options_portfolio_backtester.analytics.stats import BacktestStats @dataclass class OptimizationResult: """Result of a single parameter combination.""" params: dict[str, Any] stats: BacktestStats balance: pd.DataFrame def grid_sweep( run_fn: Callable[..., tuple[BacktestStats, pd.DataFrame]], param_grid: dict[str, list[Any]], max_workers: int | None = None, ) -> list[OptimizationResult]: """Run a parameter grid sweep using parallel execution. Args: run_fn: Function that takes **params and returns (BacktestStats, balance). param_grid: Dict mapping parameter names to lists of values. max_workers: Number of parallel workers (None = CPU count). Returns: List of OptimizationResult, sorted by Sharpe ratio descending. """ keys = list(param_grid.keys()) combos = list(itertools.product(*param_grid.values())) results: list[OptimizationResult] = [] with ProcessPoolExecutor(max_workers=max_workers) as executor: futures = {} for combo in combos: params = dict(zip(keys, combo)) future = executor.submit(run_fn, **params) futures[future] = params for future in as_completed(futures): params = futures[future] try: stats, balance = future.result() results.append(OptimizationResult( params=params, stats=stats, balance=balance, )) except Exception: continue results.sort(key=lambda r: r.stats.sharpe_ratio, reverse=True) return results def walk_forward( run_fn: Callable[[pd.Timestamp, pd.Timestamp], tuple[BacktestStats, pd.DataFrame]], dates: pd.DatetimeIndex, in_sample_pct: float = 0.70, n_splits: int = 5, ) -> list[tuple[OptimizationResult, OptimizationResult]]: """Walk-forward analysis with rolling in-sample/out-of-sample splits. Args: run_fn: Function that takes (start_date, end_date) and returns (stats, balance). dates: Full date range. in_sample_pct: Fraction of each window used for in-sample. n_splits: Number of walk-forward windows. Returns: List of (in_sample_result, out_of_sample_result) tuples. """ total = len(dates) window_size = total // n_splits results = [] for i in range(n_splits): start_idx = i * window_size end_idx = min(start_idx + window_size, total) split_idx = start_idx + int((end_idx - start_idx) * in_sample_pct) is_start = dates[start_idx] is_end = dates[split_idx - 1] oos_start = dates[split_idx] oos_end = dates[end_idx - 1] try: is_stats, is_balance = run_fn(is_start, is_end) oos_stats, oos_balance = run_fn(oos_start, oos_end) results.append(( OptimizationResult(params={"split": i, "type": "in_sample"}, stats=is_stats, balance=is_balance), OptimizationResult(params={"split": i, "type": "out_of_sample"}, stats=oos_stats, balance=oos_balance), )) except Exception: continue return results def rust_grid_sweep( options_data, stocks_data, base_config: dict, schema_mapping: dict, param_overrides: list[dict], n_workers: int | None = None, ) -> list[dict]: """Run a parallel grid sweep using the Rust backtest engine. Each dict in param_overrides can contain: - "label": str - "profit_pct": Optional[float] - "loss_pct": Optional[float] - "rebalance_dates": Optional[list[str]] - "leg_entry_filters": Optional[list[Optional[str]]] - "leg_exit_filters": Optional[list[Optional[str]]] Returns list of result dicts sorted by sharpe_ratio descending. """ from options_portfolio_backtester._ob_rust import parallel_sweep results = parallel_sweep( options_data, stocks_data, base_config, schema_mapping, param_overrides, n_workers, ) results.sort(key=lambda r: r.get("sharpe_ratio", 0.0), reverse=True) return results ================================================ FILE: options_portfolio_backtester/analytics/stats.py ================================================ """BacktestStats — comprehensive analytics matching and exceeding bt/ffn. Provides: - Trade stats: profit factor, win rate, largest win/loss - Return stats: total, annualized, Sharpe, Sortino, Calmar - Risk stats: max drawdown, drawdown duration, volatility, tail ratio - Period stats: monthly/yearly Sharpe, Sortino, mean, vol, skew, kurtosis - Extreme analysis: best/worst day, month, year - Lookback returns: MTD, 3M, 6M, YTD, 1Y, 3Y, 5Y, 10Y - Portfolio metrics: turnover, Herfindahl concentration index """ from __future__ import annotations from dataclasses import dataclass, field from typing import Any import numpy as np import pandas as pd from options_portfolio_backtester._ob_rust import compute_full_stats @dataclass class PeriodStats: """Stats for a specific return frequency (daily, monthly, yearly).""" mean: float = 0.0 vol: float = 0.0 sharpe: float = 0.0 sortino: float = 0.0 skew: float = 0.0 kurtosis: float = 0.0 best: float = 0.0 worst: float = 0.0 @dataclass class LookbackReturns: """Trailing period returns as of the end date.""" mtd: float | None = None three_month: float | None = None six_month: float | None = None ytd: float | None = None one_year: float | None = None three_year: float | None = None five_year: float | None = None ten_year: float | None = None @dataclass class BacktestStats: """Comprehensive backtest statistics.""" # Trade stats total_trades: int = 0 wins: int = 0 losses: int = 0 win_pct: float = 0.0 profit_factor: float = 0.0 largest_win: float = 0.0 largest_loss: float = 0.0 avg_win: float = 0.0 avg_loss: float = 0.0 avg_trade: float = 0.0 # Return stats total_return: float = 0.0 annualized_return: float = 0.0 sharpe_ratio: float = 0.0 sortino_ratio: float = 0.0 calmar_ratio: float = 0.0 # Risk stats max_drawdown: float = 0.0 max_drawdown_duration: int = 0 avg_drawdown: float = 0.0 avg_drawdown_duration: int = 0 volatility: float = 0.0 tail_ratio: float = 0.0 # Period stats daily: PeriodStats = field(default_factory=PeriodStats) monthly: PeriodStats = field(default_factory=PeriodStats) yearly: PeriodStats = field(default_factory=PeriodStats) # Lookback lookback: LookbackReturns = field(default_factory=LookbackReturns) # Portfolio metrics turnover: float = 0.0 herfindahl: float = 0.0 @classmethod def from_balance_range( cls, balance: pd.DataFrame, start: str | pd.Timestamp | None = None, end: str | pd.Timestamp | None = None, **kwargs, ) -> "BacktestStats": """Slice balance to [start, end] and recompute all stats.""" if balance.empty: return cls() b = balance.copy() if start: b = b.loc[pd.Timestamp(start):] if end: b = b.loc[:pd.Timestamp(end)] if b.empty: return cls() b["% change"] = b["total capital"].pct_change() return cls.from_balance(b, **kwargs) @classmethod def from_balance( cls, balance: pd.DataFrame, trade_pnls: np.ndarray | None = None, risk_free_rate: float = 0.0, ) -> "BacktestStats": """Compute stats from a balance DataFrame and optional trade P&Ls.""" if balance.empty: return cls() total_capital = balance["total capital"].values.astype(np.float64) timestamps_ns = balance.index.values.astype("datetime64[ns]").view("int64").astype(np.int64).tolist() pnls = trade_pnls.astype(np.float64).tolist() if trade_pnls is not None else [] # Build stock weight matrix stock_cols = [c for c in balance.columns if f"{c} qty" in balance.columns] if stock_cols: total = balance["total capital"].values with np.errstate(divide="ignore", invalid="ignore"): weights = balance[stock_cols].values / total[:, None] weights = np.nan_to_num(weights, 0.0).astype(np.float64) flat_weights = weights.ravel().tolist() n_stocks = len(stock_cols) else: flat_weights = [] n_stocks = 0 d = compute_full_stats( total_capital.tolist(), timestamps_ns, pnls, flat_weights, n_stocks, risk_free_rate, ) stats = cls() # Scalars for attr in ( "total_trades", "wins", "losses", "win_pct", "profit_factor", "largest_win", "largest_loss", "avg_win", "avg_loss", "avg_trade", "total_return", "annualized_return", "sharpe_ratio", "sortino_ratio", "calmar_ratio", "max_drawdown", "max_drawdown_duration", "avg_drawdown", "avg_drawdown_duration", "volatility", "tail_ratio", "turnover", "herfindahl", ): setattr(stats, attr, d[attr]) # Period stats for period_name in ("daily", "monthly", "yearly"): pd_dict = d[period_name] setattr(stats, period_name, PeriodStats( mean=pd_dict["mean"], vol=pd_dict["vol"], sharpe=pd_dict["sharpe"], sortino=pd_dict["sortino"], skew=pd_dict["skew"], kurtosis=pd_dict["kurtosis"], best=pd_dict["best"], worst=pd_dict["worst"], )) # Lookback lb = d["lookback"] stats.lookback = LookbackReturns( mtd=lb["mtd"], three_month=lb["three_month"], six_month=lb["six_month"], ytd=lb["ytd"], one_year=lb["one_year"], three_year=lb["three_year"], five_year=lb["five_year"], ten_year=lb["ten_year"], ) return stats def to_dataframe(self) -> pd.DataFrame: """Return stats as a styled DataFrame.""" data = { "Total trades": self.total_trades, "Wins": self.wins, "Losses": self.losses, "Win %": self.win_pct, "Profit factor": self.profit_factor, "Largest win": self.largest_win, "Largest loss": self.largest_loss, "Avg win": self.avg_win, "Avg loss": self.avg_loss, "Avg trade": self.avg_trade, "Total return": self.total_return, "Annualized return": self.annualized_return, "Sharpe ratio": self.sharpe_ratio, "Sortino ratio": self.sortino_ratio, "Calmar ratio": self.calmar_ratio, "Max drawdown": self.max_drawdown, "Max DD duration (days)": self.max_drawdown_duration, "Avg drawdown": self.avg_drawdown, "Avg DD duration (days)": self.avg_drawdown_duration, "Volatility": self.volatility, "Tail ratio": self.tail_ratio, # Daily "Daily mean": self.daily.mean, "Daily vol": self.daily.vol, "Daily Sharpe": self.daily.sharpe, "Daily Sortino": self.daily.sortino, "Daily skew": self.daily.skew, "Daily kurtosis": self.daily.kurtosis, "Best day": self.daily.best, "Worst day": self.daily.worst, # Monthly "Monthly mean": self.monthly.mean, "Monthly vol": self.monthly.vol, "Monthly Sharpe": self.monthly.sharpe, "Monthly Sortino": self.monthly.sortino, "Monthly skew": self.monthly.skew, "Monthly kurtosis": self.monthly.kurtosis, "Best month": self.monthly.best, "Worst month": self.monthly.worst, # Yearly "Yearly mean": self.yearly.mean, "Yearly vol": self.yearly.vol, "Yearly Sharpe": self.yearly.sharpe, "Yearly Sortino": self.yearly.sortino, "Best year": self.yearly.best, "Worst year": self.yearly.worst, # Portfolio "Turnover": self.turnover, "Herfindahl index": self.herfindahl, } # Add lookback returns (skip None values) lb = self.lookback for label, val in [ ("MTD", lb.mtd), ("3M return", lb.three_month), ("6M return", lb.six_month), ("YTD", lb.ytd), ("1Y return", lb.one_year), ("3Y return", lb.three_year), ("5Y return", lb.five_year), ("10Y return", lb.ten_year), ]: if val is not None: data[label] = val return pd.DataFrame( list(data.values()), index=list(data.keys()), columns=["Value"] ) def summary(self) -> str: """Return a formatted text summary.""" lines = [ f"Total Return: {self.total_return:>10.2%}", f"Annualized Return: {self.annualized_return:>10.2%}", f"Sharpe Ratio: {self.sharpe_ratio:>10.2f}", f"Sortino Ratio: {self.sortino_ratio:>10.2f}", f"Max Drawdown: {self.max_drawdown:>10.2%}", f"Max DD Duration: {self.max_drawdown_duration:>10d} days", f"Calmar Ratio: {self.calmar_ratio:>10.2f}", f"Profit Factor: {self.profit_factor:>10.2f}", f"Win Rate: {self.win_pct:>10.1f}%", f"Total Trades: {self.total_trades:>10d}", ] if self.monthly.sharpe != 0: lines.append(f"Monthly Sharpe: {self.monthly.sharpe:>10.2f}") if self.monthly.best != 0: lines.append(f"Best Month: {self.monthly.best:>10.2%}") lines.append(f"Worst Month: {self.monthly.worst:>10.2%}") if self.turnover != 0: lines.append(f"Turnover: {self.turnover:>10.2%}") return "\n".join(lines) def lookback_table(self) -> pd.DataFrame: """Lookback returns as a single-row DataFrame.""" lb = self.lookback data = {} for label, val in [ ("MTD", lb.mtd), ("3M", lb.three_month), ("6M", lb.six_month), ("YTD", lb.ytd), ("1Y", lb.one_year), ("3Y", lb.three_year), ("5Y", lb.five_year), ("10Y", lb.ten_year), ]: if val is not None: data[label] = val if not data: return pd.DataFrame() return pd.DataFrame([data]) ================================================ FILE: options_portfolio_backtester/analytics/summary.py ================================================ """Summary statistics for trade logs.""" from __future__ import annotations import numpy as np import pandas as pd from options_portfolio_backtester.core.types import Order def summary(trade_log: pd.DataFrame, balance: pd.DataFrame) -> pd.io.formats.style.Styler: """Returns a table with summary statistics about the trade log""" initial_capital: float = balance['total capital'].iloc[0] trade_log.loc[:, ('totals', 'capital')] = (-trade_log['totals']['cost'] * trade_log['totals']['qty']).cumsum() + initial_capital daily_returns: pd.Series = balance['% change'] * 100 first_leg: str = trade_log.columns.levels[0][0] ## Not sure of a better way to to this, just doing `Order` or `@Order` inside ## the .eval(...) does not seem to work. order_bto = Order.BTO order_sto = Order.STO entry_mask: pd.Series = trade_log[first_leg].eval('(order == @order_bto) | (order == @order_sto)') entries: pd.DataFrame = trade_log.loc[entry_mask] exits: pd.DataFrame = trade_log.loc[~entry_mask] costs: np.ndarray = np.array([]) for contract in entries[first_leg]['contract']: entry = entries.loc[entries[first_leg]['contract'] == contract] exit_ = exits.loc[exits[first_leg]['contract'] == contract] try: # Here we assume we are entering only once per contract (i.e both entry and exit_ have only one row) costs = np.append(costs, (entry['totals']['cost'] * entry['totals']['qty']).values[0] + (exit_['totals']['cost'] * exit_['totals']['qty']).values[0]) except IndexError: continue wins: np.ndarray = costs < 0 losses: np.ndarray = costs >= 0 total_trades: int = len(exits) win_number: int = int(np.sum(wins)) loss_number: int = total_trades - win_number win_pct: float = (win_number / total_trades) * 100 if total_trades > 0 else 0 profit_factor: float = np.sum(wins) / np.sum(losses) if np.sum(losses) > 0 else 0 largest_loss: float = max(0, np.max(costs)) if len(costs) > 0 else 0 avg_profit: float = np.mean(-costs) if len(costs) > 0 else 0 avg_pl: float = np.mean(daily_returns) total_pl: float = (trade_log['totals']['capital'].iloc[-1] / initial_capital) * 100 data = [total_trades, win_number, loss_number, win_pct, largest_loss, profit_factor, avg_profit, avg_pl, total_pl] stats = [ 'Total trades', 'Number of wins', 'Number of losses', 'Win %', 'Largest loss', 'Profit factor', 'Average profit', 'Average P&L %', 'Total P&L %' ] strat = ['Strategy'] summary_df = pd.DataFrame(data, stats, strat) formatters: dict[str, str] = { "Total trades": "{:.0f}", "Number of wins": "{:.0f}", "Number of losses": "{:.0f}", "Win %": "{:.2f}%", "Largest loss": "${:.2f}", "Profit factor": "{:.2f}", "Average profit": "${:.2f}", "Average P&L %": "{:.2f}%", "Total P&L %": "{:.2f}%" } styled = summary_df.style for row_label, fmt in formatters.items(): styled = styled.format(fmt, subset=pd.IndexSlice[row_label, :]) return styled ================================================ FILE: options_portfolio_backtester/analytics/tearsheet.py ================================================ """Simple tearsheet-style report helpers.""" from __future__ import annotations from dataclasses import dataclass from pathlib import Path import numpy as np import pandas as pd from options_portfolio_backtester.analytics.stats import BacktestStats @dataclass class TearsheetReport: """Container for common report artifacts.""" stats: BacktestStats stats_table: pd.DataFrame monthly_returns: pd.DataFrame drawdown_series: pd.Series def to_dict(self) -> dict[str, object]: return { "stats": self.stats, "stats_table": self.stats_table, "monthly_returns": self.monthly_returns, "drawdown_series": self.drawdown_series, } def to_csv(self, directory: str | Path) -> dict[str, Path]: out_dir = Path(directory) out_dir.mkdir(parents=True, exist_ok=True) stats_path = out_dir / "stats_table.csv" monthly_path = out_dir / "monthly_returns.csv" drawdown_path = out_dir / "drawdown_series.csv" self.stats_table.to_csv(stats_path) self.monthly_returns.to_csv(monthly_path) self.drawdown_series.rename("drawdown").to_frame().to_csv(drawdown_path) return { "stats_table": stats_path, "monthly_returns": monthly_path, "drawdown_series": drawdown_path, } def to_markdown(self) -> str: lines = ["# Tearsheet", "", "## Summary", ""] try: lines.extend(self.stats_table.to_markdown().splitlines()) except Exception: lines.extend(self.stats_table.to_string().splitlines()) lines.extend(["", "## Monthly Returns", ""]) if self.monthly_returns.empty: lines.append("_No monthly returns available._") else: try: lines.extend(self.monthly_returns.to_markdown().splitlines()) except Exception: lines.extend(self.monthly_returns.to_string().splitlines()) return "\n".join(lines) def to_html(self) -> str: summary = self.stats_table.to_html(classes="stats-table") monthly = ( self.monthly_returns.to_html(classes="monthly-returns") if not self.monthly_returns.empty else "

No monthly returns available.

" ) return ( "Tearsheet" "

Tearsheet

" "

Summary

" f"{summary}" "

Monthly Returns

" f"{monthly}" "" ) def monthly_return_table(balance: pd.DataFrame) -> pd.DataFrame: if balance.empty or "% change" not in balance.columns: return pd.DataFrame() rets = balance["% change"].dropna() if rets.empty: return pd.DataFrame() monthly = (1.0 + rets).groupby(pd.Grouper(freq="ME")).prod() - 1.0 out = monthly.to_frame(name="return") out["year"] = out.index.year out["month"] = out.index.month return out.pivot(index="year", columns="month", values="return").sort_index() def drawdown_series(balance: pd.DataFrame) -> pd.Series: if balance.empty or "total capital" not in balance.columns: return pd.Series(dtype=float) total = balance["total capital"].dropna() if total.empty: return pd.Series(dtype=float) peak = total.cummax() return (total - peak) / peak def build_tearsheet( balance: pd.DataFrame, trade_pnls=None, risk_free_rate: float = 0.0, ) -> TearsheetReport: trade_arr = None if trade_pnls is None else np.asarray(trade_pnls, dtype=float) stats = BacktestStats.from_balance(balance, trade_pnls=trade_arr, risk_free_rate=risk_free_rate) table = stats.to_dataframe() monthly = monthly_return_table(balance) dd = drawdown_series(balance) return TearsheetReport( stats=stats, stats_table=table, monthly_returns=monthly, drawdown_series=dd, ) ================================================ FILE: options_portfolio_backtester/analytics/trade_log.py ================================================ """Structured trade log — replaces MultiIndex trade log with per-trade P&L.""" from __future__ import annotations from dataclasses import dataclass, field from typing import Any import pandas as pd import numpy as np from options_portfolio_backtester.core.types import Order @dataclass class Trade: """A single round-trip trade (entry + exit).""" contract: str underlying: str option_type: str strike: float entry_date: Any exit_date: Any entry_price: float exit_price: float quantity: int shares_per_contract: int entry_order: Order exit_order: Order entry_commission: float = 0.0 exit_commission: float = 0.0 @property def gross_pnl(self) -> float: """P&L before commissions.""" return (self.exit_price - self.entry_price) * self.quantity * self.shares_per_contract @property def net_pnl(self) -> float: """P&L after commissions.""" return self.gross_pnl - self.entry_commission - self.exit_commission @property def return_pct(self) -> float: """Return as percentage of entry cost.""" entry_cost = abs(self.entry_price * self.quantity * self.shares_per_contract) if entry_cost == 0: return 0.0 return self.net_pnl / entry_cost class TradeLog: """Structured collection of round-trip trades with analysis methods.""" def __init__(self) -> None: self.trades: list[Trade] = [] def add_trade(self, trade: Trade) -> None: self.trades.append(trade) @classmethod def from_legacy_trade_log(cls, trade_log: pd.DataFrame, shares_per_contract: int = 100) -> "TradeLog": """Build a TradeLog from the legacy MultiIndex trade_log DataFrame.""" tl = cls() if trade_log.empty: return tl first_leg: str = trade_log.columns.levels[0][0] order_bto = Order.BTO order_sto = Order.STO entry_mask = trade_log[first_leg].eval( "(order == @order_bto) | (order == @order_sto)" ) entries = trade_log.loc[entry_mask] exits = trade_log.loc[~entry_mask] for contract in entries[first_leg]["contract"]: entry = entries.loc[entries[first_leg]["contract"] == contract] exit_ = exits.loc[exits[first_leg]["contract"] == contract] if entry.empty or exit_.empty: continue try: e_row = entry.iloc[0] x_row = exit_.iloc[0] trade = Trade( contract=contract, underlying=e_row[first_leg]["underlying"], option_type=e_row[first_leg]["type"], strike=e_row[first_leg]["strike"], entry_date=e_row["totals"]["date"], exit_date=x_row["totals"]["date"], entry_price=abs(e_row[first_leg]["cost"]) / shares_per_contract, exit_price=abs(x_row[first_leg]["cost"]) / shares_per_contract, quantity=int(e_row["totals"]["qty"]), shares_per_contract=shares_per_contract, entry_order=e_row[first_leg]["order"], exit_order=x_row[first_leg]["order"], ) tl.add_trade(trade) except (IndexError, KeyError): continue return tl def to_dataframe(self) -> pd.DataFrame: """Convert to a flat DataFrame for analysis.""" if not self.trades: return pd.DataFrame() rows = [] for t in self.trades: rows.append({ "contract": t.contract, "underlying": t.underlying, "type": t.option_type, "strike": t.strike, "entry_date": t.entry_date, "exit_date": t.exit_date, "entry_price": t.entry_price, "exit_price": t.exit_price, "quantity": t.quantity, "gross_pnl": t.gross_pnl, "net_pnl": t.net_pnl, "return_pct": t.return_pct, "entry_commission": t.entry_commission, "exit_commission": t.exit_commission, }) return pd.DataFrame(rows) @property def net_pnls(self) -> np.ndarray: return np.array([t.net_pnl for t in self.trades]) @property def winners(self) -> list[Trade]: return [t for t in self.trades if t.net_pnl > 0] @property def losers(self) -> list[Trade]: return [t for t in self.trades if t.net_pnl <= 0] def __len__(self) -> int: return len(self.trades) ================================================ FILE: options_portfolio_backtester/convexity/__init__.py ================================================ """Convexity scanner: cross-asset tail protection scoring and allocation.""" from options_portfolio_backtester.convexity.allocator import ( allocate_equal_weight, allocate_inverse_vol, pick_cheapest, ) from options_portfolio_backtester.convexity.backtest import ( BacktestResult, run_backtest, run_unhedged, ) from options_portfolio_backtester.convexity.config import ( BacktestConfig, InstrumentConfig, default_config, ) from options_portfolio_backtester.convexity.scoring import compute_convexity_scores __all__ = [ "InstrumentConfig", "BacktestConfig", "default_config", "compute_convexity_scores", "BacktestResult", "run_backtest", "run_unhedged", "pick_cheapest", "allocate_equal_weight", "allocate_inverse_vol", ] ================================================ FILE: options_portfolio_backtester/convexity/_utils.py ================================================ """Shared utilities for the convexity module.""" from __future__ import annotations import numpy as np import pandas as pd def _to_ns(series: pd.Series) -> np.ndarray: """Convert a datetime Series to int64 nanosecond timestamps.""" return series.values.astype("datetime64[ns]").view("int64").astype(np.int64) ================================================ FILE: options_portfolio_backtester/convexity/allocator.py ================================================ """Allocation strategies: pick which instrument(s) to hedge.""" from __future__ import annotations def pick_cheapest(scores: dict[str, float]) -> str: """Pick the instrument with the highest convexity ratio.""" if not scores: raise ValueError("No scores to pick from") return max(scores, key=scores.get) def allocate_equal_weight(symbols: list[str], budget: float) -> dict[str, float]: """Split budget equally across all instruments.""" if not symbols: return {} per_symbol = budget / len(symbols) return {s: per_symbol for s in symbols} def allocate_inverse_vol(vol_map: dict[str, float], budget: float) -> dict[str, float]: """Allocate more to lower-volatility instruments. Weight is proportional to 1/vol, normalized to sum to budget. """ if not vol_map: return {} inv_vols = {} for sym, vol in vol_map.items(): if vol > 0: inv_vols[sym] = 1.0 / vol if not inv_vols: return allocate_equal_weight(list(vol_map.keys()), budget) total_inv_vol = sum(inv_vols.values()) return {sym: (iv / total_inv_vol) * budget for sym, iv in inv_vols.items()} ================================================ FILE: options_portfolio_backtester/convexity/backtest.py ================================================ """Backtest: run the monthly rebalance loop via Rust backend.""" from __future__ import annotations import logging from dataclasses import dataclass import numpy as np import pandas as pd from .config import BacktestConfig log = logging.getLogger(__name__) def _to_ns(series: pd.Series) -> np.ndarray: """Convert a datetime Series to int64 nanosecond timestamps.""" return series.values.astype("datetime64[ns]").view("int64").astype(np.int64) @dataclass class BacktestResult: """Results from a single-instrument backtest.""" records: pd.DataFrame # monthly rebalance records daily_balance: pd.DataFrame # daily portfolio values config: BacktestConfig def run_backtest( options_data, stocks_data, config: BacktestConfig, ) -> BacktestResult: """Run the full backtest: monthly put overlay on equity portfolio. Takes HistoricalOptionsData and TiingoData from options_backtester. """ from options_portfolio_backtester._ob_rust import run_convexity_backtest opt_df = options_data._data puts = opt_df[opt_df["type"] == "put"].sort_values("quotedate") stk_df = stocks_data._data.sort_values("date") if puts.empty or stk_df.empty: empty_records = pd.DataFrame() empty_daily = pd.DataFrame() return BacktestResult(records=empty_records, daily_balance=empty_daily, config=config) result = run_convexity_backtest( put_dates_ns=_to_ns(puts["quotedate"]), put_expirations_ns=_to_ns(puts["expiration"]), put_strikes=puts["strike"].values.astype(np.float64), put_bids=puts["bid"].values.astype(np.float64), put_asks=puts["ask"].values.astype(np.float64), put_deltas=puts["delta"].values.astype(np.float64), put_underlying=puts["underlying_last"].values.astype(np.float64), put_dtes=puts["dte"].values.astype(np.int32), put_ivs=puts["impliedvol"].values.astype(np.float64), stock_dates_ns=_to_ns(stk_df["date"]), stock_prices=stk_df["adjClose"].values.astype(np.float64), initial_capital=config.initial_capital, budget_pct=config.budget_pct, target_delta=config.target_delta, dte_min=config.dte_min, dte_max=config.dte_max, tail_drop=config.tail_drop, ) # Build monthly records DataFrame rec = result["records"] records = pd.DataFrame( { "date": pd.to_datetime(rec["dates_ns"], unit="ns"), "shares": rec["shares"], "stock_price": rec["stock_prices"], "equity_value": rec["equity_values"], "put_cost": rec["put_costs"], "put_exit_value": rec["put_exit_values"], "put_pnl": rec["put_pnls"], "portfolio_value": rec["portfolio_values"], "convexity_ratio": rec["convexity_ratios"], "strike": rec["strikes"], "contracts": rec["contracts"], } ).set_index("date") # Build daily balance DataFrame daily = pd.DataFrame( { "date": pd.to_datetime(result["daily_dates_ns"], unit="ns"), "balance": result["daily_balances"], } ).set_index("date") daily["pct_change"] = daily["balance"].pct_change() log.info( "Backtest: %d months, final value $%.0f (started $%.0f)", len(records), daily["balance"].iloc[-1] if len(daily) > 0 else 0, config.initial_capital, ) return BacktestResult(records=records, daily_balance=daily, config=config) def run_unhedged(stocks_data, config: BacktestConfig) -> pd.DataFrame: """Run unhedged equity-only benchmark. Returns daily balance DataFrame.""" stk_df = stocks_data._data.sort_values("date") if stk_df.empty: return pd.DataFrame() prices = stk_df["adjClose"].values.astype(np.float64) dates = stk_df["date"] initial_shares = config.initial_capital / prices[0] daily_balance = initial_shares * prices df = pd.DataFrame({"date": dates, "balance": daily_balance}).set_index("date") df["pct_change"] = df["balance"].pct_change() return df ================================================ FILE: options_portfolio_backtester/convexity/config.py ================================================ """Configuration: instrument registry and backtest parameters.""" from __future__ import annotations from dataclasses import dataclass, field @dataclass(frozen=True) class InstrumentConfig: """Configuration for a single instrument.""" symbol: str options_file: str stocks_file: str target_delta: float = -0.10 dte_min: int = 14 dte_max: int = 60 tail_drop: float = 0.20 @dataclass(frozen=True) class BacktestConfig: """Global backtest parameters.""" initial_capital: float = 1_000_000.0 budget_pct: float = 0.005 # 0.5% of portfolio per month on puts target_delta: float = -0.10 dte_min: int = 14 dte_max: int = 60 tail_drop: float = 0.20 instruments: list[InstrumentConfig] = field(default_factory=list) def default_config( options_file: str = "data/processed/options.csv", stocks_file: str = "data/processed/stocks.csv", ) -> BacktestConfig: """Default config with SPY only.""" spy = InstrumentConfig( symbol="SPY", options_file=options_file, stocks_file=stocks_file, ) return BacktestConfig(instruments=[spy]) ================================================ FILE: options_portfolio_backtester/convexity/scoring.py ================================================ """Scoring: compute convexity ratios via Rust backend.""" from __future__ import annotations import logging import numpy as np import pandas as pd from .config import BacktestConfig log = logging.getLogger(__name__) def _to_ns(series: pd.Series) -> np.ndarray: """Convert a datetime Series to int64 nanosecond timestamps.""" return series.values.astype("datetime64[ns]").view("int64").astype(np.int64) def compute_convexity_scores( options_data, config: BacktestConfig, ) -> pd.DataFrame: """Compute daily convexity ratio scores for an instrument. Takes an HistoricalOptionsData object from options_backtester and returns a DataFrame indexed by date with convexity_ratio and supporting fields. """ from options_portfolio_backtester._ob_rust import compute_daily_scores df = options_data._data puts = df[df["type"] == "put"].sort_values("quotedate") if puts.empty: return pd.DataFrame() result = compute_daily_scores( dates_ns=_to_ns(puts["quotedate"]), strikes=puts["strike"].values.astype(np.float64), bids=puts["bid"].values.astype(np.float64), asks=puts["ask"].values.astype(np.float64), deltas=puts["delta"].values.astype(np.float64), underlying_prices=puts["underlying_last"].values.astype(np.float64), dtes=puts["dte"].values.astype(np.int32), implied_vols=puts["impliedvol"].values.astype(np.float64), target_delta=config.target_delta, dte_min=config.dte_min, dte_max=config.dte_max, tail_drop=config.tail_drop, ) scores = pd.DataFrame( { "date": pd.to_datetime(result["dates_ns"], unit="ns"), "convexity_ratio": result["convexity_ratios"], "strike": result["strikes"], "ask": result["asks"], "bid": result["bids"], "delta": result["deltas"], "underlying_price": result["underlying_prices"], "implied_vol": result["implied_vols"], "dte": result["dtes"], "annual_cost": result["annual_costs"], "tail_payoff": result["tail_payoffs"], } ).set_index("date") log.info("Computed %d daily scores (%.1f years)", len(scores), len(scores) / 252) return scores ================================================ FILE: options_portfolio_backtester/convexity/viz.py ================================================ """Visualization: Altair charts for scores, allocations, and P&L.""" from __future__ import annotations import altair as alt import pandas as pd def convexity_scores_chart(scores_df: pd.DataFrame) -> alt.Chart: """Line chart of daily convexity ratios over time.""" data = scores_df.reset_index() return ( alt.Chart(data) .mark_line() .encode( x=alt.X("date:T", title="Date"), y=alt.Y("convexity_ratio:Q", title="Convexity Ratio"), tooltip=["date:T", "convexity_ratio:Q", "strike:Q", "underlying_price:Q", "implied_vol:Q"], ) .properties(title="Daily Convexity Ratio", width=800, height=300) ) def monthly_pnl_chart(records: pd.DataFrame) -> alt.Chart: """Bar chart of monthly put P&L.""" data = records.reset_index() return ( alt.Chart(data) .mark_bar() .encode( x=alt.X("date:T", title="Date"), y=alt.Y("put_pnl:Q", title="Put P&L ($)"), color=alt.condition( alt.datum.put_pnl > 0, alt.value("steelblue"), alt.value("salmon"), ), tooltip=["date:T", "put_pnl:Q", "put_cost:Q", "put_exit_value:Q", "strike:Q", "contracts:Q"], ) .properties(title="Monthly Put P&L", width=800, height=200) ) def cumulative_pnl_chart(results: dict[str, pd.DataFrame]) -> alt.Chart: """Cumulative portfolio value for multiple strategies.""" frames = [] for name, daily_df in results.items(): df = daily_df[["balance"]].copy() df["strategy"] = name frames.append(df.reset_index()) if not frames: return alt.Chart(pd.DataFrame()).mark_line() data = pd.concat(frames, ignore_index=True) return ( alt.Chart(data) .mark_line() .encode( x=alt.X("date:T", title="Date"), y=alt.Y("balance:Q", title="Portfolio Value ($)", scale=alt.Scale(zero=False)), color=alt.Color("strategy:N", title="Strategy"), tooltip=["date:T", "balance:Q", "strategy:N"], ) .properties(title="Cumulative Portfolio Value", width=800, height=400) ) ================================================ FILE: options_portfolio_backtester/core/__init__.py ================================================ ================================================ FILE: options_portfolio_backtester/core/types.py ================================================ """Core domain types for options backtesting. Direction is decoupled from column names — use Direction.price_column instead of Direction.value to get the DataFrame column for pricing. """ from __future__ import annotations from collections import namedtuple from dataclasses import dataclass, field from enum import Enum from typing import Any # --------------------------------------------------------------------------- # Enums # --------------------------------------------------------------------------- class OptionType(Enum): CALL = "call" PUT = "put" def __invert__(self) -> OptionType: return OptionType.PUT if self == OptionType.CALL else OptionType.CALL class Direction(Enum): """Trade direction. price_column gives the DataFrame column name.""" BUY = "buy" SELL = "sell" @property def price_column(self) -> str: """Column name used for trade execution pricing.""" return "ask" if self == Direction.BUY else "bid" def __invert__(self) -> Direction: return Direction.SELL if self == Direction.BUY else Direction.BUY class Signal(Enum): ENTRY = "entry" EXIT = "exit" class Order(Enum): BTO = "BTO" # Buy to Open BTC = "BTC" # Buy to Close STO = "STO" # Sell to Open STC = "STC" # Sell to Close def __invert__(self) -> Order: _inv = {Order.BTO: Order.STC, Order.STC: Order.BTO, Order.STO: Order.BTC, Order.BTC: Order.STO} return _inv[self] def get_order(direction: Direction, signal: Signal) -> Order: """Map (direction, signal) to the appropriate Order type.""" if direction == Direction.BUY: return Order.BTO if signal == Signal.ENTRY else Order.STC return Order.STO if signal == Signal.ENTRY else Order.BTC # --------------------------------------------------------------------------- # Value objects # --------------------------------------------------------------------------- @dataclass(frozen=True, slots=True) class Greeks: """Option Greeks for a single contract or aggregated position. Supports addition (aggregation) and scalar multiplication (scaling by qty). """ delta: float = 0.0 gamma: float = 0.0 theta: float = 0.0 vega: float = 0.0 def __add__(self, other: Greeks) -> Greeks: return Greeks( delta=self.delta + other.delta, gamma=self.gamma + other.gamma, theta=self.theta + other.theta, vega=self.vega + other.vega, ) def __mul__(self, scalar: float) -> Greeks: return Greeks( delta=self.delta * scalar, gamma=self.gamma * scalar, theta=self.theta * scalar, vega=self.vega * scalar, ) def __rmul__(self, scalar: float) -> Greeks: return self.__mul__(scalar) def __neg__(self) -> Greeks: return self * -1.0 @property def as_dict(self) -> dict[str, float]: return {"delta": self.delta, "gamma": self.gamma, "theta": self.theta, "vega": self.vega} @dataclass(frozen=True, slots=True) class Fill: """A single execution fill. Captures price, quantity, commission, slippage, and computes notional. """ price: float quantity: int direction: Direction shares_per_contract: int = 100 commission: float = 0.0 slippage: float = 0.0 @property def direction_sign(self) -> int: return -1 if self.direction == Direction.BUY else 1 @property def notional(self) -> float: """Net cash impact: direction_sign * (price * qty * spc) - commission - slippage.""" raw = self.direction_sign * self.price * self.quantity * self.shares_per_contract return raw - self.commission - self.slippage @dataclass(frozen=True, slots=True) class OptionContract: """Identifies a specific option contract.""" contract_id: str underlying: str expiration: Any # pd.Timestamp or str option_type: OptionType strike: float # Re-use namedtuple for backward compatibility StockAllocation = namedtuple("StockAllocation", "symbol percentage") # Backward-compatible aliases Stock = StockAllocation Type = OptionType ================================================ FILE: options_portfolio_backtester/data/__init__.py ================================================ """Data module — schema and providers.""" ================================================ FILE: options_portfolio_backtester/data/providers.py ================================================ """Data providers — ABCs, CSV implementations, and data loaders.""" from __future__ import annotations import os from abc import ABC, abstractmethod from typing import Any, Union import pandas as pd from .schema import Schema, Filter class TiingoData: """Tiingo (stocks & indeces) Data container class.""" def __init__(self, file: str, schema: Schema | None = None, **params: Any) -> None: if schema is None: self.schema = TiingoData.default_schema() file_extension = os.path.splitext(file)[1] if file_extension == '.h5': self._data: pd.DataFrame = pd.read_hdf(file, **params) elif file_extension == '.csv': params['parse_dates'] = [self.schema.date.mapping] self._data = pd.read_csv(file, **params) columns = self._data.columns assert all((col in columns for _key, col in self.schema)) date_col = self.schema['date'] self.start_date: pd.Timestamp = self._data[date_col].min() self.end_date: pd.Timestamp = self._data[date_col].max() def apply_filter(self, f: Filter) -> pd.DataFrame: """Apply Filter `f` to the data. Returns a `pd.DataFrame` with the filtered rows.""" return self._data.query(f.query) def iter_dates(self) -> pd.core.groupby.DataFrameGroupBy: """Returns `pd.DataFrameGroupBy` that groups stocks by date""" return self._data.groupby(self.schema['date']) def iter_months(self) -> pd.core.groupby.DataFrameGroupBy: """Returns `pd.DataFrameGroupBy` that groups stocks by month""" date_col = self.schema['date'] first_date_per_month = ( self._data.groupby(self._data[date_col].dt.to_period('M'))[date_col] .min() ) mask = self._data[date_col].isin(first_date_per_month.values) return self._data[mask].groupby(date_col) def __getattr__(self, attr: str) -> Any: """Pass method invocation to `self._data`""" method = getattr(self._data, attr) if hasattr(method, '__call__'): def df_method(*args: Any, **kwargs: Any) -> Any: return method(*args, **kwargs) return df_method else: return method def __getitem__(self, item: Union[str, pd.Series]) -> Union[pd.DataFrame, pd.Series]: if isinstance(item, pd.Series): return self._data[item] else: key = self.schema[item] return self._data[key] def __setitem__(self, key: str, value: Any) -> None: self._data[key] = value if key not in self.schema: self.schema.update({key: key}) def __len__(self) -> int: return len(self._data) def __repr__(self) -> str: return self._data.__repr__() @staticmethod def default_schema() -> Schema: """Returns default schema for Tiingo Data""" return Schema.stocks() def sma(self, periods: int) -> None: sma = self._data.groupby('symbol', as_index=False).rolling(periods)['adjClose'].mean() sma = sma.fillna(0) sma.index = [index[1] for index in sma.index] self._data['sma'] = sma self.schema.update({'sma': 'sma'}) class HistoricalOptionsData: """Historical Options Data container class.""" def __init__(self, file: str, schema: Schema | None = None, **params: Any) -> None: if schema is None: self.schema = HistoricalOptionsData.default_schema() file_extension = os.path.splitext(file)[1] if file_extension == '.h5': self._data: pd.DataFrame = pd.read_hdf(file, **params) elif file_extension == '.csv': params['parse_dates'] = [self.schema.expiration.mapping, self.schema.date.mapping] self._data = pd.read_csv(file, **params) columns = self._data.columns assert all((col in columns for _key, col in self.schema)) date_col = self.schema['date'] expiration_col = self.schema['expiration'] self._data['dte'] = (self._data[expiration_col] - self._data[date_col]).dt.days self.schema.update({'dte': 'dte'}) self.start_date: pd.Timestamp = self._data[date_col].min() self.end_date: pd.Timestamp = self._data[date_col].max() def apply_filter(self, f: Filter) -> pd.DataFrame: """Apply Filter `f` to the data. Returns a `pd.DataFrame` with the filtered rows.""" return self._data.query(f.query) def iter_dates(self) -> pd.core.groupby.DataFrameGroupBy: """Returns `pd.DataFrameGroupBy` that groups contracts by date""" return self._data.groupby(self.schema['date']) def iter_months(self) -> pd.core.groupby.DataFrameGroupBy: """Returns `pd.DataFrameGroupBy` that groups contracts by month""" date_col = self.schema['date'] first_date_per_month = ( self._data.groupby(self._data[date_col].dt.to_period('M'))[date_col] .min() ) mask = self._data[date_col].isin(first_date_per_month.values) return self._data[mask].groupby(date_col) def __getattr__(self, attr: str) -> Any: """Pass method invocation to `self._data`""" method = getattr(self._data, attr) if hasattr(method, '__call__'): def df_method(*args: Any, **kwargs: Any) -> Any: return method(*args, **kwargs) return df_method else: return method def __getitem__(self, item: Union[str, pd.Series]) -> Union[pd.DataFrame, pd.Series]: if isinstance(item, pd.Series): return self._data[item] else: key = self.schema[item] return self._data[key] def __setitem__(self, key: str, value: Any) -> None: self._data[key] = value if key not in self.schema: self.schema.update({key: key}) def __len__(self) -> int: return len(self._data) def __repr__(self) -> str: return self._data.__repr__() @staticmethod def default_schema() -> Schema: """Returns default schema for Historical Options Data""" schema = Schema.options() schema.update({ 'contract': 'optionroot', 'date': 'quotedate', 'last': 'last', 'open_interest': 'openinterest', 'impliedvol': 'impliedvol', 'delta': 'delta', 'gamma': 'gamma', 'theta': 'theta', 'vega': 'vega' }) return schema # --------------------------------------------------------------------------- # Abstract base classes # --------------------------------------------------------------------------- class DataProvider(ABC): """Base interface for all data providers.""" @property @abstractmethod def schema(self) -> Schema: ... @property @abstractmethod def data(self) -> pd.DataFrame: ... @property @abstractmethod def start_date(self) -> pd.Timestamp: ... @property @abstractmethod def end_date(self) -> pd.Timestamp: ... @abstractmethod def apply_filter(self, f: Filter) -> pd.DataFrame: ... @abstractmethod def iter_dates(self) -> Any: ... @abstractmethod def iter_months(self) -> Any: ... class OptionsDataProvider(DataProvider): """Options-specific data provider interface.""" pass class StocksDataProvider(DataProvider): """Stocks-specific data provider interface.""" @abstractmethod def sma(self, periods: int) -> None: ... # --------------------------------------------------------------------------- # CSV implementations (wrap existing loaders) # --------------------------------------------------------------------------- class CsvOptionsProvider(OptionsDataProvider): """Load options data from CSV files using the existing HistoricalOptionsData loader.""" def __init__(self, file: str, schema: Schema | None = None, **params: Any) -> None: self._loader = HistoricalOptionsData(file, schema=schema, **params) @property def schema(self) -> Schema: return self._loader.schema @property def data(self) -> pd.DataFrame: return self._loader._data @property def start_date(self) -> pd.Timestamp: return self._loader.start_date @property def end_date(self) -> pd.Timestamp: return self._loader.end_date def apply_filter(self, f: Filter) -> pd.DataFrame: return self._loader.apply_filter(f) def iter_dates(self) -> Any: return self._loader.iter_dates() def iter_months(self) -> Any: return self._loader.iter_months() def __getitem__(self, item: Any) -> Any: return self._loader[item] def __setitem__(self, key: str, value: Any) -> None: self._loader[key] = value def __len__(self) -> int: return len(self._loader) @property def _data(self) -> pd.DataFrame: """Access to underlying DataFrame.""" return self._loader._data class CsvStocksProvider(StocksDataProvider): """Load stock data from CSV files using the existing TiingoData loader.""" def __init__(self, file: str, schema: Schema | None = None, **params: Any) -> None: self._loader = TiingoData(file, schema=schema, **params) @property def schema(self) -> Schema: return self._loader.schema @property def data(self) -> pd.DataFrame: return self._loader._data @property def start_date(self) -> pd.Timestamp: return self._loader.start_date @property def end_date(self) -> pd.Timestamp: return self._loader.end_date def apply_filter(self, f: Filter) -> pd.DataFrame: return self._loader.apply_filter(f) def iter_dates(self) -> Any: return self._loader.iter_dates() def iter_months(self) -> Any: return self._loader.iter_months() def sma(self, periods: int) -> None: self._loader.sma(periods) def __getitem__(self, item: Any) -> Any: return self._loader[item] def __setitem__(self, key: str, value: Any) -> None: self._loader[key] = value def __len__(self) -> int: return len(self._loader) @property def _data(self) -> pd.DataFrame: """Access to underlying DataFrame.""" return self._loader._data ================================================ FILE: options_portfolio_backtester/data/schema.py ================================================ """Filter DSL — Schema, Field, and Filter for building query expressions.""" from __future__ import annotations from typing import Any, Iterator, Union class Schema: """Data schema class. Used provide uniform access to fields in the data set. """ stock_columns = [ "symbol", "date", "open", "close", "high", "low", "volume", "adjClose", "adjHigh", "adjLow", "adjOpen", "adjVolume", "divCash", "splitFactor" ] option_columns = [ "underlying", "underlying_last", "date", "contract", "type", "expiration", "strike", "bid", "ask", "volume", "open_interest" ] @staticmethod def stocks() -> Schema: """Builder method that returns a `Schema` with default mappings for stocks""" mappings = {key: key for key in Schema.stock_columns} return Schema(mappings) @staticmethod def options() -> Schema: """Builder method that returns a `Schema` with default mappings for options""" mappings = {key: key for key in Schema.option_columns} return Schema(mappings) def __init__(self, mappings: dict[str, str]) -> None: assert all((key in mappings for key in Schema.stock_columns)) or all( (key in mappings for key in Schema.option_columns)) self._mappings: dict[str, str] = mappings def update(self, mappings: dict[str, str]) -> Schema: """Update schema according to given `mappings`""" self._mappings.update(mappings) return self def __contains__(self, key: str) -> bool: """Returns True if key is in schema""" return key in self._mappings.keys() def __getattr__(self, key: str) -> Field: """Returns Field object used to build Filters""" return Field(key, self._mappings[key]) def __setitem__(self, key: str, value: str) -> None: self._mappings[key] = value def __getitem__(self, key: str) -> str: """Returns mapping of given `key`""" return self._mappings[key] def __iter__(self) -> Iterator[tuple[str, str]]: return iter(self._mappings.items()) def __repr__(self) -> str: return "Schema({})".format([Field(k, m) for k, m in self._mappings.items()]) def __eq__(self, other: object) -> bool: if not isinstance(other, Schema): return NotImplemented return self._mappings == other._mappings class Field: """Encapsulates data fields to build filters used by strategies""" __slots__ = ("name", "mapping") def __init__(self, name: str, mapping: str) -> None: self.name = name self.mapping = mapping def _create_filter(self, op: str, other: Union[Field, Any]) -> Filter: if isinstance(other, Field): query = Field._format_query(self.mapping, op, other.mapping) else: query = Field._format_query(self.mapping, op, other) return Filter(query) def _combine_fields(self, op: str, other: Union[Field, int, float], invert: bool = False) -> Field: if isinstance(other, Field): name = Field._format_query(self.name, op, other.name, invert) mapping = Field._format_query(self.mapping, op, other.mapping, invert) elif isinstance(other, (int, float)): name = Field._format_query(self.name, op, other, invert) mapping = Field._format_query(self.mapping, op, other, invert) else: raise TypeError return Field(name, mapping) @staticmethod def _format_query(left: Any, op: str, right: Any, invert: bool = False) -> str: if invert: left, right = right, left query = "{left} {op} {right}".format(left=left, op=op, right=right) return query def __add__(self, value: Union[Field, int, float]) -> Field: return self._combine_fields("+", value) def __radd__(self, value: Union[Field, int, float]) -> Field: return self._combine_fields("+", value, invert=True) def __sub__(self, value: Union[Field, int, float]) -> Field: return self._combine_fields("-", value) def __rsub__(self, value: Union[Field, int, float]) -> Field: return self._combine_fields("-", value, invert=True) def __mul__(self, value: Union[Field, int, float]) -> Field: return self._combine_fields("*", value) def __rmul__(self, value: Union[Field, int, float]) -> Field: return self._combine_fields("*", value, invert=True) def __truediv__(self, value: Union[Field, int, float]) -> Field: return self._combine_fields("/", value) def __rtruediv__(self, value: Union[Field, int, float]) -> Field: return self._combine_fields("/", value, invert=True) def __lt__(self, value: Union[Field, Any]) -> Filter: return self._create_filter("<", value) def __le__(self, value: Union[Field, Any]) -> Filter: return self._create_filter("<=", value) def __gt__(self, value: Union[Field, Any]) -> Filter: return self._create_filter(">", value) def __ge__(self, value: Union[Field, Any]) -> Filter: return self._create_filter(">=", value) def __eq__(self, value: Union[Field, Any]) -> Filter: # type: ignore[override] if isinstance(value, str): value = "'{}'".format(value) return self._create_filter("==", value) def __ne__(self, value: Union[Field, Any]) -> Filter: # type: ignore[override] return self._create_filter("!=", value) def __repr__(self) -> str: return "Field(name='{}', mapping='{}')".format(self.name, self.mapping) class Filter: """This class determines entry/exit conditions for strategies""" __slots__ = ("query") def __init__(self, query: str) -> None: self.query = query def __and__(self, other: Filter) -> Filter: """Returns logical *and* between `self` and `other`""" assert isinstance(other, Filter) new_query = "({}) & ({})".format(self.query, other.query) return Filter(query=new_query) def __or__(self, other: Filter) -> Filter: """Returns logical *or* between `self` and `other`""" assert isinstance(other, Filter) new_query = "(({}) | ({}))".format(self.query, other.query) return Filter(query=new_query) def __invert__(self) -> Filter: """Negates filter""" return Filter("!({})".format(self.query)) def __call__(self, data: 'pd.DataFrame') -> 'pd.Series': """Returns dataframe of filtered data""" return data.eval(self.query) def __repr__(self) -> str: return "Filter(query='{}')".format(self.query) __all__ = ["Schema", "Field", "Filter"] ================================================ FILE: options_portfolio_backtester/engine/__init__.py ================================================ ================================================ FILE: options_portfolio_backtester/engine/algo_adapters.py ================================================ """Algo adapter layer to drive BacktestEngine with bt-style pipeline blocks.""" from __future__ import annotations import math from dataclasses import dataclass, field from typing import Literal, Protocol import pandas as pd from options_portfolio_backtester.core.types import Greeks StepStatus = Literal["continue", "skip_day", "stop"] @dataclass(frozen=True) class EngineStepDecision: """Decision emitted by one engine-algo step.""" status: StepStatus = "continue" message: str = "" @dataclass class EnginePipelineContext: """Mutable run context shared by all engine algo steps for one rebalance date.""" date: pd.Timestamp stocks: pd.DataFrame options: pd.DataFrame total_capital: float current_cash: float current_greeks: Greeks options_allocation: float entry_filters: list = field(default_factory=list) exit_threshold_override: tuple[float, float] | None = None class EngineAlgo(Protocol): def __call__(self, ctx: EnginePipelineContext) -> EngineStepDecision: ... class EngineRunMonthly: """Allow rebalances only on first rebalance day per month.""" def __init__(self) -> None: self._last_month: tuple[int, int] | None = None def reset(self) -> None: self._last_month = None def __call__(self, ctx: EnginePipelineContext) -> EngineStepDecision: key = (ctx.date.year, ctx.date.month) if self._last_month == key: return EngineStepDecision(status="skip_day", message="not month-start") self._last_month = key return EngineStepDecision() class BudgetPercent: """Set options allocation budget as percent of current total capital.""" def __init__(self, pct: float) -> None: self.pct = float(pct) def __call__(self, ctx: EnginePipelineContext) -> EngineStepDecision: ctx.options_allocation = max(0.0, float(ctx.total_capital) * self.pct) return EngineStepDecision() class RangeFilter: """Keep contracts where *column* falls within [min_val, max_val]. Generic building block — use directly or via the convenience aliases ``SelectByDelta``, ``SelectByDTE``, ``IVRankFilter``. """ def __init__(self, column: str, min_val: float, max_val: float) -> None: self.column = column self.min_val = float(min_val) self.max_val = float(max_val) def __call__(self, ctx: EnginePipelineContext) -> EngineStepDecision: lo, hi, col = self.min_val, self.max_val, self.column def _flt(df: pd.DataFrame) -> pd.Series: if col not in df.columns: return pd.Series(True, index=df.index) v = df[col] return (v >= lo) & (v <= hi) ctx.entry_filters.append(_flt) return EngineStepDecision() def SelectByDelta(min_delta: float = -1.0, max_delta: float = 1.0, column: str = "delta") -> RangeFilter: """Keep contracts with delta within [min_delta, max_delta].""" return RangeFilter(column=column, min_val=min_delta, max_val=max_delta) def SelectByDTE(min_dte: int = 0, max_dte: int = 10_000, column: str = "dte") -> RangeFilter: """Keep contracts with DTE within [min_dte, max_dte].""" return RangeFilter(column=column, min_val=float(min_dte), max_val=float(max_dte)) def IVRankFilter(min_rank: float = 0.0, max_rank: float = 1.0, column: str = "iv_rank") -> RangeFilter: """Keep contracts with IV rank within [min_rank, max_rank].""" return RangeFilter(column=column, min_val=min_rank, max_val=max_rank) class MaxGreekExposure: """Skip new entries when current absolute greek exposure exceeds limits.""" def __init__( self, max_abs_delta: float | None = None, max_abs_vega: float | None = None, ) -> None: self.max_abs_delta = float(max_abs_delta) if max_abs_delta is not None else None self.max_abs_vega = float(max_abs_vega) if max_abs_vega is not None else None def __call__(self, ctx: EnginePipelineContext) -> EngineStepDecision: if self.max_abs_delta is not None and abs(float(ctx.current_greeks.delta)) > self.max_abs_delta: return EngineStepDecision( status="skip_day", message=f"|delta|>{self.max_abs_delta}", ) if self.max_abs_vega is not None and abs(float(ctx.current_greeks.vega)) > self.max_abs_vega: return EngineStepDecision( status="skip_day", message=f"|vega|>{self.max_abs_vega}", ) return EngineStepDecision() class ExitOnThreshold: """Override strategy exit profit/loss thresholds for this run. At least one of *profit_pct* or *loss_pct* must be finite, otherwise the algo is a no-op and likely a caller mistake. """ def __init__(self, profit_pct: float = float("inf"), loss_pct: float = float("inf")) -> None: self.profit_pct = float(profit_pct) self.loss_pct = float(loss_pct) if math.isinf(self.profit_pct) and math.isinf(self.loss_pct): import warnings warnings.warn( "ExitOnThreshold created with both thresholds infinite — " "exit overrides will have no effect", stacklevel=2, ) def __call__(self, ctx: EnginePipelineContext) -> EngineStepDecision: ctx.exit_threshold_override = (self.profit_pct, self.loss_pct) return EngineStepDecision() ================================================ FILE: options_portfolio_backtester/engine/clock.py ================================================ """Trading clock — date iteration and rebalance scheduling.""" from __future__ import annotations from typing import Generator import pandas as pd class TradingClock: """Generates (date, stocks_df, options_df) tuples for the backtest loop. Handles daily/monthly iteration and rebalance scheduling. """ def __init__( self, stocks_data: pd.DataFrame, options_data: pd.DataFrame, stocks_date_col: str = "date", options_date_col: str = "quotedate", monthly: bool = False, ) -> None: self.stocks_data = stocks_data self.options_data = options_data self.stocks_date_col = stocks_date_col self.options_date_col = options_date_col self.monthly = monthly def iter_dates(self) -> Generator[tuple[pd.Timestamp, pd.DataFrame, pd.DataFrame], None, None]: """Iterate over trading dates, yielding (date, stocks, options) per step.""" if self.monthly: stocks_iter = self._monthly_iter(self.stocks_data, self.stocks_date_col) options_iter = self._monthly_iter(self.options_data, self.options_date_col) else: stocks_iter = self.stocks_data.groupby(self.stocks_date_col) options_iter = self.options_data.groupby(self.options_date_col) for (date, stocks), (_, options) in zip(stocks_iter, options_iter): yield date, stocks, options def rebalance_dates(self, freq: int) -> pd.DatetimeIndex: """Compute rebalance dates using business-month-start frequency. Args: freq: Number of business months between rebalances. Returns: DatetimeIndex of rebalance dates present in the data. """ if freq <= 0: return pd.DatetimeIndex([]) dates = pd.DataFrame( self.options_data[[self.options_date_col, "volume"]] ).drop_duplicates(self.options_date_col).set_index(self.options_date_col) return pd.to_datetime( dates.groupby(pd.Grouper(freq=f"{freq}BMS")) .apply(lambda x: x.index.min()) .values ) @staticmethod def _monthly_iter(data: pd.DataFrame, date_col: str): first_date_per_month = ( data.groupby(data[date_col].dt.to_period('M'))[date_col] .min() ) mask = data[date_col].isin(first_date_per_month.values) return data[mask].groupby(date_col) @property def all_dates(self) -> pd.DatetimeIndex: return pd.DatetimeIndex(self.options_data[self.options_date_col].unique()) ================================================ FILE: options_portfolio_backtester/engine/engine.py ================================================ """BacktestEngine — thin orchestrator composing all framework components. Replaces the monolithic Backtest class with a clean composition of: - Data providers (stocks, options) - Strategy (legs, filters, thresholds) - Execution (cost model, fill model, sizer, signal selector) - Portfolio (positions, cash, holdings) - Risk management (constraints) - Analytics (trade log, balance sheet) """ from __future__ import annotations import hashlib import json import logging import subprocess from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Any import numpy as np import pandas as pd from options_portfolio_backtester.core.types import ( Direction, OptionType, Order, Signal, Greeks, Stock, StockAllocation, get_order, ) from options_portfolio_backtester.execution.cost_model import TransactionCostModel, NoCosts from options_portfolio_backtester.execution.fill_model import FillModel, MarketAtBidAsk from options_portfolio_backtester.execution.sizer import PositionSizer, CapitalBased from options_portfolio_backtester.execution.signal_selector import SignalSelector, FirstMatch from options_portfolio_backtester.portfolio.risk import RiskManager from options_portfolio_backtester.portfolio.portfolio import Portfolio from options_portfolio_backtester import _ob_rust from options_portfolio_backtester.engine.algo_adapters import ( EngineAlgo, EnginePipelineContext, ) from options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData from options_portfolio_backtester.data.schema import Schema from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg logger = logging.getLogger(__name__) def _intrinsic_value(option_type: str, strike: float, underlying_price: float) -> float: """Compute intrinsic value of an option given spot price. For puts: max(0, strike - spot) For calls: max(0, spot - strike) """ if option_type == OptionType.CALL.value: return max(0.0, underlying_price - strike) return max(0.0, strike - underlying_price) @dataclass class _StrategySlot: """Configuration and runtime state for one strategy within a multi-strategy engine.""" strategy: Strategy weight: float rebalance_freq: int rebalance_unit: str = 'BMS' check_exits_daily: bool = False name: str = "" inventory: pd.DataFrame = field(default=None, repr=False) rebalance_dates: pd.DatetimeIndex = field(default=None, repr=False) class BacktestEngine: """Orchestrates backtest with pluggable execution components. Composes data providers, strategy legs, cost/fill/sizer/selector models, and risk constraints into a single backtest loop. Dispatches to Rust for all supported configurations. """ def __init__( self, allocation: dict[str, float], initial_capital: int = 1_000_000, shares_per_contract: int = 100, cost_model: TransactionCostModel | None = None, fill_model: FillModel | None = None, sizer: PositionSizer | None = None, signal_selector: SignalSelector | None = None, risk_manager: RiskManager | None = None, algos: list[EngineAlgo] | None = None, stop_if_broke: bool = False, max_notional_pct: float | None = None, ) -> None: assets = ("stocks", "options", "cash") self._raw_allocation = {a: allocation.get(a, 0.0) for a in assets} total_allocation = sum(self._raw_allocation.values()) self.allocation: dict[str, float] = {} for asset in assets: self.allocation[asset] = self._raw_allocation[asset] / total_allocation self.initial_capital = initial_capital self.shares_per_contract = shares_per_contract self.cost_model = cost_model or NoCosts() self.fill_model = fill_model or MarketAtBidAsk() self.sizer = sizer or CapitalBased() self.signal_selector = signal_selector or FirstMatch() self.risk_manager = risk_manager or RiskManager() self.algos = list(algos or []) self.stop_if_broke = stop_if_broke self.max_notional_pct = max_notional_pct self.options_budget_pct: float | None = None self.options_budget_annual_pct: float | None = None self._stocks: list[Stock] = [] self._options_strategy: Strategy | None = None self._stocks_data: TiingoData | None = None self._options_data: HistoricalOptionsData | None = None self.run_metadata: dict[str, Any] = {} self._event_log_rows: list[dict[str, Any]] = [] # -- Properties (same API as original Backtest) -- @property def stocks(self) -> list[Stock]: return self._stocks @stocks.setter def stocks(self, stocks: list[Stock]) -> None: assert np.isclose(sum(s.percentage for s in stocks), 1.0, atol=1e-6) self._stocks = list(stocks) @property def options_strategy(self) -> Strategy | None: return self._options_strategy @options_strategy.setter def options_strategy(self, strat: Strategy) -> None: self._options_strategy = strat @property def stocks_data(self) -> TiingoData | None: return self._stocks_data @stocks_data.setter def stocks_data(self, data: TiingoData) -> None: self._stocks_schema = data.schema self._stocks_data = data @property def options_data(self) -> HistoricalOptionsData | None: return self._options_data @options_data.setter def options_data(self, data: HistoricalOptionsData) -> None: self._options_schema = data.schema self._options_data = data # -- Multi-strategy API -- def add_strategy( self, strategy: Strategy, weight: float, rebalance_freq: int, rebalance_unit: str = 'BMS', check_exits_daily: bool = False, name: str | None = None, ) -> None: """Register a strategy slot for multi-strategy mode. Args: strategy: The Strategy object (legs + exit thresholds). weight: Fraction of options allocation for this strategy. rebalance_freq: Rebalance every N periods. rebalance_unit: Pandas offset alias (default 'BMS'). check_exits_daily: Check exits on non-rebalance days. name: Human-readable name (auto-generated if omitted). """ if not hasattr(self, '_strategy_slots'): self._strategy_slots: list[_StrategySlot] = [] slot_name = name or f"strategy_{len(self._strategy_slots)}" self._strategy_slots.append(_StrategySlot( strategy=strategy, weight=weight, rebalance_freq=rebalance_freq, rebalance_unit=rebalance_unit, check_exits_daily=check_exits_daily, name=slot_name, )) @property def _is_multi_strategy(self) -> bool: return hasattr(self, '_strategy_slots') and len(self._strategy_slots) > 0 # -- Main entry point -- def run(self, rebalance_freq: int = 0, monthly: bool = False, sma_days: int | None = None, rebalance_unit: str = 'BMS', check_exits_daily: bool = False) -> pd.DataFrame: """Run the backtest. Returns the trade log DataFrame. Args: check_exits_daily: When True, evaluate exit filters on every trading day (not just rebalancing days). Positions that match the exit filter are closed and cash is updated, but no new entries or stock reallocation occurs outside rebalancing days. """ self._event_log_rows = [] for algo in self.algos: if hasattr(algo, "reset"): algo.reset() assert self._stocks_data, "Stock data not set" assert all( stock.symbol in self._stocks_data["symbol"].values for stock in self._stocks ), "Ensure all stocks in portfolio are present in the data" assert self._options_data, "Options data not set" # Multi-strategy mode if self._is_multi_strategy: total_weight = sum(s.weight for s in self._strategy_slots) assert abs(total_weight - 1.0) < 1e-6, ( f"Strategy weights must sum to 1.0, got {total_weight}" ) for slot in self._strategy_slots: assert self._options_data.schema == slot.strategy.schema return self._run_rust_multi( monthly=monthly, sma_days=sma_days, check_exits_daily=check_exits_daily, ) assert self._options_strategy, "Options Strategy not set" assert self._options_data.schema == self._options_strategy.schema option_dates = self._options_data["date"].unique() stock_dates = self.stocks_data["date"].unique() assert np.array_equal(stock_dates, option_dates) # Translate algos to Rust-compatible config fields before dispatch. if self.algos: self._translate_algos_to_config() return self._run_rust( rebalance_freq, monthly=monthly, sma_days=sma_days, rebalance_unit=rebalance_unit, check_exits_daily=check_exits_daily, ) def events_dataframe(self) -> pd.DataFrame: """Structured execution event log for debugging and audit. The ``data`` dict from each event is flattened into top-level columns so that the result can be filtered directly (e.g. ``df[df["cash"] > 0]``). """ if not self._event_log_rows: return pd.DataFrame(columns=["date", "event", "status"]) flat = [] for row in self._event_log_rows: entry = {"date": row["date"], "event": row["event"], "status": row["status"]} entry.update(row.get("data", {})) flat.append(entry) return pd.DataFrame(flat) def _translate_algos_to_config(self) -> None: """Translate algo pipeline into Rust-compatible engine config fields. Each algo type maps to an existing Rust feature: - EngineRunMonthly → rebalance_unit='BMS' + rebalance_freq=1 (already handled) - BudgetPercent → options_budget_pct - RangeFilter/SelectByDelta/SelectByDTE/IVRankFilter → entry filter conjunction - MaxGreekExposure → risk_constraints (MaxDelta/MaxVega) - ExitOnThreshold → profit_pct/loss_pct on strategy After translation, self.algos is cleared so the Rust gate passes. """ from options_portfolio_backtester.engine.algo_adapters import ( EngineRunMonthly, BudgetPercent, RangeFilter, MaxGreekExposure, ExitOnThreshold, ) from options_portfolio_backtester.portfolio.risk import RiskManager for algo in self.algos: if isinstance(algo, EngineRunMonthly): # Already handled by rebalance_unit='BMS' + rebalance_freq=1. # If user set algos=[EngineRunMonthly()], it's a no-op for Rust. pass elif isinstance(algo, BudgetPercent): self.options_budget_pct = algo.pct elif isinstance(algo, RangeFilter): # Append range condition to each leg's entry filter as conjunction. col, lo, hi = algo.column, algo.min_val, algo.max_val clause = f"({col} >= {lo}) & ({col} <= {hi})" for leg in self._options_strategy.legs: existing = leg.entry_filter.query if existing: leg.entry_filter.query = f"({existing}) & ({clause})" else: leg.entry_filter.query = clause elif isinstance(algo, MaxGreekExposure): if algo.max_abs_delta is not None: self.risk_manager.add_constraint( type("MaxDelta", (), { "to_rust_config": lambda self_: {"type": "MaxDelta", "limit": algo.max_abs_delta}, "is_allowed": lambda self_, cg, pg, pv, pk: ( abs(cg.delta + pg.delta) <= algo.max_abs_delta, "" ), })() ) if algo.max_abs_vega is not None: self.risk_manager.add_constraint( type("MaxVega", (), { "to_rust_config": lambda self_: {"type": "MaxVega", "limit": algo.max_abs_vega}, "is_allowed": lambda self_, cg, pg, pv, pk: ( abs(cg.vega + pg.vega) <= algo.max_abs_vega, "" ), })() ) elif isinstance(algo, ExitOnThreshold): import math if not math.isinf(algo.profit_pct): self._options_strategy.add_exit_thresholds( profit_pct=algo.profit_pct, loss_pct=self._options_strategy.exit_thresholds[1], ) if not math.isinf(algo.loss_pct): self._options_strategy.add_exit_thresholds( profit_pct=self._options_strategy.exit_thresholds[0], loss_pct=algo.loss_pct, ) else: raise ValueError( f"Unsupported algo type for Rust dispatch: {type(algo).__name__}. " f"All execution runs through Rust; translate to config fields." ) self.algos.clear() def _run_rust( self, rebalance_freq: int, monthly: bool, sma_days: int | None, rebalance_unit: str = 'BMS', check_exits_daily: bool = False, ) -> pd.DataFrame: """Run the backtest using the Rust full-loop implementation.""" import math import pyarrow as pa import polars as pl strategy = self._options_strategy # Compute rebalance dates for the Rust backtest loop. dates_df = ( pd.DataFrame(self.options_data._data[["quotedate", "volume"]]) .drop_duplicates("quotedate") .set_index("quotedate") ) if rebalance_freq: rebalancing_days = pd.to_datetime( dates_df.groupby(pd.Grouper(freq=f"{rebalance_freq}{rebalance_unit}")) .apply(lambda x: x.index.min()) .values ) # Pass rebalance dates as i64 nanoseconds (matching Polars Datetime(ns)) rb_date_ns = [int(d.value) for d in rebalancing_days if not pd.isna(d)] else: rb_date_ns = [] opts_date_col = self._options_schema["date"] stocks_date_col = self._stocks_schema["date"] exp_col = self._options_schema["expiration"] # Drop columns Rust never accesses to reduce Arrow conversion cost. _drop_cols = {"underlying_last", "last", "optionalias", "impliedvol"} # Also drop openinterest unless MaxOpenInterest selector is in use if not (hasattr(self.signal_selector, '__class__') and self.signal_selector.__class__.__name__ == 'MaxOpenInterest'): _drop_cols.add("openinterest") opts_df = self._options_data._data to_drop = [c for c in _drop_cols if c in opts_df.columns] opts_src = opts_df.drop(columns=to_drop) if to_drop else opts_df # Convert pandas → PyArrow → Polars (avoids intermediate copies). opts_pl = pl.from_arrow(pa.Table.from_pandas(opts_src, preserve_index=False)) stocks_pl = pl.from_arrow( pa.Table.from_pandas(self._stocks_data._data, preserve_index=False) ) leg_configs = [] for leg in strategy.legs: lc = { "name": leg.name, "entry_filter": leg.entry_filter.query, "exit_filter": leg.exit_filter.query, "direction": leg.direction.price_column, "type": leg.type.value, "entry_sort_col": leg.entry_sort[0] if leg.entry_sort else None, "entry_sort_asc": leg.entry_sort[1] if leg.entry_sort else True, } # Per-leg overrides leg_sel = getattr(leg, 'signal_selector', None) if leg_sel is not None and hasattr(leg_sel, 'to_rust_config'): lc["signal_selector"] = leg_sel.to_rust_config() leg_fill = getattr(leg, 'fill_model', None) if leg_fill is not None and hasattr(leg_fill, 'to_rust_config'): lc["fill_model"] = leg_fill.to_rust_config() leg_configs.append(lc) config = { "allocation": self.allocation, "initial_capital": float(self.initial_capital), "shares_per_contract": self.shares_per_contract, "rebalance_dates": rb_date_ns, "legs": leg_configs, "profit_pct": ( strategy.exit_thresholds[0] if strategy.exit_thresholds[0] != math.inf else None ), "loss_pct": ( strategy.exit_thresholds[1] if strategy.exit_thresholds[1] != math.inf else None ), "stocks": [(s.symbol, s.percentage) for s in self._stocks], "cost_model": self.cost_model.to_rust_config(), "fill_model": self.fill_model.to_rust_config(), "signal_selector": self.signal_selector.to_rust_config(), "risk_constraints": [c.to_rust_config() for c in self.risk_manager.constraints], "sma_days": sma_days, "options_budget_pct": self.options_budget_pct, "options_budget_annual_pct": self.options_budget_annual_pct, "stop_if_broke": self.stop_if_broke, "max_notional_pct": self.max_notional_pct, "check_exits_daily": check_exits_daily, } schema_mapping = { "contract": self._options_schema["contract"], "date": opts_date_col, "stocks_date": stocks_date_col, "stocks_symbol": self._stocks_schema["symbol"], "stocks_price": self._stocks_schema["adjClose"], "underlying": self._options_schema["underlying"], "expiration": self._options_schema["expiration"], "type": self._options_schema["type"], "strike": self._options_schema["strike"], } balance_pl, trade_log_pl, stats = _ob_rust.run_backtest_py( opts_pl, stocks_pl, config, schema_mapping, ) # Convert trade log from flat columns to MultiIndex trade_log_pd = trade_log_pl.to_pandas() self.trade_log = self._flat_trade_log_to_multiindex(trade_log_pd) # Convert balance self.balance = balance_pl.to_pandas() if "date" in self.balance.columns: self.balance["date"] = pd.to_datetime(self.balance["date"]) self.balance.set_index("date", inplace=True) # Add initial balance row (day before first rebalance) — matches Python initial_date = self.stocks_data.start_date - pd.Timedelta(1, unit="day") initial_row = pd.DataFrame( {"total capital": self.initial_capital, "cash": float(self.initial_capital)}, index=[initial_date], ) self.balance = pd.concat([initial_row, self.balance], sort=False) for col_name in self.balance.columns: self.balance[col_name] = pd.to_numeric(self.balance[col_name], errors="coerce") # Ensure per-stock columns exist (match Python's balance format) for stock in self._stocks: sym = stock.symbol if sym not in self.balance.columns: self.balance[sym] = 0.0 if f"{sym} qty" not in self.balance.columns: self.balance[f"{sym} qty"] = 0.0 for col_name in ["options qty", "stocks qty", "calls capital", "puts capital"]: if col_name not in self.balance.columns: self.balance[col_name] = 0.0 # Add derived columns matching Python output self.balance["options capital"] = ( self.balance["calls capital"] + self.balance["puts capital"] ).fillna(0) stock_cols = [s.symbol for s in self._stocks] self.balance["stocks capital"] = sum( self.balance.get(c, 0) for c in stock_cols ) first_idx = self.balance.index[0] self.balance.loc[first_idx, "stocks capital"] = 0 self.balance.loc[first_idx, "options capital"] = 0 self.balance["total capital"] = ( self.balance["cash"] + self.balance["stocks capital"] + self.balance["options capital"] ) self.balance["% change"] = self.balance["total capital"].pct_change() self.balance["accumulated return"] = (1.0 + self.balance["% change"]).cumprod() # Set current_cash to match Python loop's final state after rebalancing # (after the loop, all capital is allocated to stocks/options/cash per allocation) final_total = self.balance["total capital"].iloc[-1] self.current_cash = self.allocation["cash"] * final_total self._initialize_inventories() self._portfolio = Portfolio(initial_cash=self.current_cash) self._attach_run_metadata( rebalance_freq=rebalance_freq, monthly=monthly, sma_days=sma_days, ) return self.trade_log def _run_rust_multi( self, monthly: bool = False, sma_days: int | None = None, check_exits_daily: bool = False, ) -> pd.DataFrame: """Run multi-strategy backtest using Rust backend.""" import math import pyarrow as pa import polars as pl opts_date_col = self._options_schema["date"] stocks_date_col = self._stocks_schema["date"] # Drop unused columns for Arrow conversion speed _drop_cols = {"underlying_last", "last", "optionalias", "impliedvol"} opts_df = self._options_data._data to_drop = [c for c in _drop_cols if c in opts_df.columns] opts_src = opts_df.drop(columns=to_drop) if to_drop else opts_df opts_pl = pl.from_arrow(pa.Table.from_pandas(opts_src, preserve_index=False)) stocks_pl = pl.from_arrow( pa.Table.from_pandas(self._stocks_data._data, preserve_index=False) ) # Compute per-slot rebalance dates dates_df = ( pd.DataFrame(self.options_data._data[["quotedate", "volume"]]) .drop_duplicates("quotedate") .set_index("quotedate") ) slot_configs = [] for slot in self._strategy_slots: if slot.rebalance_freq: rb_dates = pd.to_datetime( dates_df.groupby( pd.Grouper(freq=f"{slot.rebalance_freq}{slot.rebalance_unit}") ).apply(lambda x: x.index.min()).values ) rb_date_ns = [int(d.value) for d in rb_dates if not pd.isna(d)] else: rb_date_ns = [] leg_configs = [] for leg in slot.strategy.legs: lc = { "name": leg.name, "entry_filter": leg.entry_filter.query, "exit_filter": leg.exit_filter.query, "direction": leg.direction.price_column, "type": leg.type.value, "entry_sort_col": leg.entry_sort[0] if leg.entry_sort else None, "entry_sort_asc": leg.entry_sort[1] if leg.entry_sort else True, } leg_sel = getattr(leg, 'signal_selector', None) if leg_sel is not None and hasattr(leg_sel, 'to_rust_config'): lc["signal_selector"] = leg_sel.to_rust_config() leg_fill = getattr(leg, 'fill_model', None) if leg_fill is not None and hasattr(leg_fill, 'to_rust_config'): lc["fill_model"] = leg_fill.to_rust_config() leg_configs.append(lc) slot_configs.append({ "name": slot.name, "legs": leg_configs, "weight": slot.weight, "rebalance_dates": rb_date_ns, "profit_pct": ( slot.strategy.exit_thresholds[0] if slot.strategy.exit_thresholds[0] != math.inf else None ), "loss_pct": ( slot.strategy.exit_thresholds[1] if slot.strategy.exit_thresholds[1] != math.inf else None ), "check_exits_daily": slot.check_exits_daily, }) config = { "allocation": self.allocation, "initial_capital": float(self.initial_capital), "shares_per_contract": self.shares_per_contract, "rebalance_dates": [], # Not used for multi-strategy; per-slot instead "legs": [], # Not used for multi-strategy; per-slot instead "stocks": [(s.symbol, s.percentage) for s in self._stocks], "cost_model": self.cost_model.to_rust_config(), "fill_model": self.fill_model.to_rust_config(), "signal_selector": self.signal_selector.to_rust_config(), "risk_constraints": [c.to_rust_config() for c in self.risk_manager.constraints], "sma_days": sma_days, "options_budget_pct": self.options_budget_pct, "options_budget_annual_pct": self.options_budget_annual_pct, "stop_if_broke": self.stop_if_broke, "max_notional_pct": self.max_notional_pct, "check_exits_daily": check_exits_daily, } schema_mapping = { "contract": self._options_schema["contract"], "date": opts_date_col, "stocks_date": stocks_date_col, "stocks_symbol": self._stocks_schema["symbol"], "stocks_price": self._stocks_schema["adjClose"], "underlying": self._options_schema["underlying"], "expiration": self._options_schema["expiration"], "type": self._options_schema["type"], "strike": self._options_schema["strike"], } balance_pl, trade_log_pl, stats = _ob_rust.run_multi_strategy_py( opts_pl, stocks_pl, config, schema_mapping, slot_configs, ) # Convert trade log trade_log_pd = trade_log_pl.to_pandas() self.trade_log = self._flat_trade_log_to_multiindex(trade_log_pd) # Convert balance self.balance = balance_pl.to_pandas() if "date" in self.balance.columns: self.balance["date"] = pd.to_datetime(self.balance["date"]) self.balance.set_index("date", inplace=True) # Add initial balance row initial_date = self.stocks_data.start_date - pd.Timedelta(1, unit="day") initial_row = pd.DataFrame( {"total capital": self.initial_capital, "cash": float(self.initial_capital)}, index=[initial_date], ) self.balance = pd.concat([initial_row, self.balance], sort=False) for col_name in self.balance.columns: self.balance[col_name] = pd.to_numeric(self.balance[col_name], errors="coerce") # Ensure per-stock columns exist for stock in self._stocks: sym = stock.symbol if sym not in self.balance.columns: self.balance[sym] = 0.0 if f"{sym} qty" not in self.balance.columns: self.balance[f"{sym} qty"] = 0.0 for col_name in ["options qty", "stocks qty", "calls capital", "puts capital"]: if col_name not in self.balance.columns: self.balance[col_name] = 0.0 # Add derived columns self.balance["options capital"] = ( self.balance["calls capital"] + self.balance["puts capital"] ).fillna(0) stock_cols = [s.symbol for s in self._stocks] self.balance["stocks capital"] = sum( self.balance.get(c, 0) for c in stock_cols ) first_idx = self.balance.index[0] self.balance.loc[first_idx, "stocks capital"] = 0 self.balance.loc[first_idx, "options capital"] = 0 self.balance["total capital"] = ( self.balance["cash"] + self.balance["stocks capital"] + self.balance["options capital"] ) self.balance["% change"] = self.balance["total capital"].pct_change() self.balance["accumulated return"] = (1.0 + self.balance["% change"]).cumprod() final_total = self.balance["total capital"].iloc[-1] self.current_cash = self.allocation["cash"] * final_total self._attach_run_metadata( rebalance_freq=0, monthly=monthly, sma_days=sma_days, ) return self.trade_log def _attach_run_metadata( self, rebalance_freq: int, monthly: bool, sma_days: int | None, ) -> None: metadata = self._build_run_metadata( rebalance_freq=rebalance_freq, monthly=monthly, sma_days=sma_days, ) self.run_metadata = metadata self.balance.attrs["run_metadata"] = metadata self.trade_log.attrs["run_metadata"] = metadata def _build_run_metadata( self, rebalance_freq: int, monthly: bool, sma_days: int | None, ) -> dict[str, Any]: stocks = [ {"symbol": stock.symbol, "percentage": float(stock.percentage)} for stock in self._stocks ] run_config = { "allocation": {k: float(v) for k, v in self.allocation.items()}, "initial_capital": float(self.initial_capital), "shares_per_contract": int(self.shares_per_contract), "rebalance_freq": int(rebalance_freq), "monthly": bool(monthly), "sma_days": int(sma_days) if sma_days is not None else None, "stocks": stocks, } data_snapshot = self._data_snapshot() return { "framework": "options_portfolio_backtester.engine.BacktestEngine", "git_sha": self._git_sha(), "run_at_utc": datetime.now(timezone.utc).isoformat(), "config_hash": self._sha256_json(run_config), "data_snapshot_hash": self._sha256_json(data_snapshot), "data_snapshot": data_snapshot, } def _data_snapshot(self) -> dict[str, Any]: options_dates = self._options_data["date"] stocks_dates = self._stocks_data["date"] return { "options_rows": int(len(self._options_data._data)), "stocks_rows": int(len(self._stocks_data._data)), "options_date_start": pd.Timestamp(options_dates.min()).isoformat(), "options_date_end": pd.Timestamp(options_dates.max()).isoformat(), "stocks_date_start": pd.Timestamp(stocks_dates.min()).isoformat(), "stocks_date_end": pd.Timestamp(stocks_dates.max()).isoformat(), "options_columns": list(self._options_data._data.columns), "stocks_columns": list(self._stocks_data._data.columns), } @staticmethod def _sha256_json(payload: dict[str, Any]) -> str: blob = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str) return hashlib.sha256(blob.encode("utf-8")).hexdigest() @staticmethod def _git_sha() -> str: repo_root = Path(__file__).resolve().parents[2] try: proc = subprocess.run( ["git", "rev-parse", "HEAD"], cwd=repo_root, check=True, capture_output=True, text=True, ) return proc.stdout.strip() except Exception: return "unknown" def _flat_trade_log_to_multiindex(self, flat_df: pd.DataFrame) -> pd.DataFrame: """Convert flat 'leg__field' columns from Rust to MultiIndex DataFrame.""" if flat_df.empty: return pd.DataFrame() tuples = [] for c in flat_df.columns: if "__" in c: parts = c.split("__", 1) tuples.append((parts[0], parts[1])) else: tuples.append(("", c)) flat_df.columns = pd.MultiIndex.from_tuples(tuples) return flat_df # -- Internals (same logic as original, with pluggable components) -- def _initialize_inventories(self) -> None: columns = pd.MultiIndex.from_product( [ [leg.name for leg in self._options_strategy.legs], ["contract", "underlying", "expiration", "type", "strike", "cost", "order"], ] ) totals = pd.MultiIndex.from_product([["totals"], ["cost", "qty", "date"]]) self._options_inventory: pd.DataFrame = pd.DataFrame( columns=pd.Index(columns.tolist() + totals.tolist()) ) self._stocks_inventory: pd.DataFrame = pd.DataFrame( columns=["symbol", "price", "qty"] ) # Portfolio dataclass — dual-write alongside legacy DataFrames self._portfolio = Portfolio(initial_cash=0.0) def _current_options_capital(self, options, stocks): options_value = self._get_current_option_quotes(options) values_by_row: Any = [0] * len(options_value[0]) if len(options_value[0]) != 0: sym_col = self._stocks_schema["symbol"] # Use unadjusted close for intrinsic value — strikes are raw prices _close_col = self._stocks_schema["close"] if "close" in self._stocks_schema else None price_col = _close_col if (_close_col and _close_col in stocks.columns) else self._stocks_schema["adjClose"] for i, leg in enumerate(self._options_strategy.legs): cost_series = options_value[i]["cost"].copy() # Replace NaN (missing contracts) with intrinsic value if cost_series.isna().any(): inv_leg = self._options_inventory[leg.name] for idx in cost_series.index[cost_series.isna()]: opt_type = inv_leg.at[idx, "type"] strike = inv_leg.at[idx, "strike"] underlying = inv_leg.at[idx, "underlying"] spot_match = stocks.loc[stocks[sym_col] == underlying, price_col] spot = spot_match.iloc[0] if len(spot_match) > 0 else 0.0 iv = _intrinsic_value(opt_type, float(strike), float(spot)) cash_sign = -1.0 if ~leg.direction == Direction.SELL else 1.0 cost_series.at[idx] = cash_sign * iv * self.shares_per_contract values_by_row += cost_series.values total: float = -sum(values_by_row * self._options_inventory["totals"]["qty"].values) else: total = 0 return total def _get_current_option_quotes(self, options): current_options_quotes: list[pd.DataFrame] = [] for leg in self._options_strategy.legs: inventory_leg = self._options_inventory[leg.name] leg_options = inventory_leg[["contract"]].merge( options, how="left", left_on="contract", right_on=leg.schema["contract"], ) leg_options.index = self._options_inventory.index leg_options["order"] = get_order(leg.direction, Signal.EXIT) leg_options["cost"] = leg_options[self._options_schema[(~leg.direction).price_column]] if ~leg.direction == Direction.SELL: leg_options["cost"] = -leg_options["cost"] leg_options["cost"] *= self.shares_per_contract current_options_quotes.append(leg_options) return current_options_quotes def __repr__(self) -> str: return ( f"BacktestEngine(capital={self.initial_capital}, " f"allocation={self.allocation}, " f"cost_model={self.cost_model.__class__.__name__})" ) ================================================ FILE: options_portfolio_backtester/engine/multi_strategy.py ================================================ """Multi-strategy engine — run N strategies with shared capital and risk budget.""" from __future__ import annotations from typing import Any import pandas as pd from options_portfolio_backtester.engine.engine import BacktestEngine from options_portfolio_backtester.execution.cost_model import TransactionCostModel, NoCosts from options_portfolio_backtester.portfolio.risk import RiskManager from options_portfolio_backtester.core.types import Stock class StrategyAllocation: """Configuration for one strategy within a multi-strategy engine.""" def __init__( self, name: str, engine: BacktestEngine, weight: float = 1.0, ) -> None: self.name = name self.engine = engine self.weight = weight class MultiStrategyEngine: """Run multiple strategies with shared capital allocation. Each strategy gets a fraction of total capital proportional to its weight. Results are combined into a single balance sheet. """ def __init__( self, strategies: list[StrategyAllocation], initial_capital: int = 1_000_000, ) -> None: self.strategies = strategies self.initial_capital = initial_capital total_weight = sum(s.weight for s in strategies) self._weights = {s.name: s.weight / total_weight for s in strategies} def run(self, rebalance_freq: int = 0, monthly: bool = False, sma_days: int | None = None) -> dict[str, pd.DataFrame]: """Run all strategies and return per-strategy trade logs. Returns: Dict mapping strategy name to its trade log DataFrame. """ results: dict[str, pd.DataFrame] = {} for sa in self.strategies: capital_share = int(self.initial_capital * self._weights[sa.name]) # Override the engine's initial capital with its share sa.engine.initial_capital = capital_share trade_log = sa.engine.run( rebalance_freq=rebalance_freq, monthly=monthly, sma_days=sma_days, ) results[sa.name] = trade_log # Build combined balance self._build_combined_balance() return results def _build_combined_balance(self) -> None: """Combine balance sheets from all strategies.""" balances = [] for sa in self.strategies: if hasattr(sa.engine, "balance"): b = sa.engine.balance[["total capital", "% change"]].copy() b.columns = [f"{sa.name}_capital", f"{sa.name}_pct_change"] balances.append(b) if balances: self.balance = pd.concat(balances, axis=1) capital_cols = [f"{sa.name}_capital" for sa in self.strategies] self.balance["total capital"] = self.balance[capital_cols].sum(axis=1) self.balance["% change"] = self.balance["total capital"].pct_change() self.balance["accumulated return"] = ( 1.0 + self.balance["% change"] ).cumprod() else: self.balance = pd.DataFrame() ================================================ FILE: options_portfolio_backtester/engine/pipeline.py ================================================ """Composable algo pipeline for stock portfolio workflows. Provides bt-compatible scheduling, selection, weighting, and rebalancing algos. """ from __future__ import annotations import re as _re import random as _random from dataclasses import dataclass, field from typing import Callable, Literal, Protocol, Sequence import numpy as np import pandas as pd StepStatus = Literal["continue", "skip_day", "stop"] @dataclass(frozen=True) class StepDecision: """Outcome returned by a pipeline step.""" status: StepStatus = "continue" message: str = "" @dataclass class PipelineContext: """Mutable state shared across pipeline steps for one date.""" date: pd.Timestamp prices: pd.Series total_capital: float cash: float positions: dict[str, float] selected_symbols: list[str] = field(default_factory=list) target_weights: dict[str, float] = field(default_factory=dict) # Price history up to current date (set by AlgoPipelineBacktester). price_history: pd.DataFrame | None = None @dataclass(frozen=True) class PipelineLogRow: date: pd.Timestamp step: str status: StepStatus message: str class Algo(Protocol): """Protocol for a pipeline step.""" def __call__(self, ctx: PipelineContext) -> StepDecision: ... # --------------------------------------------------------------------------- # Scheduling algos # --------------------------------------------------------------------------- class RunMonthly: """Gate pipeline execution to month starts.""" def __init__(self) -> None: self._last_month: tuple[int, int] | None = None def reset(self) -> None: self._last_month = None def __call__(self, ctx: PipelineContext) -> StepDecision: key = (ctx.date.year, ctx.date.month) if self._last_month == key: return StepDecision(status="skip_day", message="not month-start") self._last_month = key return StepDecision() class RunWeekly: """Gate pipeline execution to week starts (Monday).""" def __init__(self) -> None: self._last_week: tuple[int, int] | None = None def reset(self) -> None: self._last_week = None def __call__(self, ctx: PipelineContext) -> StepDecision: key = (ctx.date.isocalendar()[0], ctx.date.isocalendar()[1]) if self._last_week == key: return StepDecision(status="skip_day", message="not week-start") self._last_week = key return StepDecision() class RunQuarterly: """Gate pipeline execution to quarter starts.""" def __init__(self) -> None: self._last_quarter: tuple[int, int] | None = None def reset(self) -> None: self._last_quarter = None def __call__(self, ctx: PipelineContext) -> StepDecision: key = (ctx.date.year, (ctx.date.month - 1) // 3) if self._last_quarter == key: return StepDecision(status="skip_day", message="not quarter-start") self._last_quarter = key return StepDecision() class RunYearly: """Gate pipeline execution to year starts.""" def __init__(self) -> None: self._last_year: int | None = None def reset(self) -> None: self._last_year = None def __call__(self, ctx: PipelineContext) -> StepDecision: if self._last_year == ctx.date.year: return StepDecision(status="skip_day", message="not year-start") self._last_year = ctx.date.year return StepDecision() class RunDaily: """Allow pipeline execution on every date (no gating).""" def __call__(self, ctx: PipelineContext) -> StepDecision: return StepDecision() class RunOnce: """Execute pipeline only on the first date, skip all subsequent dates.""" def __init__(self) -> None: self._ran = False def reset(self) -> None: self._ran = False def __call__(self, ctx: PipelineContext) -> StepDecision: if self._ran: return StepDecision(status="skip_day", message="already ran") self._ran = True return StepDecision() class RunOnDate: """Execute pipeline only on specific dates.""" def __init__(self, dates: Sequence[str | pd.Timestamp]) -> None: self._dates = {pd.Timestamp(d).normalize() for d in dates} def __call__(self, ctx: PipelineContext) -> StepDecision: if ctx.date.normalize() not in self._dates: return StepDecision(status="skip_day", message="not a target date") return StepDecision() class RunAfterDate: """Execute pipeline only after a specific date (inclusive).""" def __init__(self, date: str | pd.Timestamp) -> None: self._date = pd.Timestamp(date).normalize() def __call__(self, ctx: PipelineContext) -> StepDecision: if ctx.date.normalize() < self._date: return StepDecision(status="skip_day", message="before start date") return StepDecision() class RunEveryNPeriods: """Execute pipeline every N trading days.""" def __init__(self, n: int) -> None: self._n = int(n) self._count = 0 def reset(self) -> None: self._count = 0 def __call__(self, ctx: PipelineContext) -> StepDecision: self._count += 1 if self._count % self._n != 1 and self._count != 1: return StepDecision(status="skip_day", message=f"period {self._count}, not every {self._n}") return StepDecision() class RunAfterDays: """Warmup gate: skip the first *n* trading days.""" def __init__(self, n: int) -> None: self._n = int(n) self._count = 0 def reset(self) -> None: self._count = 0 def __call__(self, ctx: PipelineContext) -> StepDecision: self._count += 1 if self._count <= self._n: return StepDecision(status="skip_day", message=f"warmup day {self._count}/{self._n}") return StepDecision() class RunIfOutOfBounds: """Trigger rebalance when any position drifts beyond *tolerance* from target. Typically used with ``Or``: ``Or(RunQuarterly(), RunIfOutOfBounds(0.05))``. Requires ``target_weights`` to have been set by a prior weighting algo on the *previous* rebalance (stored internally). """ def __init__(self, tolerance: float = 0.05) -> None: self._tolerance = float(tolerance) self._last_target: dict[str, float] = {} def reset(self) -> None: self._last_target = {} def __call__(self, ctx: PipelineContext) -> StepDecision: if not self._last_target: # No previous target — let downstream algos set it, then remember return StepDecision(status="skip_day", message="no prior target weights") total = float(ctx.total_capital) if total <= 0: return StepDecision(status="skip_day", message="no capital") for sym, target_w in self._last_target.items(): qty = ctx.positions.get(sym, 0.0) if sym in ctx.prices.index and pd.notna(ctx.prices[sym]): actual_w = float(qty) * float(ctx.prices[sym]) / total else: actual_w = 0.0 if abs(actual_w - target_w) > self._tolerance: return StepDecision() # out of bounds → allow rebalance return StepDecision(status="skip_day", message="all weights within bounds") def update_target(self, weights: dict[str, float]) -> None: """Call after a successful rebalance to remember the new target.""" self._last_target = dict(weights) class Or: """Logical OR combinator: pass if any child algo passes.""" def __init__(self, *algos: Algo) -> None: self._algos = algos def reset(self) -> None: for algo in self._algos: if hasattr(algo, "reset"): algo.reset() def __call__(self, ctx: PipelineContext) -> StepDecision: for algo in self._algos: decision = algo(ctx) if decision.status == "continue": return StepDecision() return StepDecision(status="skip_day", message="all sub-algos skipped") class Not: """Logical NOT combinator: invert the child algo's decision.""" def __init__(self, algo: Algo) -> None: self._algo = algo def reset(self) -> None: if hasattr(self._algo, "reset"): self._algo.reset() def __call__(self, ctx: PipelineContext) -> StepDecision: decision = self._algo(ctx) if decision.status == "skip_day": return StepDecision() return StepDecision(status="skip_day", message="inverted") # --------------------------------------------------------------------------- # Selection algos # --------------------------------------------------------------------------- class SelectThese: """Select a fixed list of symbols if priced on current date.""" def __init__(self, symbols: list[str]) -> None: self.symbols = [s.upper() for s in symbols] def __call__(self, ctx: PipelineContext) -> StepDecision: available = [s for s in self.symbols if s in ctx.prices.index and pd.notna(ctx.prices[s])] ctx.selected_symbols = available if not available: return StepDecision(status="skip_day", message="no selected symbols with valid prices") return StepDecision() class SelectAll: """Select all symbols with valid prices on current date.""" def __call__(self, ctx: PipelineContext) -> StepDecision: available = [s for s in ctx.prices.index if pd.notna(ctx.prices[s]) and float(ctx.prices[s]) > 0] ctx.selected_symbols = sorted(available) if not available: return StepDecision(status="skip_day", message="no symbols with valid prices") return StepDecision() class SelectHasData: """Select symbols that have at least *min_days* of price history.""" def __init__(self, min_days: int = 1) -> None: self._min_days = int(min_days) def __call__(self, ctx: PipelineContext) -> StepDecision: if ctx.price_history is None or ctx.price_history.empty: return StepDecision(status="skip_day", message="no price history") keep = [] for s in ctx.selected_symbols or list(ctx.prices.index): if s in ctx.price_history.columns: valid = ctx.price_history[s].dropna() if len(valid) >= self._min_days: keep.append(s) ctx.selected_symbols = keep if not keep: return StepDecision(status="skip_day", message=f"no symbols with {self._min_days}+ days") return StepDecision() class SelectMomentum: """Select top *n* symbols by trailing momentum (total return over *lookback* days).""" def __init__(self, n: int, lookback: int = 252, sort_descending: bool = True) -> None: self._n = int(n) self._lookback = int(lookback) self._sort_desc = sort_descending def __call__(self, ctx: PipelineContext) -> StepDecision: if ctx.price_history is None or ctx.price_history.empty: return StepDecision(status="skip_day", message="no price history for momentum") candidates = ctx.selected_symbols or [ s for s in ctx.prices.index if pd.notna(ctx.prices[s]) ] scores: dict[str, float] = {} for s in candidates: if s not in ctx.price_history.columns: continue series = ctx.price_history[s].dropna() if len(series) < 2: continue window = series.iloc[-self._lookback:] if len(window) < 2 or float(window.iloc[0]) <= 0: continue scores[s] = float(window.iloc[-1] / window.iloc[0] - 1) ranked = sorted(scores, key=scores.get, reverse=self._sort_desc) # type: ignore[arg-type] ctx.selected_symbols = ranked[: self._n] if not ctx.selected_symbols: return StepDecision(status="skip_day", message="no symbols with enough momentum data") return StepDecision() class SelectN: """Keep the first *n* symbols from current selection (stable order).""" def __init__(self, n: int) -> None: self._n = int(n) def __call__(self, ctx: PipelineContext) -> StepDecision: ctx.selected_symbols = ctx.selected_symbols[: self._n] if not ctx.selected_symbols: return StepDecision(status="skip_day", message="no symbols after SelectN") return StepDecision() class SelectRandomly: """Select *n* symbols at random from the current selection.""" def __init__(self, n: int, seed: int | None = None) -> None: self._n = int(n) self._rng = _random.Random(seed) def __call__(self, ctx: PipelineContext) -> StepDecision: candidates = ctx.selected_symbols or [ s for s in ctx.prices.index if pd.notna(ctx.prices[s]) ] if not candidates: return StepDecision(status="skip_day", message="no candidates for random selection") k = min(self._n, len(candidates)) ctx.selected_symbols = sorted(self._rng.sample(candidates, k)) return StepDecision() class SelectActive: """Filter out symbols whose price is zero or NaN (dead/expired).""" def __call__(self, ctx: PipelineContext) -> StepDecision: candidates = ctx.selected_symbols or list(ctx.prices.index) active = [ s for s in candidates if s in ctx.prices.index and pd.notna(ctx.prices[s]) and float(ctx.prices[s]) > 0 ] ctx.selected_symbols = active if not active: return StepDecision(status="skip_day", message="no active symbols") return StepDecision() class SelectRegex: """Select symbols whose name matches a regex pattern.""" def __init__(self, pattern: str) -> None: self._pattern = _re.compile(pattern) def __call__(self, ctx: PipelineContext) -> StepDecision: candidates = ctx.selected_symbols or list(ctx.prices.index) matched = [s for s in candidates if self._pattern.search(s)] ctx.selected_symbols = matched if not matched: return StepDecision(status="skip_day", message=f"no symbols match {self._pattern.pattern!r}") return StepDecision() class SelectWhere: """Select symbols where a user-defined function returns True.""" def __init__(self, fn: Callable[[str, PipelineContext], bool]) -> None: self._fn = fn def __call__(self, ctx: PipelineContext) -> StepDecision: candidates = ctx.selected_symbols or [ s for s in ctx.prices.index if pd.notna(ctx.prices[s]) ] ctx.selected_symbols = [s for s in candidates if self._fn(s, ctx)] if not ctx.selected_symbols: return StepDecision(status="skip_day", message="no symbols passed filter") return StepDecision() # --------------------------------------------------------------------------- # Weighting algos # --------------------------------------------------------------------------- class WeighSpecified: """Set fixed target weights, normalized over selected symbols.""" def __init__(self, weights: dict[str, float]) -> None: self.weights = {k.upper(): float(v) for k, v in weights.items()} def __call__(self, ctx: PipelineContext) -> StepDecision: if not ctx.selected_symbols: return StepDecision(status="skip_day", message="no selected symbols") raw = {s: self.weights.get(s, 0.0) for s in ctx.selected_symbols} total = float(sum(raw.values())) if total <= 0: return StepDecision(status="skip_day", message="target weights sum to zero") ctx.target_weights = {s: w / total for s, w in raw.items()} return StepDecision() class WeighEqually: """Equal-weight all selected symbols.""" def __call__(self, ctx: PipelineContext) -> StepDecision: if not ctx.selected_symbols: return StepDecision(status="skip_day", message="no selected symbols") w = 1.0 / len(ctx.selected_symbols) ctx.target_weights = {s: w for s in ctx.selected_symbols} return StepDecision() class WeighRandomly: """Assign random weights to selected symbols (normalized to sum to 1). Useful for constructing random benchmark strategies. """ def __init__(self, seed: int | None = None) -> None: self._rng = np.random.RandomState(seed) def __call__(self, ctx: PipelineContext) -> StepDecision: if not ctx.selected_symbols: return StepDecision(status="skip_day", message="no selected symbols") raw = self._rng.dirichlet(np.ones(len(ctx.selected_symbols))) ctx.target_weights = {s: float(w) for s, w in zip(ctx.selected_symbols, raw)} return StepDecision() class WeighTarget: """Read target weights from a pre-computed DataFrame indexed by date. *weights_df* should have dates as index and symbol names as columns. On each date, looks up the closest prior row. """ def __init__(self, weights_df: pd.DataFrame) -> None: self._weights = weights_df.sort_index() def __call__(self, ctx: PipelineContext) -> StepDecision: if not ctx.selected_symbols: return StepDecision(status="skip_day", message="no selected symbols") # Find the most recent row <= current date mask = self._weights.index <= ctx.date if not mask.any(): return StepDecision(status="skip_day", message="no weight data for this date") row = self._weights.loc[mask].iloc[-1] weights = {} for s in ctx.selected_symbols: if s in row.index and pd.notna(row[s]): weights[s] = float(row[s]) if not weights: return StepDecision(status="skip_day", message="no matching weights") total = sum(weights.values()) if total <= 0: return StepDecision(status="skip_day", message="weights sum to zero") ctx.target_weights = {s: w / total for s, w in weights.items()} return StepDecision() class WeighInvVol: """Inverse-volatility weighting (risk parity lite). Weight_i = (1/vol_i) / sum(1/vol_j). Uses trailing *lookback*-day returns standard deviation. """ def __init__(self, lookback: int = 252) -> None: self._lookback = int(lookback) def __call__(self, ctx: PipelineContext) -> StepDecision: if not ctx.selected_symbols: return StepDecision(status="skip_day", message="no selected symbols") if ctx.price_history is None or ctx.price_history.empty: return StepDecision(status="skip_day", message="no price history for inv-vol") inv_vols: dict[str, float] = {} for s in ctx.selected_symbols: if s not in ctx.price_history.columns: continue series = ctx.price_history[s].dropna() window = series.iloc[-self._lookback:] if len(window) < 3: continue rets = window.pct_change().dropna() vol = float(rets.std()) if vol > 0: inv_vols[s] = 1.0 / vol if not inv_vols: return StepDecision(status="skip_day", message="no valid vol data") total = sum(inv_vols.values()) ctx.target_weights = {s: v / total for s, v in inv_vols.items()} return StepDecision() class WeighMeanVar: """Mean-variance optimization (max Sharpe ratio portfolio). Uses trailing *lookback*-day returns. Falls back to equal weight if optimization fails (singular covariance, etc.). """ def __init__(self, lookback: int = 252, risk_free_rate: float = 0.0) -> None: self._lookback = int(lookback) self._rf = float(risk_free_rate) def __call__(self, ctx: PipelineContext) -> StepDecision: if not ctx.selected_symbols: return StepDecision(status="skip_day", message="no selected symbols") if ctx.price_history is None or ctx.price_history.empty: return StepDecision(status="skip_day", message="no price history for mean-var") syms = [s for s in ctx.selected_symbols if s in ctx.price_history.columns] if len(syms) < 1: return StepDecision(status="skip_day", message="no price history columns match") prices = ctx.price_history[syms].dropna() if len(prices) < 3: return StepDecision(status="skip_day", message="insufficient data for mean-var") rets = prices.iloc[-self._lookback:].pct_change().dropna() if len(rets) < 3: return StepDecision(status="skip_day", message="insufficient returns for mean-var") mu = rets.mean().values cov = rets.cov().values n = len(syms) try: cov_inv = np.linalg.inv(cov) except np.linalg.LinAlgError: # Singular covariance — fall back to equal weight w = 1.0 / n ctx.target_weights = {s: w for s in syms} return StepDecision() excess = mu - self._rf / 252 raw_w = cov_inv @ excess # Normalize to sum to 1, allow short positions only if naturally arising total = float(np.sum(np.abs(raw_w))) if total <= 0: w = 1.0 / n ctx.target_weights = {s: w for s in syms} return StepDecision() # Long-only: clip negatives, renormalize clipped = np.maximum(raw_w, 0.0) clip_sum = float(np.sum(clipped)) if clip_sum <= 0: w = 1.0 / n ctx.target_weights = {s: w for s in syms} return StepDecision() weights = clipped / clip_sum ctx.target_weights = {s: float(weights[i]) for i, s in enumerate(syms)} return StepDecision() class WeighERC: """Equal Risk Contribution weighting. Each asset contributes equally to portfolio risk. Uses iterative bisection approximation. """ def __init__(self, lookback: int = 252, max_iter: int = 100) -> None: self._lookback = int(lookback) self._max_iter = int(max_iter) def __call__(self, ctx: PipelineContext) -> StepDecision: if not ctx.selected_symbols: return StepDecision(status="skip_day", message="no selected symbols") if ctx.price_history is None or ctx.price_history.empty: return StepDecision(status="skip_day", message="no price history for ERC") syms = [s for s in ctx.selected_symbols if s in ctx.price_history.columns] if len(syms) < 1: return StepDecision(status="skip_day", message="no matching columns") prices = ctx.price_history[syms].dropna() rets = prices.iloc[-self._lookback:].pct_change().dropna() if len(rets) < 3: return StepDecision(status="skip_day", message="insufficient data for ERC") cov = rets.cov().values n = len(syms) # Start with equal weights w = np.ones(n) / n for _ in range(self._max_iter): sigma = np.sqrt(float(w @ cov @ w)) if sigma <= 0: break mrc = (cov @ w) / sigma # marginal risk contribution rc = w * mrc # risk contribution target_rc = sigma / n # Adjust: increase weight of under-contributing, decrease over-contributing adj = target_rc / np.maximum(rc, 1e-12) w = w * adj w = np.maximum(w, 0.0) w_sum = float(np.sum(w)) if w_sum > 0: w = w / w_sum ctx.target_weights = {s: float(w[i]) for i, s in enumerate(syms)} return StepDecision() class TargetVol: """Scale weights to target a specific annualized portfolio volatility. Scales the existing target_weights by (target_vol / realized_vol). Excess weight goes to cash. """ def __init__(self, target: float = 0.10, lookback: int = 252) -> None: self._target = float(target) self._lookback = int(lookback) def __call__(self, ctx: PipelineContext) -> StepDecision: if not ctx.target_weights: return StepDecision(status="skip_day", message="no target weights to scale") if ctx.price_history is None or ctx.price_history.empty: return StepDecision(status="skip_day", message="no price history for vol scaling") syms = list(ctx.target_weights.keys()) available = [s for s in syms if s in ctx.price_history.columns] if not available: return StepDecision(status="skip_day", message="no price data for vol scaling") prices = ctx.price_history[available].dropna() rets = prices.iloc[-self._lookback:].pct_change().dropna() if len(rets) < 3: return StepDecision() # not enough data, pass through unchanged weights_arr = np.array([ctx.target_weights.get(s, 0.0) for s in available]) port_rets = rets.values @ weights_arr realized_vol = float(np.std(port_rets) * np.sqrt(252)) if realized_vol <= 0: return StepDecision() scale = min(self._target / realized_vol, 1.0) # never lever above 1.0 ctx.target_weights = {s: w * scale for s, w in ctx.target_weights.items()} return StepDecision() # --------------------------------------------------------------------------- # Weight limits # --------------------------------------------------------------------------- class LimitWeights: """Cap individual position weights and renormalize.""" def __init__(self, limit: float = 0.25) -> None: self._limit = float(limit) def __call__(self, ctx: PipelineContext) -> StepDecision: if not ctx.target_weights: return StepDecision() # Iteratively clip and renormalize (may need multiple passes) weights = dict(ctx.target_weights) for _ in range(10): over = {s: w for s, w in weights.items() if w > self._limit} if not over: break under = {s: w for s, w in weights.items() if w <= self._limit} for s in over: weights[s] = self._limit under_sum = sum(under.values()) over_excess = sum(w - self._limit for w in over.values()) if under_sum > 0: scale = 1.0 + over_excess / under_sum for s in under: weights[s] = weights[s] * scale ctx.target_weights = weights return StepDecision() class LimitDeltas: """Cap how much any single weight can change between rebalances. On each call, computes the current portfolio weights from positions and clips ``target_weights`` so no weight moves more than *limit* from its current value. Excess is redistributed proportionally. """ def __init__(self, limit: float = 0.10) -> None: self._limit = float(limit) def __call__(self, ctx: PipelineContext) -> StepDecision: if not ctx.target_weights: return StepDecision() total = float(ctx.total_capital) if total <= 0: return StepDecision() # Compute current weights from positions current: dict[str, float] = {} for sym in ctx.target_weights: qty = ctx.positions.get(sym, 0.0) if sym in ctx.prices.index and pd.notna(ctx.prices[sym]): current[sym] = float(qty) * float(ctx.prices[sym]) / total else: current[sym] = 0.0 # Clip deltas clipped: dict[str, float] = {} for sym, target_w in ctx.target_weights.items(): cur_w = current.get(sym, 0.0) delta = target_w - cur_w clamped = max(-self._limit, min(self._limit, delta)) clipped[sym] = cur_w + clamped # Renormalize to sum to original target sum orig_sum = sum(ctx.target_weights.values()) clip_sum = sum(clipped.values()) if clip_sum > 0 and orig_sum > 0: scale = orig_sum / clip_sum clipped = {s: w * scale for s, w in clipped.items()} ctx.target_weights = clipped return StepDecision() class ScaleWeights: """Multiply all target weights by a scalar. Useful for leverage (scale > 1) or de-leverage (scale < 1). Excess weight goes to cash. """ def __init__(self, scale: float) -> None: self._scale = float(scale) def __call__(self, ctx: PipelineContext) -> StepDecision: if not ctx.target_weights: return StepDecision() ctx.target_weights = {s: w * self._scale for s, w in ctx.target_weights.items()} return StepDecision() # --------------------------------------------------------------------------- # Capital flows # --------------------------------------------------------------------------- class CapitalFlow: """Model periodic capital additions (+) or withdrawals (-). *flows* is a dict mapping dates to amounts, or a callable ``(date: pd.Timestamp) -> float`` returning the flow amount. """ def __init__(self, flows: dict[str | pd.Timestamp, float] | Callable[[pd.Timestamp], float]) -> None: if callable(flows): self._fn = flows else: mapping = {pd.Timestamp(k).normalize(): float(v) for k, v in flows.items()} self._fn = lambda d: mapping.get(d.normalize(), 0.0) def __call__(self, ctx: PipelineContext) -> StepDecision: amount = self._fn(ctx.date) if amount != 0.0: ctx.cash = float(ctx.cash + amount) ctx.total_capital = float(ctx.total_capital + amount) return StepDecision() # --------------------------------------------------------------------------- # Risk guards # --------------------------------------------------------------------------- class MaxDrawdownGuard: """Block new rebalances while drawdown exceeds threshold.""" def __init__(self, max_drawdown_pct: float) -> None: self.max_drawdown_pct = float(max_drawdown_pct) self._peak = 0.0 def reset(self) -> None: self._peak = 0.0 def __call__(self, ctx: PipelineContext) -> StepDecision: self._peak = max(self._peak, float(ctx.total_capital)) if self._peak <= 0: return StepDecision() dd = (self._peak - float(ctx.total_capital)) / self._peak if dd > self.max_drawdown_pct: return StepDecision(status="skip_day", message=f"drawdown {dd:.2%} > {self.max_drawdown_pct:.2%}") return StepDecision() class HedgeRisks: """Adjust target weights to hedge portfolio Greeks toward targets. Uses a Jacobian-based approach: for each hedge instrument, compute partial derivatives (delta/vega per unit weight), then solve the linear system to find weight adjustments that bring portfolio Greeks closest to targets. Expects ``ctx.prices`` to contain columns for hedge instruments and ``ctx.price_history`` to be available for estimating betas (used as a proxy for delta when true Greeks are unavailable). """ def __init__( self, target_delta: float = 0.0, target_vega: float = 0.0, hedge_symbols: list[str] | None = None, ) -> None: self.target_delta = float(target_delta) self.target_vega = float(target_vega) self.hedge_symbols = hedge_symbols def __call__(self, ctx: PipelineContext) -> StepDecision: if not ctx.target_weights: return StepDecision(status="skip_day", message="no target weights to hedge") # Determine hedge instruments hedgers = self.hedge_symbols or ctx.selected_symbols if not hedgers: return StepDecision(status="skip_day", message="no hedge symbols") hedgers = [s for s in hedgers if s in ctx.prices.index and pd.notna(ctx.prices[s])] if not hedgers: return StepDecision(status="skip_day", message="no valid hedge symbols") if ctx.price_history is None or len(ctx.price_history) < 3: return StepDecision(status="skip_day", message="insufficient history for hedge") # Estimate current portfolio delta/vega using trailing returns correlation port_syms = [s for s in ctx.target_weights if s in ctx.price_history.columns] if not port_syms: return StepDecision() rets = ctx.price_history[list(set(port_syms + hedgers))].pct_change().dropna() if len(rets) < 3: return StepDecision(status="skip_day", message="insufficient returns for hedge") # Portfolio delta ~ sum(weight_i * beta_i), using beta = std_i as proxy port_delta = 0.0 for s in port_syms: if s in rets.columns: port_delta += ctx.target_weights.get(s, 0.0) * float(rets[s].std()) delta_gap = self.target_delta - port_delta # Build Jacobian: each hedger's marginal delta contribution n = len(hedgers) jacobian = np.zeros((1, n)) for j, h in enumerate(hedgers): if h in rets.columns: jacobian[0, j] = float(rets[h].std()) target_vec = np.array([delta_gap]) try: adjustments, _, _, _ = np.linalg.lstsq(jacobian, target_vec, rcond=None) except np.linalg.LinAlgError: return StepDecision(message="hedge solve failed, weights unchanged") for j, h in enumerate(hedgers): current = ctx.target_weights.get(h, 0.0) ctx.target_weights[h] = current + float(adjustments[j]) return StepDecision(message=f"hedged delta gap={delta_gap:.4f}") class Margin: """Simulate margin/leverage with interest charges and margin calls. Allows total exposure to exceed cash by borrowing. Charges daily interest on borrowed amount. If equity drops below maintenance_pct of total exposure, forces liquidation via "stop". """ def __init__( self, leverage: float = 2.0, interest_rate: float = 0.02, maintenance_pct: float = 0.25, ) -> None: self.leverage = float(leverage) self.interest_rate = float(interest_rate) self.maintenance_pct = float(maintenance_pct) self._borrowed = 0.0 def reset(self) -> None: self._borrowed = 0.0 def __call__(self, ctx: PipelineContext) -> StepDecision: # Compute current stock value from positions stock_value = 0.0 for sym, qty in ctx.positions.items(): if sym in ctx.prices.index and pd.notna(ctx.prices[sym]): stock_value += float(qty) * float(ctx.prices[sym]) self._borrowed = max(0.0, stock_value - float(ctx.cash)) # Charge daily interest on borrowed amount if self._borrowed > 0: daily_interest = self.interest_rate / 252.0 * self._borrowed ctx.cash = float(ctx.cash) - daily_interest ctx.total_capital = float(ctx.total_capital) - daily_interest # Margin call check: equity vs total exposure equity = float(ctx.cash) + stock_value exposure = stock_value if exposure > 0 and equity / exposure < self.maintenance_pct: return StepDecision(status="stop", message=f"margin call: equity/exposure={equity / exposure:.2%}") # Scale target weights by leverage factor if ctx.target_weights: ctx.target_weights = {s: w * self.leverage for s, w in ctx.target_weights.items()} return StepDecision() class CouponPayingPosition: """Inject periodic coupon cash flows into the portfolio. Simulates a fixed-income position by adding coupon_amount to cash at the specified frequency. Returns "stop" after maturity. """ _FREQUENCY_MONTHS = { "annual": 12, "semi-annual": 6, "quarterly": 3, "monthly": 1, } def __init__( self, coupon_amount: float, frequency: str = "semi-annual", start_date: str | pd.Timestamp | None = None, maturity_date: str | pd.Timestamp | None = None, ) -> None: self.coupon_amount = float(coupon_amount) if frequency not in self._FREQUENCY_MONTHS: raise ValueError(f"frequency must be one of {list(self._FREQUENCY_MONTHS)}") self.frequency = frequency self._months = self._FREQUENCY_MONTHS[frequency] self.start_date = pd.Timestamp(start_date) if start_date else None self.maturity_date = pd.Timestamp(maturity_date) if maturity_date else None self._last_coupon_month: tuple[int, int] | None = None def reset(self) -> None: self._last_coupon_month = None def __call__(self, ctx: PipelineContext) -> StepDecision: # Check maturity if self.maturity_date and ctx.date >= self.maturity_date: ctx.cash = float(ctx.cash) + self.coupon_amount # final coupon ctx.total_capital = float(ctx.total_capital) + self.coupon_amount return StepDecision(status="stop", message="bond matured") # Check start date if self.start_date and ctx.date < self.start_date: return StepDecision() # Check if this is a coupon month month_key = (ctx.date.year, ctx.date.month) if self._last_coupon_month == month_key: return StepDecision() # Determine if enough months have passed since last coupon if self._last_coupon_month is not None: last_y, last_m = self._last_coupon_month months_elapsed = (ctx.date.year - last_y) * 12 + (ctx.date.month - last_m) if months_elapsed < self._months: return StepDecision() # Pay coupon self._last_coupon_month = month_key ctx.cash = float(ctx.cash) + self.coupon_amount ctx.total_capital = float(ctx.total_capital) + self.coupon_amount return StepDecision(message=f"coupon paid: ${self.coupon_amount:.2f}") class ReplayTransactions: """Replay a pre-recorded trade blotter through the pipeline. Takes a DataFrame with columns: date, symbol, quantity (positive=buy, negative=sell). On each matching date, executes the recorded trades at current prices. """ def __init__(self, blotter: pd.DataFrame) -> None: required = {"date", "symbol", "quantity"} missing = required - set(blotter.columns) if missing: raise ValueError(f"blotter missing columns: {missing}") self._blotter = blotter.copy() self._blotter["date"] = pd.to_datetime(self._blotter["date"]).dt.normalize() def __call__(self, ctx: PipelineContext) -> StepDecision: day_trades = self._blotter[self._blotter["date"] == ctx.date.normalize()] if day_trades.empty: return StepDecision() executed = 0 for _, trade in day_trades.iterrows(): sym = str(trade["symbol"]).upper() qty = float(trade["quantity"]) if sym not in ctx.prices.index or pd.isna(ctx.prices[sym]): continue price = float(ctx.prices[sym]) if price <= 0: continue cost = qty * price ctx.cash = float(ctx.cash) - cost ctx.total_capital = float(ctx.total_capital) # recalc handled by backtester current_qty = ctx.positions.get(sym, 0.0) new_qty = current_qty + qty if new_qty == 0: ctx.positions.pop(sym, None) else: ctx.positions[sym] = new_qty executed += 1 return StepDecision(message=f"replayed {executed} trades") # --------------------------------------------------------------------------- # Position management # --------------------------------------------------------------------------- class CloseDead: """Close positions where price has dropped to zero or is NaN. Removes dead positions and frees up the capital (at zero value). """ def __call__(self, ctx: PipelineContext) -> StepDecision: dead = [] for sym, qty in ctx.positions.items(): if sym not in ctx.prices.index or pd.isna(ctx.prices[sym]) or float(ctx.prices[sym]) <= 0: dead.append(sym) for sym in dead: del ctx.positions[sym] if dead: return StepDecision(message=f"closed dead: {', '.join(dead)}") return StepDecision() class ClosePositionsAfterDates: """Close specific positions on or after given dates. *schedule* maps symbol names to the date after which they should be closed. """ def __init__(self, schedule: dict[str, str | pd.Timestamp]) -> None: self._schedule = {s.upper(): pd.Timestamp(d).normalize() for s, d in schedule.items()} def __call__(self, ctx: PipelineContext) -> StepDecision: closed = [] for sym, close_date in self._schedule.items(): if ctx.date.normalize() >= close_date and sym in ctx.positions: del ctx.positions[sym] closed.append(sym) if closed: return StepDecision(message=f"closed after date: {', '.join(closed)}") return StepDecision() class Require: """Guard: only continue if the wrapped algo returns 'continue'. Unlike normal pipeline flow, ``Require`` runs the inner algo but does NOT break the pipeline on skip — it only checks whether the algo *would* have passed. Use it to conditionally gate downstream steps. """ def __init__(self, algo: Algo) -> None: self._algo = algo def reset(self) -> None: if hasattr(self._algo, "reset"): self._algo.reset() def __call__(self, ctx: PipelineContext) -> StepDecision: decision = self._algo(ctx) if decision.status != "continue": return StepDecision(status="skip_day", message=f"requirement not met: {decision.message}") return StepDecision() # --------------------------------------------------------------------------- # Rebalancing algos # --------------------------------------------------------------------------- class Rebalance: """Rebalance positions to target weights at current prices. Performs a full liquidate-and-rebuy on each rebalance date. """ def __call__(self, ctx: PipelineContext) -> StepDecision: if not ctx.target_weights: return StepDecision(status="skip_day", message="no target weights") new_positions: dict[str, float] = {} spent = 0.0 for sym, w in ctx.target_weights.items(): price = float(ctx.prices[sym]) if price <= 0: continue target_value = float(ctx.total_capital) * w qty = float(np.floor(target_value / price)) new_positions[sym] = qty spent += qty * price ctx.positions.clear() ctx.positions.update(new_positions) ctx.cash = float(ctx.total_capital - spent) return StepDecision() class RebalanceOverTime: """Spread rebalancing over *n* periods to reduce market impact. On each trigger, moves 1/n of the way from current to target weights. Must be preceded by a scheduling algo and a weighting algo. """ def __init__(self, n: int = 5) -> None: self._n = int(n) self._target: dict[str, float] = {} self._remaining = 0 def reset(self) -> None: self._target = {} self._remaining = 0 def __call__(self, ctx: PipelineContext) -> StepDecision: # If new target weights are set, start a new gradual rebalance if ctx.target_weights and ctx.target_weights != self._target: self._target = dict(ctx.target_weights) self._remaining = self._n if self._remaining <= 0 or not self._target: return StepDecision(status="skip_day", message="no gradual rebalance in progress") # Compute current weights from positions total = float(ctx.total_capital) if total <= 0: return StepDecision(status="skip_day", message="no capital") current_weights: dict[str, float] = {} all_syms = set(self._target.keys()) | set(ctx.positions.keys()) for sym in all_syms: qty = ctx.positions.get(sym, 0.0) if sym in ctx.prices.index and pd.notna(ctx.prices[sym]): current_weights[sym] = float(qty) * float(ctx.prices[sym]) / total else: current_weights[sym] = 0.0 # Move fraction of the way toward target frac = 1.0 / self._remaining blended: dict[str, float] = {} for sym in all_syms: cur = current_weights.get(sym, 0.0) tgt = self._target.get(sym, 0.0) blended[sym] = cur + frac * (tgt - cur) # Apply blended weights new_positions: dict[str, float] = {} spent = 0.0 for sym, w in blended.items(): if sym not in ctx.prices.index or pd.isna(ctx.prices[sym]): continue price = float(ctx.prices[sym]) if price <= 0: continue target_value = total * w qty = float(np.floor(target_value / price)) if qty > 0: new_positions[sym] = qty spent += qty * price ctx.positions.clear() ctx.positions.update(new_positions) ctx.cash = float(total - spent) self._remaining -= 1 return StepDecision() class AlgoPipelineBacktester: """Simple stock backtester driven by composable pipeline algos.""" def __init__( self, prices: pd.DataFrame, algos: list[Algo], initial_capital: float = 1_000_000.0, ) -> None: self.prices = prices.sort_index() self.algos = algos self.initial_capital = float(initial_capital) self.logs: list[PipelineLogRow] = [] def run(self) -> pd.DataFrame: self.logs = [] for algo in self.algos: if hasattr(algo, "reset"): algo.reset() cash = float(self.initial_capital) positions: dict[str, float] = {} rows: list[dict[str, float | pd.Timestamp]] = [] all_dates = list(self.prices.index) for i, (date, price_row) in enumerate(self.prices.iterrows()): stocks_cap = float(sum(float(qty) * float(price_row[sym]) for sym, qty in positions.items() if sym in price_row.index and pd.notna(price_row[sym]))) total_cap = cash + stocks_cap # Price history up to current date (for algos that need lookback) history = self.prices.iloc[:i + 1] if i > 0 else self.prices.iloc[:1] ctx = PipelineContext( date=pd.Timestamp(date), prices=price_row, total_capital=total_cap, cash=cash, positions=dict(positions), price_history=history, ) stop_all = False for algo in self.algos: decision = algo(ctx) self.logs.append( PipelineLogRow( date=pd.Timestamp(date), step=algo.__class__.__name__, status=decision.status, message=decision.message, ) ) if decision.status == "skip_day": break if decision.status == "stop": stop_all = True break cash = float(ctx.cash) positions = dict(ctx.positions) stocks_cap = float(sum(float(qty) * float(price_row[sym]) for sym, qty in positions.items() if sym in price_row.index and pd.notna(price_row[sym]))) total_cap = cash + stocks_cap row: dict[str, float | pd.Timestamp] = { "date": pd.Timestamp(date), "cash": cash, "stocks capital": stocks_cap, "total capital": total_cap, } for sym, qty in positions.items(): row[f"{sym} qty"] = float(qty) rows.append(row) if stop_all: break if not rows: balance = pd.DataFrame() self.balance = balance return balance balance = pd.DataFrame(rows).set_index("date") if not balance.empty: balance["% change"] = balance["total capital"].pct_change() balance["accumulated return"] = (1.0 + balance["% change"]).cumprod() self.balance = balance return balance def set_date_range(self, start=None, end=None): """Filter results to date range, return new BacktestStats.""" from options_portfolio_backtester.analytics.stats import BacktestStats return BacktestStats.from_balance_range(self.balance, start, end) def logs_dataframe(self) -> pd.DataFrame: if not self.logs: return pd.DataFrame(columns=["date", "step", "status", "message"]) return pd.DataFrame([{ "date": r.date, "step": r.step, "status": r.status, "message": r.message, } for r in self.logs]) # --------------------------------------------------------------------------- # Random benchmarking # --------------------------------------------------------------------------- @dataclass(frozen=True) class RandomBenchmarkResult: """Result of ``benchmark_random``: your strategy vs random portfolios.""" strategy_return: float random_returns: list[float] percentile: float # what % of random runs your strategy beat @property def mean_random(self) -> float: return float(np.mean(self.random_returns)) @property def std_random(self) -> float: return float(np.std(self.random_returns)) def benchmark_random( prices: pd.DataFrame, strategy_algos: list[Algo], n_random: int = 100, initial_capital: float = 1_000_000.0, seed: int = 42, ) -> RandomBenchmarkResult: """Compare a strategy against *n_random* random-weight portfolios. Runs the given strategy once, then runs *n_random* simulations with ``SelectAll → WeighRandomly → Rebalance`` on the same price data. Returns a ``RandomBenchmarkResult`` with the strategy's total return, the distribution of random returns, and the percentile rank. """ # Run the target strategy bt = AlgoPipelineBacktester(prices=prices, algos=strategy_algos, initial_capital=initial_capital) bal = bt.run() if bal.empty: strat_ret = 0.0 else: strat_ret = float(bal["total capital"].iloc[-1] / bal["total capital"].iloc[0] - 1) # Run random strategies random_rets: list[float] = [] for i in range(n_random): random_algos: list[Algo] = [ RunMonthly(), SelectAll(), WeighRandomly(seed=seed + i), Rebalance(), ] rbt = AlgoPipelineBacktester(prices=prices, algos=random_algos, initial_capital=initial_capital) rbal = rbt.run() if rbal.empty: random_rets.append(0.0) else: random_rets.append(float(rbal["total capital"].iloc[-1] / rbal["total capital"].iloc[0] - 1)) beaten = sum(1 for r in random_rets if strat_ret > r) pct = beaten / max(len(random_rets), 1) * 100 return RandomBenchmarkResult( strategy_return=strat_ret, random_returns=random_rets, percentile=pct, ) ================================================ FILE: options_portfolio_backtester/engine/strategy_tree.py ================================================ """Hierarchical strategy tree runner.""" from __future__ import annotations from dataclasses import dataclass, field import pandas as pd from options_portfolio_backtester.engine.engine import BacktestEngine @dataclass class StrategyTreeNode: """Node in a capital-allocation strategy tree.""" name: str weight: float = 1.0 max_share: float | None = None engine: BacktestEngine | None = None children: list["StrategyTreeNode"] = field(default_factory=list) def __post_init__(self) -> None: if self.engine is not None and self.children: raise ValueError( f"StrategyTreeNode '{self.name}' has both engine and children; " "a node must be either a leaf (engine) or a branch (children), not both" ) def is_leaf(self) -> bool: return self.engine is not None def to_dot(self) -> str: """Generate Graphviz DOT string for this subtree.""" lines = [ "digraph StrategyTree {", " rankdir=TB;", ' node [style=filled, fillcolor=lightyellow];', ] self._dot_recursive(lines, parent_id=None) lines.append("}") return "\n".join(lines) def _dot_recursive(self, lines: list[str], parent_id: str | None) -> None: node_id = f"n{id(self)}" label = f"{self.name}\\nw={self.weight}" if self.max_share is not None: label += f"\\nmax={self.max_share}" shape = "ellipse" if self.is_leaf() else "box" lines.append(f' {node_id} [label="{label}", shape={shape}];') if parent_id: lines.append(f" {parent_id} -> {node_id};") for child in self.children: child._dot_recursive(lines, node_id) class StrategyTreeEngine: """Run leaf engines with capital shares implied by tree weights.""" def __init__(self, root: StrategyTreeNode, initial_capital: int = 1_000_000) -> None: self.root = root self.initial_capital = initial_capital self.throttles: dict[str, dict[str, float]] = {} def to_dot(self) -> str: """Generate Graphviz DOT string for the strategy tree.""" return self.root.to_dot() def _leaf_shares(self, node: StrategyTreeNode, parent_share: float) -> list[tuple[StrategyTreeNode, float]]: if node.is_leaf(): capped = min(parent_share, node.max_share) if node.max_share is not None else parent_share if capped < parent_share: self.throttles[node.name] = {"requested_share": parent_share, "applied_share": capped} return [(node, capped)] if not node.children: return [] total = sum(c.weight for c in node.children) if total <= 0: return [] out: list[tuple[StrategyTreeNode, float]] = [] for child in node.children: out.extend(self._leaf_shares(child, parent_share * (child.weight / total))) return out def run(self, rebalance_freq: int = 0, monthly: bool = False, sma_days: int | None = None) -> dict[str, pd.DataFrame]: leaf_allocs = self._leaf_shares(self.root, 1.0) results: dict[str, pd.DataFrame] = {} self.leaf_weights = {leaf.name: w for leaf, w in leaf_allocs} self.attribution = {} allocated_share = float(sum(w for _, w in leaf_allocs)) unallocated_share = max(0.0, 1.0 - allocated_share) balances: list[pd.DataFrame] = [] for leaf, share in leaf_allocs: cap = round(self.initial_capital * share) saved_capital = leaf.engine.initial_capital leaf.engine.initial_capital = cap trade_log = leaf.engine.run(rebalance_freq=rebalance_freq, monthly=monthly, sma_days=sma_days) leaf.engine.initial_capital = saved_capital results[leaf.name] = trade_log self.attribution[leaf.name] = { "weight": share, "capital": cap, } b = leaf.engine.balance[["total capital"]].rename(columns={"total capital": f"{leaf.name}_capital"}) balances.append(b) if balances: self.balance = pd.concat(balances, axis=1) cap_cols = [c for c in self.balance.columns if c.endswith("_capital")] self.balance["unallocated_cash"] = float(self.initial_capital * unallocated_share) self.balance["total capital"] = self.balance[cap_cols].sum(axis=1) + self.balance["unallocated_cash"] self.balance["% change"] = self.balance["total capital"].pct_change() self.balance["accumulated return"] = (1.0 + self.balance["% change"]).cumprod() else: self.balance = pd.DataFrame() return results ================================================ FILE: options_portfolio_backtester/execution/__init__.py ================================================ ================================================ FILE: options_portfolio_backtester/execution/_rust_bridge.py ================================================ """Rust execution functions from _ob_rust.""" from options_portfolio_backtester._ob_rust import ( rust_option_cost, rust_stock_cost, rust_fill_price, rust_nearest_delta_index, rust_max_value_index, rust_risk_check, ) ================================================ FILE: options_portfolio_backtester/execution/cost_model.py ================================================ """Transaction cost models for options and stocks.""" from __future__ import annotations from abc import ABC, abstractmethod from options_portfolio_backtester.execution._rust_bridge import ( rust_option_cost, rust_stock_cost, ) class TransactionCostModel(ABC): """Base class for all transaction cost models.""" @abstractmethod def option_cost(self, price: float, quantity: int, shares_per_contract: int) -> float: """Return total commission for an options trade.""" ... @abstractmethod def stock_cost(self, price: float, quantity: float) -> float: """Return total commission for a stock trade.""" ... class NoCosts(TransactionCostModel): """Zero transaction costs — matches original behavior.""" def option_cost(self, price: float, quantity: int, shares_per_contract: int) -> float: return 0.0 def stock_cost(self, price: float, quantity: float) -> float: return 0.0 def to_rust_config(self) -> dict: return {"type": "NoCosts"} class PerContractCommission(TransactionCostModel): """Fixed per-contract commission (e.g., $0.65/contract for IBKR).""" def __init__(self, rate: float = 0.65, stock_rate: float = 0.005) -> None: self.rate = rate self.stock_rate = stock_rate # per-share def option_cost(self, price: float, quantity: int, shares_per_contract: int) -> float: return rust_option_cost("PerContract", self.rate, self.stock_rate, [], price, float(quantity), shares_per_contract) def stock_cost(self, price: float, quantity: float) -> float: return rust_stock_cost("PerContract", self.rate, self.stock_rate, [], price, float(quantity)) def to_rust_config(self) -> dict: return {"type": "PerContract", "rate": self.rate, "stock_rate": self.stock_rate} class TieredCommission(TransactionCostModel): """Tiered commission schedule with volume discounts. Tiers are (max_contracts, rate) pairs sorted by max_contracts ascending. Contracts beyond the last tier use the last tier's rate. """ def __init__(self, tiers: list[tuple[int, float]] | None = None, stock_rate: float = 0.005) -> None: # Default: IBKR-style tiers self.tiers = tiers or [ (10_000, 0.65), (50_000, 0.50), (100_000, 0.25), ] self.stock_rate = stock_rate def option_cost(self, price: float, quantity: int, shares_per_contract: int) -> float: return rust_option_cost("Tiered", 0.0, self.stock_rate, self.tiers, price, float(quantity), shares_per_contract) def stock_cost(self, price: float, quantity: float) -> float: return rust_stock_cost("Tiered", 0.0, self.stock_rate, self.tiers, price, float(quantity)) def to_rust_config(self) -> dict: return { "type": "Tiered", "tiers": [(max_qty, rate) for max_qty, rate in self.tiers], "stock_rate": self.stock_rate, } class SpreadSlippage(TransactionCostModel): """Model slippage as a fraction of the bid-ask spread. Example: SpreadSlippage(pct=0.5) means you pay half the spread on top of the execution price. """ def __init__(self, pct: float = 0.5) -> None: assert 0.0 <= pct <= 1.0 self.pct = pct def option_cost(self, price: float, quantity: int, shares_per_contract: int) -> float: # Slippage is modeled separately via fill_model; this returns 0 # so it can be composed with a commission model. return 0.0 def stock_cost(self, price: float, quantity: float) -> float: return 0.0 def slippage(self, bid: float, ask: float, quantity: int, shares_per_contract: int) -> float: """Compute dollar slippage from the spread.""" spread = abs(ask - bid) return self.pct * spread * abs(quantity) * shares_per_contract ================================================ FILE: options_portfolio_backtester/execution/fill_model.py ================================================ """Fill models — determine the execution price for trades.""" from __future__ import annotations from abc import ABC, abstractmethod import pandas as pd from options_portfolio_backtester.core.types import Direction from options_portfolio_backtester.execution._rust_bridge import rust_fill_price class FillModel(ABC): """Determines the price at which a trade is filled.""" @abstractmethod def get_fill_price(self, row: pd.Series, direction: Direction) -> float: """Return the execution price for a given option quote row and direction.""" ... class MarketAtBidAsk(FillModel): """Fill at the bid (sell) or ask (buy) — matches original behavior.""" def get_fill_price(self, row: pd.Series, direction: Direction) -> float: return float(row[direction.price_column]) def to_rust_config(self) -> dict: return {"type": "MarketAtBidAsk"} class MidPrice(FillModel): """Fill at the midpoint of bid and ask.""" def get_fill_price(self, row: pd.Series, direction: Direction) -> float: bid = float(row["bid"]) ask = float(row["ask"]) return (bid + ask) / 2.0 def to_rust_config(self) -> dict: return {"type": "MidPrice"} class VolumeAwareFill(FillModel): """Fill price that adjusts for volume impact. For low-volume contracts, the fill is pushed toward the less favorable price. Above `full_volume_threshold`, the fill is at bid/ask. """ def __init__(self, full_volume_threshold: int = 100) -> None: self.full_volume_threshold = full_volume_threshold def get_fill_price(self, row: pd.Series, direction: Direction) -> float: bid = float(row["bid"]) ask = float(row["ask"]) is_buy = direction == Direction.BUY vol_raw = row.get("volume") volume = None if vol_raw is None or (isinstance(vol_raw, float) and vol_raw != vol_raw) else float(vol_raw) return rust_fill_price("VolumeAware", self.full_volume_threshold, bid, ask, volume, is_buy) def to_rust_config(self) -> dict: return {"type": "VolumeAware", "full_volume_threshold": self.full_volume_threshold} ================================================ FILE: options_portfolio_backtester/execution/signal_selector.py ================================================ """Signal selectors — choose which contract to trade from a set of candidates.""" from __future__ import annotations from abc import ABC, abstractmethod import pandas as pd from options_portfolio_backtester.execution._rust_bridge import ( rust_nearest_delta_index, rust_max_value_index, ) class SignalSelector(ABC): """Picks one entry signal from a DataFrame of candidates.""" @property def column_requirements(self) -> list[str]: """Extra columns needed from raw options data beyond standard signal fields.""" return [] @abstractmethod def select(self, candidates: pd.DataFrame) -> pd.Series: """Return the single row (as Series) to execute from candidates. Args: candidates: DataFrame of entry signals, pre-sorted if entry_sort was set. Returns: A single row (pd.Series) from candidates. """ ... class FirstMatch(SignalSelector): """Pick the first row — matches original iloc[0] behavior.""" def select(self, candidates: pd.DataFrame) -> pd.Series: return candidates.iloc[0] def to_rust_config(self) -> dict: return {"type": "FirstMatch"} class NearestDelta(SignalSelector): """Pick the contract whose delta is closest to `target_delta`. Requires a 'delta' column in candidates. """ def __init__(self, target_delta: float = -0.30, delta_column: str = "delta") -> None: self.target_delta = target_delta self.delta_column = delta_column @property def column_requirements(self) -> list[str]: return [self.delta_column] def select(self, candidates: pd.DataFrame) -> pd.Series: if self.delta_column not in candidates.columns: return candidates.iloc[0] values = candidates[self.delta_column].tolist() idx = rust_nearest_delta_index(values, self.target_delta) return candidates.iloc[idx] def to_rust_config(self) -> dict: return {"type": "NearestDelta", "target": self.target_delta, "column": self.delta_column} class MaxOpenInterest(SignalSelector): """Pick the contract with the highest open interest (proxy for liquidity). Requires an 'openinterest' or 'open_interest' column. """ def __init__(self, oi_column: str = "openinterest") -> None: self.oi_column = oi_column @property def column_requirements(self) -> list[str]: return [self.oi_column] def select(self, candidates: pd.DataFrame) -> pd.Series: if self.oi_column not in candidates.columns: return candidates.iloc[0] values = candidates[self.oi_column].astype(float).tolist() idx = rust_max_value_index(values) return candidates.iloc[idx] def to_rust_config(self) -> dict: return {"type": "MaxOpenInterest", "column": self.oi_column} ================================================ FILE: options_portfolio_backtester/execution/sizer.py ================================================ """Position sizing models — determine how many contracts to trade.""" from __future__ import annotations from abc import ABC, abstractmethod class PositionSizer(ABC): """Determines the number of contracts to trade.""" @abstractmethod def size(self, cost_per_contract: float, available_capital: float, total_capital: float) -> int: """Return the number of contracts to trade. Args: cost_per_contract: Dollar cost for one contract (absolute value). available_capital: Capital allocated to this trade. total_capital: Total portfolio value. """ ... class CapitalBased(PositionSizer): """Buy as many contracts as the allocation allows — matches original behavior. qty = available_capital // cost_per_contract """ def size(self, cost_per_contract: float, available_capital: float, total_capital: float) -> int: if cost_per_contract == 0: return 0 return int(available_capital // abs(cost_per_contract)) class FixedQuantity(PositionSizer): """Always trade a fixed number of contracts.""" def __init__(self, quantity: int = 1) -> None: self.quantity = quantity def size(self, cost_per_contract: float, available_capital: float, total_capital: float) -> int: if abs(cost_per_contract) * self.quantity > available_capital: return int(available_capital // abs(cost_per_contract)) if cost_per_contract != 0 else 0 return self.quantity class FixedDollar(PositionSizer): """Size positions to a fixed dollar amount.""" def __init__(self, amount: float = 10_000.0) -> None: self.amount = amount def size(self, cost_per_contract: float, available_capital: float, total_capital: float) -> int: if cost_per_contract == 0: return 0 target = min(self.amount, available_capital) return int(target // abs(cost_per_contract)) class PercentOfPortfolio(PositionSizer): """Size positions as a percentage of total portfolio value.""" def __init__(self, pct: float = 0.01) -> None: assert 0.0 < pct <= 1.0 self.pct = pct def size(self, cost_per_contract: float, available_capital: float, total_capital: float) -> int: if cost_per_contract == 0: return 0 target = min(self.pct * total_capital, available_capital) return int(target // abs(cost_per_contract)) ================================================ FILE: options_portfolio_backtester/portfolio/__init__.py ================================================ ================================================ FILE: options_portfolio_backtester/portfolio/greeks.py ================================================ """Portfolio-level Greeks aggregation.""" from __future__ import annotations from options_portfolio_backtester.core.types import Greeks from options_portfolio_backtester.portfolio.position import OptionPosition def aggregate_greeks( positions: dict[int, OptionPosition], leg_greeks_by_position: dict[int, dict[str, Greeks]], ) -> Greeks: """Compute portfolio-level Greeks by summing across all positions. Args: positions: {position_id: OptionPosition}. leg_greeks_by_position: {position_id: {leg_name: Greeks}}. Returns: Total portfolio Greeks. """ total = Greeks() for pid, pos in positions.items(): pos_greeks = leg_greeks_by_position.get(pid, {}) total = total + pos.greeks(pos_greeks) return total ================================================ FILE: options_portfolio_backtester/portfolio/portfolio.py ================================================ """Portfolio — clean replacement for MultiIndex DataFrames. Uses plain dicts and dataclasses instead of MultiIndex DataFrames for inventory tracking. Simpler, extensible, debuggable. """ from __future__ import annotations from dataclasses import dataclass, field from typing import Any from options_portfolio_backtester.core.types import Greeks from options_portfolio_backtester.portfolio.position import OptionPosition from options_portfolio_backtester.portfolio.greeks import aggregate_greeks @dataclass class StockHolding: """A stock position in the portfolio.""" symbol: str quantity: float cost_basis: float # average price paid class Portfolio: """Portfolio state — cash, option positions, stock holdings. Replaces the old MultiIndex _options_inventory and _stocks_inventory DataFrames with typed, inspectable data structures. """ def __init__(self, initial_cash: float = 0.0) -> None: self.cash: float = initial_cash self.option_positions: dict[int, OptionPosition] = {} self.stock_holdings: dict[str, StockHolding] = {} self._next_position_id: int = 0 def next_position_id(self) -> int: pid = self._next_position_id self._next_position_id += 1 return pid # -- Option positions -- def add_option_position(self, pos: OptionPosition) -> None: self.option_positions[pos.position_id] = pos def remove_option_position(self, position_id: int) -> OptionPosition | None: return self.option_positions.pop(position_id, None) def options_value(self, current_prices: dict[int, dict[str, float]], shares_per_contract: int) -> float: """Total mark-to-market value of all option positions. Args: current_prices: {position_id: {leg_name: exit_price}}. shares_per_contract: Contract multiplier. """ total = 0.0 for pid, pos in self.option_positions.items(): prices = current_prices.get(pid, {}) total += pos.current_value(prices, shares_per_contract) return total # -- Stock holdings -- def set_stock_holding(self, symbol: str, quantity: float, price: float) -> None: self.stock_holdings[symbol] = StockHolding( symbol=symbol, quantity=quantity, cost_basis=price, ) def clear_stock_holdings(self) -> None: self.stock_holdings.clear() def stocks_value(self, current_prices: dict[str, float]) -> float: """Total value of stock holdings at current prices.""" total = 0.0 for symbol, holding in self.stock_holdings.items(): price = current_prices.get(symbol, holding.cost_basis) total += holding.quantity * price return total # -- Portfolio totals -- def total_value(self, stock_prices: dict[str, float], option_prices: dict[int, dict[str, float]], shares_per_contract: int) -> float: """Total portfolio value: cash + stocks + options.""" return (self.cash + self.stocks_value(stock_prices) + self.options_value(option_prices, shares_per_contract)) def portfolio_greeks(self, leg_greeks_by_position: dict[int, dict[str, Greeks]]) -> Greeks: """Aggregate Greeks across all option positions.""" return aggregate_greeks(self.option_positions, leg_greeks_by_position) ================================================ FILE: options_portfolio_backtester/portfolio/position.py ================================================ """Option position and position leg — replaces MultiIndex inventory rows.""" from __future__ import annotations from dataclasses import dataclass, field from typing import Any from options_portfolio_backtester.core.types import ( Direction, OptionType, Order, Greeks, get_order, Signal, ) @dataclass class PositionLeg: """A single leg within an option position.""" name: str contract_id: str underlying: str expiration: Any # pd.Timestamp option_type: OptionType strike: float entry_price: float direction: Direction order: Order @property def exit_order(self) -> Order: return ~self.order def current_value(self, current_price: float, quantity: int, shares_per_contract: int) -> float: """Mark-to-market value of this leg. For a BUY leg, value = current_price * qty * spc (we own it). For a SELL leg, value = -current_price * qty * spc (we owe it). """ sign = -1 if self.direction == Direction.SELL else 1 return sign * current_price * quantity * shares_per_contract @dataclass class OptionPosition: """A multi-leg option position. Replaces one row in the old MultiIndex _options_inventory DataFrame. """ position_id: int legs: dict[str, PositionLeg] = field(default_factory=dict) quantity: int = 0 entry_cost: float = 0.0 # total cost at entry (negative for debit) entry_date: Any = None # pd.Timestamp def add_leg(self, leg: PositionLeg) -> None: self.legs[leg.name] = leg def current_value(self, current_prices: dict[str, float], shares_per_contract: int) -> float: """Total MTM value of this position across all legs. Args: current_prices: {leg_name: exit_price} for each leg. shares_per_contract: Contract multiplier. """ total = 0.0 for leg_name, leg in self.legs.items(): price = current_prices.get(leg_name, 0.0) total += leg.current_value(price, self.quantity, shares_per_contract) return total def greeks(self, leg_greeks: dict[str, Greeks]) -> Greeks: """Aggregate Greeks across all legs, scaled by quantity. Args: leg_greeks: {leg_name: Greeks} for each leg. """ total = Greeks() for leg_name, leg in self.legs.items(): g = leg_greeks.get(leg_name, Greeks()) sign = 1 if leg.direction == Direction.BUY else -1 total = total + g * (sign * self.quantity) return total ================================================ FILE: options_portfolio_backtester/portfolio/risk.py ================================================ """Risk management — constraints checked before entering positions.""" from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass from options_portfolio_backtester.core.types import Greeks from options_portfolio_backtester.execution._rust_bridge import rust_risk_check class RiskConstraint(ABC): """A single risk constraint.""" @abstractmethod def check(self, current_greeks: Greeks, proposed_greeks: Greeks, portfolio_value: float, peak_value: float) -> bool: """Return True if the trade is allowed, False if it violates the constraint.""" ... @abstractmethod def describe(self) -> str: """Human-readable description of the constraint.""" ... def _greeks_list(g: Greeks) -> list[float]: return [g.delta, g.gamma, g.theta, g.vega] class MaxDelta(RiskConstraint): """Reject trades that would push portfolio delta beyond a limit.""" def __init__(self, limit: float = 100.0) -> None: self.limit = limit def check(self, current_greeks: Greeks, proposed_greeks: Greeks, portfolio_value: float, peak_value: float) -> bool: return rust_risk_check( "MaxDelta", self.limit, _greeks_list(current_greeks), _greeks_list(proposed_greeks), portfolio_value, peak_value, ) def describe(self) -> str: return f"MaxDelta(limit={self.limit})" def to_rust_config(self) -> dict: return {"type": "MaxDelta", "limit": self.limit} class MaxVega(RiskConstraint): """Reject trades that would push portfolio vega beyond a limit.""" def __init__(self, limit: float = 50.0) -> None: self.limit = limit def check(self, current_greeks: Greeks, proposed_greeks: Greeks, portfolio_value: float, peak_value: float) -> bool: return rust_risk_check( "MaxVega", self.limit, _greeks_list(current_greeks), _greeks_list(proposed_greeks), portfolio_value, peak_value, ) def describe(self) -> str: return f"MaxVega(limit={self.limit})" def to_rust_config(self) -> dict: return {"type": "MaxVega", "limit": self.limit} class MaxDrawdown(RiskConstraint): """Reject new entries if portfolio drawdown exceeds a threshold.""" def __init__(self, max_dd_pct: float = 0.20) -> None: self.max_dd_pct = max_dd_pct def check(self, current_greeks: Greeks, proposed_greeks: Greeks, portfolio_value: float, peak_value: float) -> bool: return rust_risk_check( "MaxDrawdown", self.max_dd_pct, _greeks_list(current_greeks), _greeks_list(proposed_greeks), portfolio_value, peak_value, ) def describe(self) -> str: return f"MaxDrawdown(max_dd_pct={self.max_dd_pct})" def to_rust_config(self) -> dict: return {"type": "MaxDrawdown", "max_dd_pct": self.max_dd_pct} class RiskManager: """Evaluates a set of risk constraints before allowing a trade.""" def __init__(self, constraints: list[RiskConstraint] | None = None) -> None: self.constraints = constraints or [] def add_constraint(self, constraint: RiskConstraint) -> None: self.constraints.append(constraint) def is_allowed(self, current_greeks: Greeks, proposed_greeks: Greeks, portfolio_value: float, peak_value: float) -> tuple[bool, str]: """Check all constraints. Returns (allowed, reason).""" for c in self.constraints: if not c.check(current_greeks, proposed_greeks, portfolio_value, peak_value): return False, c.describe() return True, "" ================================================ FILE: options_portfolio_backtester/strategy/__init__.py ================================================ ================================================ FILE: options_portfolio_backtester/strategy/presets.py ================================================ """Pre-built strategy constructors for common options strategies.""" from __future__ import annotations from typing import TYPE_CHECKING from options_portfolio_backtester.core.types import Direction, OptionType from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg if TYPE_CHECKING: from options_portfolio_backtester.data.schema import Schema def strangle( schema: "Schema", underlying: str, direction: Direction, dte_range: tuple[int, int], dte_exit: int, otm_pct: float = 0.0, pct_tolerance: float = 1.0, exit_thresholds: tuple[float, float] = (float("inf"), float("inf")), ) -> Strategy: """Build a strangle (long or short) strategy.""" strat = Strategy(schema) otm_lo = (otm_pct - pct_tolerance) / 100 otm_hi = (otm_pct + pct_tolerance) / 100 call_leg = StrategyLeg("leg_1", schema, option_type=OptionType.CALL, direction=direction) call_leg.entry_filter = ( (schema.underlying == underlying) & (schema.dte >= dte_range[0]) & (schema.dte <= dte_range[1]) & (schema.strike >= schema.underlying_last * (1 + otm_lo)) & (schema.strike <= schema.underlying_last * (1 + otm_hi)) ) call_leg.exit_filter = schema.dte <= dte_exit put_leg = StrategyLeg("leg_2", schema, option_type=OptionType.PUT, direction=direction) put_leg.entry_filter = ( (schema.underlying == underlying) & (schema.dte >= dte_range[0]) & (schema.dte <= dte_range[1]) & (schema.strike <= schema.underlying_last * (1 - otm_lo)) & (schema.strike >= schema.underlying_last * (1 - otm_hi)) ) put_leg.exit_filter = schema.dte <= dte_exit strat.add_legs([call_leg, put_leg]) strat.add_exit_thresholds(exit_thresholds[0], exit_thresholds[1]) return strat def iron_condor( schema: "Schema", underlying: str, dte_range: tuple[int, int], dte_exit: int, short_delta_call: float = 0.30, short_delta_put: float = -0.30, wing_width: float = 5.0, exit_thresholds: tuple[float, float] = (float("inf"), float("inf")), ) -> Strategy: """Build a short iron condor (sell inner, buy outer wings). This is a simplified version using strike offsets; for delta-based selection, use a NearestDelta signal_selector on each leg. """ strat = Strategy(schema) # Short call (inner) sc = StrategyLeg("leg_1", schema, option_type=OptionType.CALL, direction=Direction.SELL) sc.entry_filter = ( (schema.underlying == underlying) & (schema.dte >= dte_range[0]) & (schema.dte <= dte_range[1]) ) sc.exit_filter = schema.dte <= dte_exit # Long call (outer wing) lc = StrategyLeg("leg_2", schema, option_type=OptionType.CALL, direction=Direction.BUY) lc.entry_filter = ( (schema.underlying == underlying) & (schema.dte >= dte_range[0]) & (schema.dte <= dte_range[1]) ) lc.exit_filter = schema.dte <= dte_exit # Short put (inner) sp = StrategyLeg("leg_3", schema, option_type=OptionType.PUT, direction=Direction.SELL) sp.entry_filter = ( (schema.underlying == underlying) & (schema.dte >= dte_range[0]) & (schema.dte <= dte_range[1]) ) sp.exit_filter = schema.dte <= dte_exit # Long put (outer wing) lp = StrategyLeg("leg_4", schema, option_type=OptionType.PUT, direction=Direction.BUY) lp.entry_filter = ( (schema.underlying == underlying) & (schema.dte >= dte_range[0]) & (schema.dte <= dte_range[1]) ) lp.exit_filter = schema.dte <= dte_exit strat.add_legs([sc, lc, sp, lp]) strat.add_exit_thresholds(exit_thresholds[0], exit_thresholds[1]) return strat def covered_call( schema: "Schema", underlying: str, dte_range: tuple[int, int], dte_exit: int, otm_pct: float = 2.0, pct_tolerance: float = 1.0, exit_thresholds: tuple[float, float] = (float("inf"), float("inf")), ) -> Strategy: """Build a covered call strategy (sell OTM calls against stock).""" strat = Strategy(schema) otm_lo = (otm_pct - pct_tolerance) / 100 otm_hi = (otm_pct + pct_tolerance) / 100 leg = StrategyLeg("leg_1", schema, option_type=OptionType.CALL, direction=Direction.SELL) leg.entry_filter = ( (schema.underlying == underlying) & (schema.dte >= dte_range[0]) & (schema.dte <= dte_range[1]) & (schema.strike >= schema.underlying_last * (1 + otm_lo)) & (schema.strike <= schema.underlying_last * (1 + otm_hi)) ) leg.exit_filter = schema.dte <= dte_exit strat.add_leg(leg) strat.add_exit_thresholds(exit_thresholds[0], exit_thresholds[1]) return strat def cash_secured_put( schema: "Schema", underlying: str, dte_range: tuple[int, int], dte_exit: int, otm_pct: float = 2.0, pct_tolerance: float = 1.0, exit_thresholds: tuple[float, float] = (float("inf"), float("inf")), ) -> Strategy: """Build a cash-secured put strategy (sell OTM puts).""" strat = Strategy(schema) otm_lo = (otm_pct - pct_tolerance) / 100 otm_hi = (otm_pct + pct_tolerance) / 100 leg = StrategyLeg("leg_1", schema, option_type=OptionType.PUT, direction=Direction.SELL) leg.entry_filter = ( (schema.underlying == underlying) & (schema.dte >= dte_range[0]) & (schema.dte <= dte_range[1]) & (schema.strike <= schema.underlying_last * (1 - otm_lo)) & (schema.strike >= schema.underlying_last * (1 - otm_hi)) ) leg.exit_filter = schema.dte <= dte_exit strat.add_leg(leg) strat.add_exit_thresholds(exit_thresholds[0], exit_thresholds[1]) return strat def collar( schema: "Schema", underlying: str, dte_range: tuple[int, int], dte_exit: int, call_otm_pct: float = 2.0, put_otm_pct: float = 2.0, pct_tolerance: float = 1.0, exit_thresholds: tuple[float, float] = (float("inf"), float("inf")), ) -> Strategy: """Build a collar strategy (long put + short call against stock).""" strat = Strategy(schema) call_lo = (call_otm_pct - pct_tolerance) / 100 call_hi = (call_otm_pct + pct_tolerance) / 100 put_lo = (put_otm_pct - pct_tolerance) / 100 put_hi = (put_otm_pct + pct_tolerance) / 100 short_call = StrategyLeg("leg_1", schema, option_type=OptionType.CALL, direction=Direction.SELL) short_call.entry_filter = ( (schema.underlying == underlying) & (schema.dte >= dte_range[0]) & (schema.dte <= dte_range[1]) & (schema.strike >= schema.underlying_last * (1 + call_lo)) & (schema.strike <= schema.underlying_last * (1 + call_hi)) ) short_call.exit_filter = schema.dte <= dte_exit long_put = StrategyLeg("leg_2", schema, option_type=OptionType.PUT, direction=Direction.BUY) long_put.entry_filter = ( (schema.underlying == underlying) & (schema.dte >= dte_range[0]) & (schema.dte <= dte_range[1]) & (schema.strike <= schema.underlying_last * (1 - put_lo)) & (schema.strike >= schema.underlying_last * (1 - put_hi)) ) long_put.exit_filter = schema.dte <= dte_exit strat.add_legs([short_call, long_put]) strat.add_exit_thresholds(exit_thresholds[0], exit_thresholds[1]) return strat def butterfly( schema: "Schema", underlying: str, dte_range: tuple[int, int], dte_exit: int, option_type: OptionType = OptionType.CALL, exit_thresholds: tuple[float, float] = (float("inf"), float("inf")), ) -> Strategy: """Build a long butterfly spread (buy 1 lower, sell 2 middle, buy 1 upper). Uses entry_sort on strike to pick the legs. The middle leg is a SELL direction with double quantity handled by the sizer. """ strat = Strategy(schema) # Lower wing (buy) lower = StrategyLeg("leg_1", schema, option_type=option_type, direction=Direction.BUY) lower.entry_filter = ( (schema.underlying == underlying) & (schema.dte >= dte_range[0]) & (schema.dte <= dte_range[1]) ) lower.entry_sort = ("strike", True) # ascending — lowest strike first lower.exit_filter = schema.dte <= dte_exit # Middle (sell 2x) middle = StrategyLeg("leg_2", schema, option_type=option_type, direction=Direction.SELL) middle.entry_filter = ( (schema.underlying == underlying) & (schema.dte >= dte_range[0]) & (schema.dte <= dte_range[1]) ) middle.exit_filter = schema.dte <= dte_exit # Upper wing (buy) upper = StrategyLeg("leg_3", schema, option_type=option_type, direction=Direction.BUY) upper.entry_filter = ( (schema.underlying == underlying) & (schema.dte >= dte_range[0]) & (schema.dte <= dte_range[1]) ) upper.entry_sort = ("strike", False) # descending — highest strike first upper.exit_filter = schema.dte <= dte_exit strat.add_legs([lower, middle, upper]) strat.add_exit_thresholds(exit_thresholds[0], exit_thresholds[1]) return strat class Strangle(Strategy): """Class-based Strangle constructor.""" def __init__( self, schema: "Schema", name: str, underlying: str, dte_entry_range: tuple[int, int], dte_exit: int, otm_pct: float = 0, pct_tolerance: float = 1, exit_thresholds: tuple[float, float] = (float('inf'), float('inf')), shares_per_contract: int = 100, ) -> None: assert (name.lower() == 'short' or name.lower() == 'long') super().__init__(schema) direction = Direction.SELL if name.lower() == 'short' else Direction.BUY leg1 = StrategyLeg( "leg_1", schema, option_type=OptionType.CALL, direction=direction, ) otm_lower_bound = (otm_pct - pct_tolerance) / 100 otm_upper_bound = (otm_pct + pct_tolerance) / 100 leg1.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_entry_range[0]) & ( schema.dte <= dte_entry_range[1]) & (schema.strike >= schema.underlying_last * (1 + otm_lower_bound)) & (schema.strike <= schema.underlying_last * (1 + otm_upper_bound)) leg1.exit_filter = (schema.dte <= dte_exit) leg2 = StrategyLeg("leg_2", schema, option_type=OptionType.PUT, direction=direction) leg2.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_entry_range[0]) & ( schema.dte <= dte_entry_range[1]) & (schema.strike <= schema.underlying_last * (1 - otm_lower_bound)) & (schema.strike >= schema.underlying_last * (1 - otm_upper_bound)) leg2.exit_filter = (schema.dte <= dte_exit) self.add_legs([leg1, leg2]) self.add_exit_thresholds(exit_thresholds[0], exit_thresholds[1]) ================================================ FILE: options_portfolio_backtester/strategy/strategy.py ================================================ """Strategy container — preserved interface with richer execution support.""" from __future__ import annotations import math from typing import TYPE_CHECKING import numpy as np import pandas as pd from options_portfolio_backtester.execution.cost_model import TransactionCostModel, NoCosts from options_portfolio_backtester.execution.sizer import PositionSizer, CapitalBased from options_portfolio_backtester.execution.signal_selector import SignalSelector, FirstMatch if TYPE_CHECKING: from options_portfolio_backtester.data.schema import Schema from .strategy_leg import StrategyLeg class Strategy: """Options strategy — collection of legs with exit thresholds. API-compatible with backtester.strategy.strategy.Strategy, adding optional cost_model, sizer, and signal_selector at the strategy level. """ def __init__( self, schema: "Schema", cost_model: TransactionCostModel | None = None, sizer: PositionSizer | None = None, signal_selector: SignalSelector | None = None, ) -> None: self.schema = schema self.legs: list[StrategyLeg] = [] self.conditions: list = [] self.exit_thresholds: tuple[float, float] = (math.inf, math.inf) self.cost_model = cost_model or NoCosts() self.sizer = sizer or CapitalBased() self.signal_selector = signal_selector or FirstMatch() def add_leg(self, leg: "StrategyLeg") -> "Strategy": assert self.schema == leg.schema leg.name = f"leg_{len(self.legs) + 1}" self.legs.append(leg) return self def add_legs(self, legs: list["StrategyLeg"]) -> "Strategy": for leg in legs: self.add_leg(leg) return self def remove_leg(self, leg_number: int) -> "Strategy": self.legs.pop(leg_number) return self def clear_legs(self) -> "Strategy": self.legs = [] return self def add_exit_thresholds(self, profit_pct: float = math.inf, loss_pct: float = math.inf) -> None: assert profit_pct >= 0 assert loss_pct >= 0 self.exit_thresholds = (profit_pct, loss_pct) def filter_thresholds(self, entry_cost: pd.Series, current_cost: pd.Series) -> pd.Series: profit_pct, loss_pct = self.exit_thresholds excess_return = (current_cost / entry_cost + 1) * -np.sign(entry_cost) return (excess_return >= profit_pct) | (excess_return <= -loss_pct) def __repr__(self) -> str: return f"Strategy(legs={self.legs}, exit_thresholds={self.exit_thresholds})" ================================================ FILE: options_portfolio_backtester/strategy/strategy_leg.py ================================================ """Strategy leg — re-exports the original StrategyLeg for now. The new StrategyLeg is API-compatible with the original and adds support for the new execution components (signal_selector, fill_model). """ from __future__ import annotations from typing import TYPE_CHECKING from options_portfolio_backtester.core.types import Direction, OptionType from options_portfolio_backtester.execution.signal_selector import SignalSelector, FirstMatch from options_portfolio_backtester.execution.fill_model import FillModel, MarketAtBidAsk if TYPE_CHECKING: from options_portfolio_backtester.data.schema import Filter, Schema class StrategyLeg: """A single option leg in a strategy. API-compatible with backtester.strategy.strategy_leg.StrategyLeg, adding optional signal_selector and fill_model. """ def __init__( self, name: str, schema: "Schema", option_type: OptionType = OptionType.CALL, direction: Direction = Direction.BUY, signal_selector: SignalSelector | None = None, fill_model: FillModel | None = None, ) -> None: self.name = name self.schema = schema self.type = option_type self.direction = direction self.signal_selector = signal_selector # None = use engine-level default self.fill_model = fill_model # None = use engine-level default self.entry_sort: tuple[str, bool] | None = None self._entry_filter: "Filter" = self._base_entry_filter() self._exit_filter: "Filter" = self._base_exit_filter() @property def entry_filter(self) -> "Filter": return self._entry_filter @entry_filter.setter def entry_filter(self, flt: "Filter") -> None: self._entry_filter = self._base_entry_filter() & flt @property def exit_filter(self) -> "Filter": return self._exit_filter @exit_filter.setter def exit_filter(self, flt: "Filter") -> None: self._exit_filter = self._base_exit_filter() & flt def _base_entry_filter(self) -> "Filter": if self.direction == Direction.BUY: return (self.schema.type == self.type.value) & (self.schema.ask > 0) return (self.schema.type == self.type.value) & (self.schema.bid > 0) def _base_exit_filter(self) -> "Filter": return self.schema.type == self.type.value def __repr__(self) -> str: return ( f"StrategyLeg(name={self.name}, type={self.type}, " f"direction={self.direction}, entry_filter={self._entry_filter}, " f"exit_filter={self._exit_filter})" ) ================================================ FILE: pyproject.toml ================================================ [build-system] requires = ["maturin>=1.7,<2.0"] build-backend = "maturin" [project] name = "options_portfolio_backtester" version = "0.3.0" description = "The open-source options backtesting framework" readme = "README.md" license = {text = "MIT"} requires-python = ">=3.11" dependencies = [ "pandas>=2.1", "numpy>=1.26", "altair>=5.0", "pyprind>=2.11", "pyarrow>=14.0", ] [project.optional-dependencies] rust = ["polars>=1.0,<1.6"] charts = ["seaborn>=0.13", "matplotlib>=3.8"] dev = [ "pytest>=8.0", "hypothesis>=6.0", "pytest-benchmark", "mypy>=1.8", "ruff>=0.3", "pandas-stubs", "maturin>=1.7", ] notebooks = ["jupyter", "nbconvert"] [tool.maturin] manifest-path = "rust/ob_python/Cargo.toml" module-name = "options_portfolio_backtester._ob_rust" python-source = "." features = ["pyo3/extension-module"] [tool.setuptools.packages.find] include = ["options_portfolio_backtester*"] [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-m \"not bench\" --ignore=tests/bench --ignore=tests/convexity --ignore=tests/compat --ignore=tests/test_deep_analytics_convexity.py" markers = [ "bench: benchmark/property tests requiring explicit opt-in", "slow: full-range stress tests (17-year SPY, minutes to run)", "chaos: chaos / fault-injection tests", ] filterwarnings = ["ignore::DeprecationWarning"] [tool.mypy] python_version = "3.12" warn_unused_configs = true disallow_untyped_defs = false ignore_missing_imports = true check_untyped_defs = false [tool.ruff] line-length = 119 target-version = "py312" [tool.ruff.lint] select = ["E", "F", "W", "I"] ignore = ["E126", "F403", "F405", "W504"] ================================================ FILE: rust/.cargo/config.toml ================================================ [env] PYO3_USE_ABI3_FORWARD_COMPATIBILITY = "1" ================================================ FILE: rust/Cargo.toml ================================================ [workspace] members = ["ob_core", "ob_python"] resolver = "2" ================================================ FILE: rust/ob_core/Cargo.toml ================================================ [package] name = "ob_core" version = "0.1.0" edition = "2021" [dependencies] polars = { version = "0.48", features = ["lazy", "parquet", "dtype-struct", "semi_anti_join"] } arrow = { version = "55", features = ["ffi"] } chrono = "0.4" rayon = "1.10" thiserror = "2" [dev-dependencies] criterion = { version = "0.5", features = ["html_reports"] } [[bench]] name = "hot_paths" harness = false ================================================ FILE: rust/ob_core/benches/hot_paths.rs ================================================ use criterion::{black_box, criterion_group, criterion_main, Criterion}; use polars::prelude::*; use ob_core::entries::{compute_entry_qty, compute_leg_entries}; use ob_core::exits::threshold_exit_mask; use ob_core::filter::CompiledFilter; use ob_core::inventory::join_inventory_to_market; use ob_core::stats::compute_stats; use ob_core::types::Direction; fn make_options_df(n: usize) -> DataFrame { let contracts: Vec = (0..n).map(|i| format!("SPX_{i}")).collect(); let underlyings: Vec<&str> = vec!["SPX"; n]; let types: Vec<&str> = (0..n) .map(|i| if i % 2 == 0 { "put" } else { "call" }) .collect(); let expirations: Vec<&str> = vec!["2024-06-01"; n]; let strikes: Vec = (0..n).map(|i| 3800.0 + i as f64 * 5.0).collect(); let asks: Vec = (0..n).map(|i| 1.0 + (i % 50) as f64 * 0.5).collect(); let bids: Vec = asks.iter().map(|a| a * 0.95).collect(); let dtes: Vec = (0..n).map(|i| 30 + (i % 180) as i32).collect(); DataFrame::new(vec![ Column::new("optionroot".into(), contracts), Column::new("underlying".into(), underlyings), Column::new("type".into(), types), Column::new("expiration".into(), expirations), Column::new("strike".into(), strikes), Column::new("ask".into(), asks), Column::new("bid".into(), bids), Column::new("dte".into(), dtes), ]) .unwrap() } fn bench_inventory_join(c: &mut Criterion) { let opts = make_options_df(10_000); let n_inv = 50; let contracts: Vec = (0..n_inv).map(|i| format!("SPX_{i}")).collect(); let qtys: Vec = vec![10.0; n_inv]; let types: Vec = (0..n_inv) .map(|i| { if i % 2 == 0 { "put".into() } else { "call".into() } }) .collect(); let underlyings: Vec = vec!["SPX".into(); n_inv]; let strikes: Vec = (0..n_inv).map(|i| 3800.0 + i as f64 * 5.0).collect(); c.bench_function("inventory_join_50x10k", |b| { b.iter(|| { let result = join_inventory_to_market( black_box(&contracts), black_box(&qtys), black_box(&types), black_box(&underlyings), black_box(&strikes), black_box(&opts), None, "optionroot", "quotedate", "bid", None, None, Direction::Buy, 100, ) .unwrap(); black_box(result.height()); }); }); } fn bench_filter_compile_and_apply(c: &mut Criterion) { let df = make_options_df(10_000); let filter = CompiledFilter::new( "(type == 'put') & (ask > 0) & (underlying == 'SPX') & (dte >= 60) & (dte <= 120)", ) .unwrap(); c.bench_function("filter_apply_10k", |b| { b.iter(|| { let result = filter.apply(black_box(&df)).unwrap(); black_box(result.height()); }); }); } fn bench_filter_compile(c: &mut Criterion) { c.bench_function("filter_compile", |b| { b.iter(|| { let f = CompiledFilter::new(black_box( "(type == 'put') & (ask > 0) & (underlying == 'SPX') & (dte >= 60) & (dte <= 120)", )) .unwrap(); black_box(&f); }); }); } fn bench_entry_computation(c: &mut Criterion) { let opts = make_options_df(10_000); let held: Vec = (0..10).map(|i| format!("SPX_{i}")).collect(); let filter = CompiledFilter::new("(type == 'put') & (ask > 0) & (dte >= 60)").unwrap(); c.bench_function("entry_compute_10k", |b| { b.iter(|| { let result = compute_leg_entries( black_box(&opts), black_box(&held), black_box(&filter), "optionroot", "ask", Some("strike"), true, 100, false, ) .unwrap(); black_box(result.height()); }); }); } fn bench_exit_mask(c: &mut Criterion) { let n = 1000; let entries: Vec = (0..n).map(|i| 100.0 + (i % 50) as f64).collect(); let currents: Vec = (0..n).map(|i| 80.0 + (i % 80) as f64).collect(); let entry_s = Series::new("entry".into(), &entries); let current_s = Series::new("current".into(), ¤ts); c.bench_function("exit_mask_1k", |b| { b.iter(|| { let mask = threshold_exit_mask( black_box(&entry_s), black_box(¤t_s), Some(0.5), Some(0.2), ) .unwrap(); black_box(mask.len()); }); }); } fn bench_stats_computation(c: &mut Criterion) { let n = 2520; // ~10 years of trading days let returns: Vec = (0..n).map(|i| ((i as f64 * 0.1).sin()) * 0.02).collect(); let pnls: Vec = (0..100) .map(|i| if i % 3 == 0 { -50.0 } else { 100.0 }) .collect(); c.bench_function("stats_10yr", |b| { b.iter(|| { let s = compute_stats(black_box(&returns), black_box(&pnls), 0.02); black_box(s); }); }); } fn bench_entry_qty(c: &mut Criterion) { let n = 5000; let costs: Vec = (0..n).map(|i| 50.0 + (i % 200) as f64).collect(); let series = Series::new("cost".into(), &costs); c.bench_function("entry_qty_5k", |b| { b.iter(|| { let qty = compute_entry_qty(black_box(&series), 1_000_000.0).unwrap(); black_box(qty.len()); }); }); } criterion_group!( benches, bench_inventory_join, bench_filter_compile, bench_filter_compile_and_apply, bench_entry_computation, bench_exit_mask, bench_stats_computation, bench_entry_qty, ); criterion_main!(benches); ================================================ FILE: rust/ob_core/src/backtest.rs ================================================ //! Full backtest loop — mirrors BacktestEngine.run() for parity. //! //! Pre-partitions all data by date at startup for O(1) lookups instead of //! O(n) DataFrame scans on each access. Uses i64 nanosecond timestamps as //! HashMap keys to avoid string conversion overhead entirely. //! //! Key optimizations: //! - filter_by_date() → HashMap::get() O(n) → O(1) //! - get_contract_field_f64() → DayOptions::get_f64() O(n) → O(1) //! - get_contract_field_str() → DayOptions::get_str() O(n) → O(1) //! - get_symbol_price() → DayStocks::get_price() O(n) → O(1) //! - Date keys are i64 (nanoseconds) — no string allocation or comparison. use std::collections::HashMap; use chrono::DateTime; use polars::prelude::*; use crate::cost_model::CostModel; use crate::entries::compute_leg_entries; use crate::fill_model::FillModel; use crate::filter::CompiledFilter; use crate::risk::{self, RiskConstraint}; use crate::signal_selector::SignalSelector; use crate::stats; use crate::stats::Stats; use crate::types::{Direction, Greeks, LegConfig}; #[derive(Clone)] pub struct BacktestConfig { pub allocation_stocks: f64, pub allocation_options: f64, pub allocation_cash: f64, pub initial_capital: f64, pub shares_per_contract: i64, pub legs: Vec, pub profit_pct: Option, pub loss_pct: Option, pub stock_symbols: Vec, pub stock_percentages: Vec, /// Pre-computed rebalance dates as nanoseconds since epoch. pub rebalance_dates: Vec, /// Transaction cost model. pub cost_model: CostModel, /// Fill model for execution pricing. pub fill_model: FillModel, /// Engine-level signal selector. pub signal_selector: SignalSelector, /// Risk constraints checked before entries. pub risk_constraints: Vec, /// SMA days for stock gating (None = no SMA gate). pub sma_days: Option, /// Options budget as a percentage of total capital per rebalance (overrides allocation_options). pub options_budget_pct: Option, /// Annual options budget as a percentage of total capital, auto-divided by rebalances/year. pub options_budget_annual_pct: Option, /// Stop the backtest if cash goes negative (mirrors Python's stop_if_broke). pub stop_if_broke: bool, /// Maximum short notional as fraction of total capital (None = no limit). pub max_notional_pct: Option, /// Check exits on every trading day, not just rebalance dates. pub check_exits_daily: bool, /// When true, spend the full budget each rebalance ignoring existing position value. /// Default (false) uses target model: spend = budget - existing_options_value. pub options_budget_fresh_spend: bool, /// When true, rebalance stocks immediately after daily option exits. /// Allows reinvesting put profits into stocks without waiting for the next rebalance date. pub rebalance_stocks_on_exit: bool, } pub struct BacktestResult { pub balance: DataFrame, pub trade_log: DataFrame, pub final_cash: f64, pub stats: Stats, } /// Configuration for one strategy slot in a multi-strategy backtest. #[derive(Clone)] pub struct StrategySlotConfig { pub name: String, pub legs: Vec, pub weight: f64, pub rebalance_dates: Vec, pub profit_pct: Option, pub loss_pct: Option, pub check_exits_daily: bool, } struct Position { leg_contracts: Vec, leg_types: Vec, leg_directions: Vec, quantity: f64, entry_cost: f64, greeks: Greeks, /// Entry-time metadata per leg, used as fallback when contract is missing from today's data. leg_underlyings: Vec, leg_expirations: Vec, leg_strikes: Vec, } struct StockHolding { symbol: String, qty: f64, price: f64, } /// Per-leg per-position entry in trade log (flat, converted to MultiIndex in Python). struct TradeRow { date: i64, leg_data: Vec, total_cost: f64, qty: f64, } struct LegTradeData { contract: String, underlying: String, expiration: String, opt_type: String, strike: f64, cost: f64, order: String, } /// Balance row for a single date range day. struct BalanceDay { date: i64, cash: f64, calls_capital: f64, puts_capital: f64, options_qty: f64, stocks_qty: f64, stock_values: Vec<(String, f64)>, stock_qtys: Vec<(String, f64)>, } // --------------------------------------------------------------------------- // Date conversion helpers. // --------------------------------------------------------------------------- /// Convert nanoseconds since epoch to "YYYY-MM-DD HH:MM:SS" string. fn ns_to_datestring(ns: i64) -> String { let secs = ns.div_euclid(1_000_000_000); let nsec = ns.rem_euclid(1_000_000_000) as u32; DateTime::from_timestamp(secs, nsec) .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) .unwrap_or_default() } /// Parse "YYYY-MM-DD HH:MM:SS" to nanoseconds since epoch. fn parse_datestring_to_ns(s: &str) -> Option { chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") .ok() .map(|dt| { let ts = dt.and_utc().timestamp(); ts * 1_000_000_000 }) } /// Extract an i64 date key (nanoseconds) from a column value at index. /// Handles Datetime (any time unit), Date, and String columns. fn extract_date_ns(col: &Column, idx: usize) -> i64 { match col.dtype() { DataType::Datetime(tu, _) => { let val = col.datetime().unwrap().get(idx).unwrap_or(0); match tu { TimeUnit::Nanoseconds => val, TimeUnit::Microseconds => val * 1_000, TimeUnit::Milliseconds => val * 1_000_000, } } DataType::Date => { let days = col.date().unwrap().get(idx).unwrap_or(0); days as i64 * 86_400_000_000_000i64 } _ => { col.str().ok() .and_then(|ca| ca.get(idx)) .and_then(parse_datestring_to_ns) .unwrap_or(0) } } } /// Read a column value as a String, handling both String and Datetime columns. fn column_value_to_string(col: &Column, idx: usize) -> String { if let Ok(ca) = col.str() { return ca.get(idx).unwrap_or("").to_string(); } match col.dtype() { DataType::Datetime(tu, _) => { let val = col.datetime().unwrap().get(idx).unwrap_or(0); let ns = match tu { TimeUnit::Nanoseconds => val, TimeUnit::Microseconds => val * 1_000, TimeUnit::Milliseconds => val * 1_000_000, }; ns_to_datestring(ns) } DataType::Date => { let days = col.date().unwrap().get(idx).unwrap_or(0); ns_to_datestring(days as i64 * 86_400_000_000_000i64) } _ => String::new(), } } // --------------------------------------------------------------------------- // Pre-partitioned data structures — O(1) date and contract lookups. // --------------------------------------------------------------------------- /// Options data for a single date with O(1) contract lookups. struct DayOptions { df: DataFrame, /// contract_string → row index within `df`. contract_idx: HashMap, } impl DayOptions { fn new(df: DataFrame, contract_col: &str) -> Self { let mut contract_idx = HashMap::with_capacity(df.height()); if let Ok(col) = df.column(contract_col) { if let Ok(ca) = col.str() { for (i, val) in ca.into_iter().enumerate() { if let Some(v) = val { // Keep first occurrence (matches original filter + iloc[0]). contract_idx.entry(v.to_string()).or_insert(i); } } } } DayOptions { df, contract_idx } } /// Get a float64 field for a contract — O(1). fn get_f64(&self, contract: &str, field: &str) -> Option { let &row_idx = self.contract_idx.get(contract)?; let col = self.df.column(field).ok()?; // Fast path: column is already f64. if let Ok(ca) = col.f64() { return ca.get(row_idx); } // Slow path: cast to f64 (e.g. Int64 strike column). let casted = col.cast(&DataType::Float64).ok()?; casted.f64().ok()?.get(row_idx) } /// Get a string field for a contract — O(1). /// Handles both String and Datetime columns (for expiration). fn get_str(&self, contract: &str, field: &str) -> Option { let &row_idx = self.contract_idx.get(contract)?; let col = self.df.column(field).ok()?; let s = column_value_to_string(col, row_idx); if s.is_empty() { None } else { Some(s) } } fn height(&self) -> usize { self.df.height() } } /// Stocks data for a single date — O(1) price lookups. struct DayStocks { prices: HashMap, } impl DayStocks { fn get_price(&self, symbol: &str) -> Option { self.prices.get(symbol).copied() } } /// All data pre-partitioned by date. pub struct PartitionedData { options: HashMap, stocks: HashMap, /// All option dates as nanoseconds, sorted ascending. all_dates_sorted: Vec, } /// Schema column name mappings passed from Python. #[derive(Clone)] pub struct SchemaMapping { pub contract: String, pub date: String, pub stocks_date: String, pub stocks_sym: String, pub stocks_price: String, pub underlying: String, pub expiration: String, pub option_type: String, pub strike: String, } // --------------------------------------------------------------------------- // Main entry point. // --------------------------------------------------------------------------- pub fn run_backtest( config: &BacktestConfig, options_data: &DataFrame, stocks_data: &DataFrame, schema: &SchemaMapping, ) -> PolarsResult { let partitioned = prepartition_data(options_data, stocks_data, schema)?; run_backtest_prepartitioned(config, &partitioned, schema) } /// Pre-compiled entry and exit filters for a backtest config. /// Avoids redundant filter parsing when running multiple configs in a sweep. pub struct PrecompiledFilters { pub entry: Vec>, pub exit: Vec>, } impl PrecompiledFilters { /// Compile filters from a BacktestConfig's legs. pub fn from_config(config: &BacktestConfig) -> Self { let entry = config.legs.iter() .map(|leg| leg.entry_filter_query.as_ref().and_then(|q| CompiledFilter::new(q).ok())) .collect(); let exit = config.legs.iter() .map(|leg| leg.exit_filter_query.as_ref().and_then(|q| CompiledFilter::new(q).ok())) .collect(); PrecompiledFilters { entry, exit } } } /// Run a backtest using pre-partitioned data (avoids re-partitioning in sweeps). pub fn run_backtest_prepartitioned( config: &BacktestConfig, partitioned: &PartitionedData, schema: &SchemaMapping, ) -> PolarsResult { let filters = PrecompiledFilters::from_config(config); run_backtest_with_filters(config, partitioned, schema, &filters) } /// Run a backtest with pre-compiled filters (used by sweep to avoid redundant compilation). pub fn run_backtest_with_filters( config: &BacktestConfig, partitioned: &PartitionedData, schema: &SchemaMapping, filters: &PrecompiledFilters, ) -> PolarsResult { let entry_filters = &filters.entry; let exit_filters = &filters.exit; let mut cash = config.initial_capital; let mut positions: Vec = Vec::new(); let mut stock_holdings: Vec = Vec::new(); let mut peak_value: f64 = config.initial_capital; // Initial value is overwritten inside the rebalance_date macro before first read, // but we need a valid initial binding for the macro's mutable capture. #[allow(unused_assignments)] let mut portfolio_greeks = Greeks::default(); let mut trade_rows: Vec = Vec::new(); let mut balance_days: Vec = Vec::new(); // Pre-compute SMA per stock symbol if sma_days is set let sma_map_by_date = config.sma_days .map(|sma_days| compute_sma_map(&partitioned, &config.stock_symbols, sma_days)); let rb_dates = &config.rebalance_dates; if rb_dates.is_empty() { return build_result(&trade_rows, &balance_days, &config.legs, cash); } // Pre-compute rebalances per year for annual budget conversion. let rebalances_per_year = if rb_dates.len() >= 2 { let first = rb_dates[0]; let last = *rb_dates.last().unwrap(); let years = (last - first) as f64 / (365.25 * 24.0 * 3600.0 * 1e9); if years > 0.0 { rb_dates.len() as f64 / years } else { rb_dates.len() as f64 } } else { 1.0 }; // Rebalance helper: executes full rebalance logic for a single date. // Extracted as a macro to avoid duplicating 60 lines across the two loop // variants (rb-only vs all-dates). macro_rules! rebalance_date { ($rb_date:expr, $prev_rb_date:expr, $partitioned:expr, $config:expr, $entry_filters:expr, $exit_filters:expr, $schema:expr, $sma_map_by_date:expr, $positions:expr, $stock_holdings:expr, $cash:expr, $peak_value:expr, $portfolio_greeks:expr, $trade_rows:expr, $balance_days:expr) => {{ let rb_date = $rb_date; let prev_rb_date = $prev_rb_date; // _update_balance(prev_rb_date, rb_date) compute_balance_period( &$positions, &$stock_holdings, $partitioned, prev_rb_date, rb_date, $config.shares_per_contract, $cash, &$config.legs, &mut $balance_days, ); let day_opts = match $partitioned.options.get(&rb_date) { Some(d) if d.height() > 0 => d, _ => { continue; } }; let day_stocks = $partitioned.stocks.get(&rb_date); // Run exit filters execute_exits( &mut $positions, &mut $cash, day_opts, $config.shares_per_contract, &$config.legs, $exit_filters, $config.profit_pct, $config.loss_pct, $schema, rb_date, &mut $trade_rows, &$config.cost_model, day_stocks, )?; // Recompute portfolio greeks from current market data after exits $portfolio_greeks = compute_portfolio_greeks_from_market( &$positions, day_opts, &$config.legs, ); // Compute total capital including held options let stock_cap = compute_stock_capital(&$stock_holdings, day_stocks); let options_cap = compute_options_capital( &$positions, day_opts, $config.shares_per_contract, day_stocks, ); let total_capital = $cash + stock_cap + options_cap; $peak_value = $peak_value.max(total_capital); // Rebalance stocks let externally_funded = $config.options_budget_pct.is_some() || $config.options_budget_annual_pct.is_some(); let liquid_capital = total_capital - options_cap; let stocks_alloc = if externally_funded { $config.allocation_stocks * liquid_capital } else { // Cap to liquid_capital: can't buy stocks with capital locked in options ($config.allocation_stocks * total_capital).min(liquid_capital) }; $stock_holdings.clear(); $cash = liquid_capital; let sma_prices = $sma_map_by_date.as_ref().and_then(|m| m.get(&rb_date)); buy_stocks( &$config.stock_symbols, &$config.stock_percentages, day_stocks, stocks_alloc, &mut $stock_holdings, &$config.cost_model, &mut $cash, sma_prices, ); // Options: buy with remaining budget only let options_alloc = if let Some(pct) = $config.options_budget_pct { total_capital * pct } else if let Some(annual) = $config.options_budget_annual_pct { total_capital * (annual / rebalances_per_year) } else { $config.allocation_options * total_capital }; let remaining_budget = if $config.options_budget_fresh_spend { options_alloc } else { options_alloc - options_cap }; if remaining_budget > 0.0 { let held: Vec = $positions.iter() .flat_map(|p| p.leg_contracts.clone()) .collect(); if externally_funded { $cash += remaining_budget; } if let Some(pos) = execute_entries( &$config.legs, $entry_filters, day_opts, &held, $config.shares_per_contract, remaining_budget, $schema, rb_date, &mut $trade_rows, &$config.fill_model, &$config.signal_selector, &$config.risk_constraints, &$portfolio_greeks, total_capital, $peak_value, $config.max_notional_pct, &$positions, )? { let cost = pos.entry_cost * pos.quantity; let commission = $config.cost_model.option_cost( cost.abs(), pos.quantity, $config.shares_per_contract, ); $cash -= cost + commission; if externally_funded { // Claw back unspent portion of externally-funded budget $cash -= remaining_budget - cost - commission; } $portfolio_greeks += pos.greeks; $positions.push(pos); } else if externally_funded { $cash -= remaining_budget; } } if $config.stop_if_broke && $cash < 0.0 { break; } }}; } if config.check_exits_daily { // All-dates loop: check exits on every trading day, rebalance on rb dates. use std::collections::HashSet; let rb_set: HashSet = rb_dates.iter().copied().collect(); let mut rb_idx: usize = 0; for &date in &partitioned.all_dates_sorted { if rb_set.contains(&date) { let prev_rb_date = if rb_idx == 0 { date } else { rb_dates[rb_idx - 1] }; rebalance_date!( date, prev_rb_date, &partitioned, config, &entry_filters, &exit_filters, schema, sma_map_by_date, positions, stock_holdings, cash, peak_value, portfolio_greeks, trade_rows, balance_days ); rb_idx += 1; } else if !positions.is_empty() { // Non-rebalance day: only run exits if let Some(day_opts) = partitioned.options.get(&date) { let day_stocks = partitioned.stocks.get(&date); let cash_before = cash; execute_exits( &mut positions, &mut cash, day_opts, config.shares_per_contract, &config.legs, &exit_filters, config.profit_pct, config.loss_pct, schema, date, &mut trade_rows, &config.cost_model, day_stocks, )?; // Immediately reinvest freed cash into stocks if config.rebalance_stocks_on_exit && cash > cash_before { let stock_cap = compute_stock_capital(&stock_holdings, day_stocks); let options_cap = compute_options_capital( &positions, day_opts, config.shares_per_contract, day_stocks, ); let total_capital = cash + stock_cap + options_cap; peak_value = peak_value.max(total_capital); let externally_funded = config.options_budget_pct.is_some() || config.options_budget_annual_pct.is_some(); let liquid_capital = total_capital - options_cap; let stocks_alloc = if externally_funded { config.allocation_stocks * liquid_capital } else { (config.allocation_stocks * total_capital).min(liquid_capital) }; stock_holdings.clear(); cash = liquid_capital; let sma_prices = sma_map_by_date.as_ref().and_then(|m| m.get(&date)); buy_stocks( &config.stock_symbols, &config.stock_percentages, day_stocks, stocks_alloc, &mut stock_holdings, &config.cost_model, &mut cash, sma_prices, ); } } } } } else { // Fast path: iterate only rebalance dates (typical: ~200 vs ~4500 all dates). for (rb_idx, &rb_date) in rb_dates.iter().enumerate() { let prev_rb_date = if rb_idx == 0 { rb_date } else { rb_dates[rb_idx - 1] }; rebalance_date!( rb_date, prev_rb_date, &partitioned, config, &entry_filters, &exit_filters, schema, sma_map_by_date, positions, stock_holdings, cash, peak_value, portfolio_greeks, trade_rows, balance_days ); } } // Final balance update: last rebalance date to end of data let last_rb = *rb_dates.last().unwrap(); let last_date = partitioned.all_dates_sorted.last().copied().unwrap_or(0); if last_date > 0 { compute_balance_period( &positions, &stock_holdings, &partitioned, last_rb, last_date, config.shares_per_contract, cash, &config.legs, &mut balance_days, ); } build_result(&trade_rows, &balance_days, &config.legs, cash) } // --------------------------------------------------------------------------- // Multi-strategy backtest. // --------------------------------------------------------------------------- /// Run a multi-strategy backtest with per-slot inventories, shared stocks/cash. /// /// Each slot has its own legs, rebalance schedule, exit thresholds, and weight. /// The shared config provides allocation, stocks, capital, cost/fill/signal models. pub fn run_multi_strategy( config: &BacktestConfig, slots: &[StrategySlotConfig], partitioned: &PartitionedData, schema: &SchemaMapping, ) -> PolarsResult { use std::collections::HashSet; let mut cash = config.initial_capital; let mut stock_holdings: Vec = Vec::new(); let mut peak_value: f64 = config.initial_capital; // Per-slot state let mut slot_positions: Vec> = slots.iter().map(|_| Vec::new()).collect(); let slot_filters: Vec = slots.iter().map(|slot| { // Build a temporary config just for filter compilation let tmp = BacktestConfig { legs: slot.legs.clone(), ..config.clone() }; PrecompiledFilters::from_config(&tmp) }).collect(); let mut trade_rows: Vec = Vec::new(); let mut balance_days: Vec = Vec::new(); // Pre-compute SMA let sma_map_by_date = config.sma_days .map(|sma_days| compute_sma_map(partitioned, &config.stock_symbols, sma_days)); // Build set of all rebalance dates (union across slots) and per-slot sets let mut all_rb_set: HashSet = HashSet::new(); let slot_rb_sets: Vec> = slots.iter().map(|slot| { let set: HashSet = slot.rebalance_dates.iter().copied().collect(); all_rb_set.extend(&set); set }).collect(); // Pre-compute rebalances per year for annual budget conversion. // Use the union of all slot rebalance dates. let mut all_rb_sorted: Vec = all_rb_set.iter().copied().collect(); all_rb_sorted.sort_unstable(); let rebalances_per_year = if all_rb_sorted.len() >= 2 { let first = all_rb_sorted[0]; let last = *all_rb_sorted.last().unwrap(); let years = (last - first) as f64 / (365.25 * 24.0 * 3600.0 * 1e9); if years > 0.0 { all_rb_sorted.len() as f64 / years } else { all_rb_sorted.len() as f64 } } else { 1.0 }; // All rebalance dates sorted let mut all_rb_dates: Vec = all_rb_set.iter().copied().collect(); all_rb_dates.sort_unstable(); if all_rb_dates.is_empty() { // Use legs from first slot for result columns let legs = if slots.is_empty() { &config.legs } else { &slots[0].legs }; return build_result(&trade_rows, &balance_days, legs, cash); } // Determine if any slot uses daily exits let any_daily_exits = config.check_exits_daily || slots.iter().any(|s| s.check_exits_daily); // Track previous rebalance date for balance computation let mut prev_global_rb: Option = None; for &date in &partitioned.all_dates_sorted { let is_rebalance = all_rb_set.contains(&date); // Which slots rebalance on this date? let slots_rebalancing: Vec = if is_rebalance { (0..slots.len()) .filter(|&i| slot_rb_sets[i].contains(&date)) .collect() } else { Vec::new() }; if is_rebalance { // Compute balance since previous rebalance let prev_rb = prev_global_rb.unwrap_or(date); compute_balance_period_multi( &slot_positions, &stock_holdings, partitioned, prev_rb, date, config.shares_per_contract, cash, slots, &mut balance_days, ); prev_global_rb = Some(date); let day_opts = match partitioned.options.get(&date) { Some(d) if d.height() > 0 => d, _ => continue, }; let day_stocks = partitioned.stocks.get(&date); // Phase 1: exits for rebalancing slots for &si in &slots_rebalancing { execute_exits( &mut slot_positions[si], &mut cash, day_opts, config.shares_per_contract, &slots[si].legs, &slot_filters[si].exit, slots[si].profit_pct, slots[si].loss_pct, schema, date, &mut trade_rows, &config.cost_model, day_stocks, )?; } // Phase 2: compute aggregate capital let stock_cap = compute_stock_capital(&stock_holdings, day_stocks); let options_cap: f64 = slot_positions.iter().map(|positions| { compute_options_capital(positions, day_opts, config.shares_per_contract, day_stocks) }).sum(); let total_capital = cash + stock_cap + options_cap; peak_value = peak_value.max(total_capital); // Options allocation let options_alloc = if let Some(pct) = config.options_budget_pct { total_capital * pct } else if let Some(annual) = config.options_budget_annual_pct { total_capital * (annual / rebalances_per_year) } else { config.allocation_options * total_capital }; // Phase 3: buy stocks (shared pool) let externally_funded = config.options_budget_pct.is_some() || config.options_budget_annual_pct.is_some(); let liquid_capital = total_capital - options_cap; let stocks_alloc = if externally_funded { config.allocation_stocks * liquid_capital } else { // Cap to liquid_capital: can't buy stocks with capital locked in options (config.allocation_stocks * total_capital).min(liquid_capital) }; stock_holdings.clear(); cash = liquid_capital; let sma_prices = sma_map_by_date.as_ref().and_then(|m| m.get(&date)); buy_stocks( &config.stock_symbols, &config.stock_percentages, day_stocks, stocks_alloc, &mut stock_holdings, &config.cost_model, &mut cash, sma_prices, ); // Phase 4: entries per rebalancing slot for &si in &slots_rebalancing { let slot = &slots[si]; let slot_opts_cap = compute_options_capital( &slot_positions[si], day_opts, config.shares_per_contract, day_stocks, ); let slot_allocation = slot.weight * options_alloc; let remaining_budget = if config.options_budget_fresh_spend { slot_allocation } else { slot_allocation - slot_opts_cap }; if remaining_budget > 0.0 { let held: Vec = slot_positions[si].iter() .flat_map(|p| p.leg_contracts.clone()) .collect(); if externally_funded { cash += remaining_budget; } let portfolio_greeks = compute_portfolio_greeks_from_market( &slot_positions[si], day_opts, &slot.legs, ); if let Some(pos) = execute_entries( &slot.legs, &slot_filters[si].entry, day_opts, &held, config.shares_per_contract, remaining_budget, schema, date, &mut trade_rows, &config.fill_model, &config.signal_selector, &config.risk_constraints, &portfolio_greeks, total_capital, peak_value, config.max_notional_pct, &slot_positions[si], )? { let cost = pos.entry_cost * pos.quantity; let commission = config.cost_model.option_cost( cost.abs(), pos.quantity, config.shares_per_contract, ); cash -= cost + commission; if externally_funded { cash -= remaining_budget - cost - commission; } slot_positions[si].push(pos); } else if externally_funded { cash -= remaining_budget; } } } if config.stop_if_broke && cash < 0.0 { break; } } else if any_daily_exits { // Non-rebalance day: run exits for slots with check_exits_daily if let Some(day_opts) = partitioned.options.get(&date) { let day_stocks = partitioned.stocks.get(&date); let cash_before = cash; for (si, slot) in slots.iter().enumerate() { if (slot.check_exits_daily || config.check_exits_daily) && !slot_positions[si].is_empty() { execute_exits( &mut slot_positions[si], &mut cash, day_opts, config.shares_per_contract, &slot.legs, &slot_filters[si].exit, slot.profit_pct, slot.loss_pct, schema, date, &mut trade_rows, &config.cost_model, day_stocks, )?; } } // If exits freed cash and rebalance_stocks_on_exit is set, // immediately reinvest into stocks (e.g. buy discounted stocks // during a crash after puts pay off). if config.rebalance_stocks_on_exit && cash > cash_before { let stock_cap = compute_stock_capital(&stock_holdings, day_stocks); let options_cap: f64 = slot_positions.iter().map(|positions| { compute_options_capital(positions, day_opts, config.shares_per_contract, day_stocks) }).sum(); let total_capital = cash + stock_cap + options_cap; peak_value = peak_value.max(total_capital); let externally_funded = config.options_budget_pct.is_some() || config.options_budget_annual_pct.is_some(); let liquid_capital = total_capital - options_cap; let stocks_alloc = if externally_funded { config.allocation_stocks * liquid_capital } else { (config.allocation_stocks * total_capital).min(liquid_capital) }; stock_holdings.clear(); cash = liquid_capital; let sma_prices = sma_map_by_date.as_ref().and_then(|m| m.get(&date)); buy_stocks( &config.stock_symbols, &config.stock_percentages, day_stocks, stocks_alloc, &mut stock_holdings, &config.cost_model, &mut cash, sma_prices, ); } } } } // Final balance update if let Some(last_rb) = prev_global_rb { let last_date = partitioned.all_dates_sorted.last().copied().unwrap_or(0); if last_date > 0 { compute_balance_period_multi( &slot_positions, &stock_holdings, partitioned, last_rb, last_date, config.shares_per_contract, cash, slots, &mut balance_days, ); } } // Use legs from first slot for result columns let legs = if slots.is_empty() { &config.legs } else { &slots[0].legs }; build_result(&trade_rows, &balance_days, legs, cash) } /// Compute balance for multi-strategy across all slot positions. fn compute_balance_period_multi( slot_positions: &[Vec], stock_holdings: &[StockHolding], partitioned: &PartitionedData, start_date: i64, end_date: i64, spc: i64, cash: f64, slots: &[StrategySlotConfig], balance_days: &mut Vec, ) { let dates = &partitioned.all_dates_sorted; let start_idx = dates.partition_point(|&d| d < start_date); let end_idx = dates.partition_point(|&d| d < end_date); for &d in &dates[start_idx..end_idx] { let day_opts = partitioned.options.get(&d); let day_stocks = partitioned.stocks.get(&d); let mut calls_cap = 0.0; let mut puts_cap = 0.0; let mut options_qty = 0.0; if let Some(opts) = day_opts { for (si, positions) in slot_positions.iter().enumerate() { let legs = &slots[si].legs; for pos in positions { options_qty += pos.quantity; for (j, leg) in legs.iter().enumerate() { if j >= pos.leg_contracts.len() { continue; } let exit_price_col = leg.direction.invert().price_column(); let price = opts.get_f64(&pos.leg_contracts[j], exit_price_col) .unwrap_or_else(|| { let spot = day_stocks .and_then(|ds| ds.get_price(&pos.leg_underlyings[j])) .unwrap_or(0.0); intrinsic_value(&pos.leg_types[j], pos.leg_strikes[j], spot) }); let sign = leg.direction.invert().sign(); let value = sign * price * pos.quantity * spc as f64; if pos.leg_types[j] == "call" { calls_cap += value; } else { puts_cap += value; } } } } } let mut stock_values = Vec::new(); let mut stock_qtys = Vec::new(); let mut stocks_qty = 0.0; for holding in stock_holdings { let price = day_stocks .and_then(|ds| ds.get_price(&holding.symbol)) .unwrap_or(holding.price); stock_values.push((holding.symbol.clone(), holding.qty * price)); stock_qtys.push((holding.symbol.clone(), holding.qty)); stocks_qty += holding.qty; } balance_days.push(BalanceDay { date: d, cash, calls_capital: calls_cap, puts_capital: puts_cap, options_qty, stocks_qty, stock_values, stock_qtys, }); } } // --------------------------------------------------------------------------- // Data pre-partitioning — called once at startup. // --------------------------------------------------------------------------- pub fn prepartition_data( options_data: &DataFrame, stocks_data: &DataFrame, schema: &SchemaMapping, ) -> PolarsResult { let date_col = &schema.date; let contract_col = &schema.contract; // Sort options by date (skip if already sorted — common for CSV data). // slice() is zero-copy (shares underlying Arrow arrays via Arc). let date_series_raw = options_data.column(date_col)?; let n_check = options_data.height(); let already_sorted = if n_check < 2 { true } else { let first = extract_date_ns(date_series_raw, 0); let last = extract_date_ns(date_series_raw, n_check - 1); if first > last { false } else { // Sample a few points to verify monotonicity cheaply. let step = (n_check / 8).max(1); let mut prev = first; let mut sorted = true; let mut i = step; while i < n_check { let val = extract_date_ns(date_series_raw, i); if val < prev { sorted = false; break; } prev = val; i += step; } sorted } }; let sorted_opts; let date_series; if already_sorted { sorted_opts = options_data.clone(); date_series = date_series_raw; } else { sorted_opts = options_data.sort([date_col.as_str()], SortMultipleOptions::default())?; date_series = sorted_opts.column(date_col)?; }; let n_opts = sorted_opts.height(); // Estimate ~500 unique dates for typical datasets (avoids HashMap reallocations). let mut options_map: HashMap = HashMap::with_capacity(512); let mut all_dates: Vec = Vec::with_capacity(512); if n_opts > 0 { let mut start = 0; let mut current = extract_date_ns(date_series, 0); for i in 1..n_opts { let d = extract_date_ns(date_series, i); if d != current { let part = sorted_opts.slice(start as i64, i - start); all_dates.push(current); options_map.insert(current, DayOptions::new(part, contract_col)); current = d; start = i; } } // Last group let part = sorted_opts.slice(start as i64, n_opts - start); all_dates.push(current); options_map.insert(current, DayOptions::new(part, contract_col)); } // Stocks: iterate once and build price HashMaps directly. // (Small data — typically 4500 rows, so no sort+slice needed.) let stocks_date_col = &schema.stocks_date; let sym_col_name = &schema.stocks_sym; let price_col_name = &schema.stocks_price; let stocks_date_series = stocks_data.column(stocks_date_col)?; let sym_ca = stocks_data.column(sym_col_name)?.str()?; let price_raw = stocks_data.column(price_col_name)?; let price_casted = price_raw.cast(&DataType::Float64)?; let price_ca = price_casted.f64()?; let n_stocks = stocks_data.height(); let mut stocks_map: HashMap = HashMap::with_capacity(512); for i in 0..n_stocks { let date_ns = extract_date_ns(stocks_date_series, i); if let (Some(sym), Some(price)) = (sym_ca.get(i), price_ca.get(i)) { stocks_map.entry(date_ns) .or_insert_with(|| DayStocks { prices: HashMap::new() }) .prices .insert(sym.to_string(), price); } } Ok(PartitionedData { options: options_map, stocks: stocks_map, all_dates_sorted: all_dates, }) } // --------------------------------------------------------------------------- // Execute exits. // --------------------------------------------------------------------------- fn execute_exits( positions: &mut Vec, cash: &mut f64, day_opts: &DayOptions, spc: i64, legs: &[LegConfig], exit_filters: &[Option], profit_pct: Option, loss_pct: Option, schema: &SchemaMapping, date: i64, trade_rows: &mut Vec, cost_model: &CostModel, day_stocks: Option<&DayStocks>, ) -> PolarsResult<()> { let mut to_remove = Vec::new(); for (i, pos) in positions.iter().enumerate() { let mut should_exit = false; // Check exit filters per leg — direct row evaluation, no Polars lazy overhead. for (j, _leg) in legs.iter().enumerate() { if let Some(ref flt) = exit_filters[j] { let contract = &pos.leg_contracts[j]; if let Some(&row_idx) = day_opts.contract_idx.get(contract.as_str()) { // Contract exists today — check exit filter on its row. if flt.eval_row(&day_opts.df, row_idx) { should_exit = true; } } else { // Contract not in today's data → exit. should_exit = true; } } } // Check threshold exits — mirrors Python's Strategy.filter_thresholds: // excess_return = (current_cost / entry_cost + 1) * -sign(entry_cost) if !should_exit { let curr = compute_position_exit_cost(pos, day_opts, spc, day_stocks); let entry = pos.entry_cost; if entry != 0.0 { let excess_return = (curr / entry + 1.0) * -entry.signum(); if profit_pct.is_some_and(|p| excess_return >= p) || loss_pct.is_some_and(|l| excess_return <= -l) { should_exit = true; } } } if should_exit { let exit_cost = compute_position_exit_cost(pos, day_opts, spc, day_stocks); *cash -= exit_cost * pos.quantity; // Apply exit commission let commission = cost_model.option_cost( exit_cost.abs(), pos.quantity.abs(), spc, ); *cash -= commission; // Build trade row for exit let mut leg_data = Vec::new(); for (j, leg) in legs.iter().enumerate() { let exit_price_col = leg.direction.invert().price_column(); let price = day_opts.get_f64(&pos.leg_contracts[j], exit_price_col) .unwrap_or_else(|| { let spot = day_stocks .and_then(|ds| ds.get_price(&pos.leg_underlyings[j])) .unwrap_or(0.0); intrinsic_value(&pos.leg_types[j], pos.leg_strikes[j], spot) }); // Cash flow sign: BUY receives (-1), SELL pays (+1) let cash_sign = if leg.direction == Direction::Buy { -1.0 } else { 1.0 }; let cost = cash_sign * price * spc as f64; let order = match leg.direction { Direction::Buy => "STC", Direction::Sell => "BTC", }; leg_data.push(LegTradeData { contract: pos.leg_contracts[j].clone(), underlying: day_opts.get_str(&pos.leg_contracts[j], &schema.underlying) .unwrap_or_else(|| pos.leg_underlyings[j].clone()), expiration: day_opts.get_str(&pos.leg_contracts[j], &schema.expiration) .unwrap_or_else(|| pos.leg_expirations[j].clone()), opt_type: pos.leg_types[j].clone(), strike: day_opts.get_f64(&pos.leg_contracts[j], &schema.strike) .unwrap_or(pos.leg_strikes[j]), cost, order: order.to_string(), }); } trade_rows.push(TradeRow { date, leg_data, total_cost: exit_cost, qty: pos.quantity, }); to_remove.push(i); } } for &i in to_remove.iter().rev() { positions.remove(i); } Ok(()) } // --------------------------------------------------------------------------- // Liquidate all remaining option positions (full rebalance). // --------------------------------------------------------------------------- #[allow(dead_code)] fn liquidate_all_positions( positions: &mut Vec, cash: &mut f64, day_opts: &DayOptions, spc: i64, legs: &[LegConfig], schema: &SchemaMapping, date: i64, trade_rows: &mut Vec, cost_model: &CostModel, day_stocks: Option<&DayStocks>, ) { for pos in positions.iter() { let exit_cost = compute_position_exit_cost(pos, day_opts, spc, day_stocks); *cash -= exit_cost * pos.quantity; let commission = cost_model.option_cost( exit_cost.abs(), pos.quantity.abs(), spc, ); *cash -= commission; let mut leg_data = Vec::new(); for (j, leg) in legs.iter().enumerate() { let exit_price_col = leg.direction.invert().price_column(); let price = day_opts.get_f64(&pos.leg_contracts[j], exit_price_col) .unwrap_or_else(|| { let spot = day_stocks .and_then(|ds| ds.get_price(&pos.leg_underlyings[j])) .unwrap_or(0.0); intrinsic_value(&pos.leg_types[j], pos.leg_strikes[j], spot) }); let cash_sign = if leg.direction == Direction::Buy { -1.0 } else { 1.0 }; let cost = cash_sign * price * spc as f64; let order = match leg.direction { Direction::Buy => "STC", Direction::Sell => "BTC", }; leg_data.push(LegTradeData { contract: pos.leg_contracts[j].clone(), underlying: day_opts.get_str(&pos.leg_contracts[j], &schema.underlying) .unwrap_or_else(|| pos.leg_underlyings[j].clone()), expiration: day_opts.get_str(&pos.leg_contracts[j], &schema.expiration) .unwrap_or_else(|| pos.leg_expirations[j].clone()), opt_type: pos.leg_types[j].clone(), strike: day_opts.get_f64(&pos.leg_contracts[j], &schema.strike) .unwrap_or(pos.leg_strikes[j]), cost, order: order.to_string(), }); } trade_rows.push(TradeRow { date, leg_data, total_cost: exit_cost, qty: pos.quantity, }); } positions.clear(); } // --------------------------------------------------------------------------- // Execute entries. // --------------------------------------------------------------------------- #[allow(clippy::too_many_arguments)] fn execute_entries( legs: &[LegConfig], entry_filters: &[Option], day_opts: &DayOptions, held_contracts: &[String], spc: i64, budget: f64, schema: &SchemaMapping, date: i64, trade_rows: &mut Vec, fill_model: &FillModel, signal_selector: &SignalSelector, risk_constraints: &[RiskConstraint], portfolio_greeks: &Greeks, total_capital: f64, peak_value: f64, max_notional_pct: Option, existing_positions: &[Position], ) -> PolarsResult> { let contract_col = &schema.contract; if legs.is_empty() || budget <= 0.0 { return Ok(None); } // Determine extra columns needed by selectors let mut extra_cols: Vec = Vec::new(); for col_name in signal_selector.column_requirements() { extra_cols.push(col_name.to_string()); } for leg in legs { if let Some(ref sel) = leg.signal_selector { for col_name in sel.column_requirements() { if !extra_cols.contains(&col_name.to_string()) { extra_cols.push(col_name.to_string()); } } } } let mut leg_results: Vec = Vec::new(); for (i, leg) in legs.iter().enumerate() { let filter = match &entry_filters[i] { Some(f) => f, None => return Ok(None), }; let entries = compute_leg_entries( &day_opts.df, held_contracts, filter, contract_col, leg.direction.price_column(), leg.entry_sort_col.as_deref(), leg.entry_sort_asc, spc, leg.direction == Direction::Sell, &extra_cols, )?; if entries.height() == 0 { return Ok(None); } leg_results.push(entries); } // Pre-filter candidates by affordability (mirrors Python's qty > 0 filter). // Python computes qty = allocation // abs(total_cost) for every candidate row // and removes rows where qty == 0 before the signal selector runs. { let min_len = leg_results.iter().map(|df| df.height()).min().unwrap_or(0); if min_len == 0 { return Ok(None); } // Compute per-row total cost (sum of costs across all legs at each row index) let mut affordable = vec![false; min_len]; for row in 0..min_len { let mut combined_cost = 0.0; for leg_df in &leg_results { if let Ok(col) = leg_df.column("cost") { if let Ok(ca) = col.f64() { combined_cost += ca.get(row).unwrap_or(0.0); } } } let abs_cost = combined_cost.abs(); if abs_cost > 0.0 && (budget / abs_cost).floor() >= 1.0 { affordable[row] = true; } } // Build a boolean mask and filter all legs to only affordable rows let mask = BooleanChunked::from_slice("mask".into(), &affordable); for leg_df in &mut leg_results { // Truncate to min_len first (align by position like Python's reset_index) if leg_df.height() > min_len { *leg_df = leg_df.slice(0, min_len); } *leg_df = leg_df.filter(&mask)?; } if leg_results.iter().any(|df| df.height() == 0) { return Ok(None); } } // Apply signal selector per leg to pick the best row let mut leg_contracts = Vec::new(); let mut leg_types = Vec::new(); let mut leg_directions = Vec::new(); let mut leg_underlyings = Vec::new(); let mut leg_expirations = Vec::new(); let mut leg_strikes = Vec::new(); let mut total_cost = 0.0; let mut original_total_cost = 0.0; let mut leg_data = Vec::new(); let mut entry_greeks = Greeks::default(); for (i, leg_df) in leg_results.iter().enumerate() { // Per-leg selector override, or engine-level selector let sel = legs[i].signal_selector.as_ref().unwrap_or(signal_selector); let row_idx = sel.select_index(leg_df); let contract = leg_df.column("contract")?.str()?.get(row_idx).unwrap_or("").to_string(); let opt_type = leg_df.column("type")?.str()?.get(row_idx).unwrap_or("").to_string(); let original_cost = leg_df.column("cost")?.f64()?.get(row_idx).unwrap_or(0.0); let mut cost = original_cost; let underlying = leg_df.column("underlying")?.str()?.get(row_idx).unwrap_or("").to_string(); // Handle expiration as either String or Datetime let expiration = column_value_to_string(leg_df.column("expiration")?, row_idx); let strike = leg_df.column("strike")?.f64()?.get(row_idx).unwrap_or(0.0); // Apply fill model to re-price if not MarketAtBidAsk let leg_fill = legs[i].fill_model.as_ref().unwrap_or(fill_model); if !matches!(leg_fill, FillModel::MarketAtBidAsk) { // Look up bid/ask/volume from day data for this contract if let (Some(bid), Some(ask)) = ( day_opts.get_f64(&contract, "bid"), day_opts.get_f64(&contract, "ask"), ) { let volume = day_opts.get_f64(&contract, "volume"); let is_buy = legs[i].direction == Direction::Buy; let fill_price = leg_fill.fill_price(bid, ask, volume, is_buy); let sign = if is_buy { 1.0 } else { -1.0 }; cost = sign * fill_price * spc as f64; } } // Collect Greeks from the entry row (for risk checking) let dir_sign = if legs[i].direction == Direction::Buy { 1.0 } else { -1.0 }; let delta = day_opts.get_f64(&contract, "delta").unwrap_or(0.0); let gamma = day_opts.get_f64(&contract, "gamma").unwrap_or(0.0); let theta = day_opts.get_f64(&contract, "theta").unwrap_or(0.0); let vega = day_opts.get_f64(&contract, "vega").unwrap_or(0.0); let order = match legs[i].direction { Direction::Buy => "BTO", Direction::Sell => "STO", }; leg_contracts.push(contract.clone()); leg_types.push(opt_type.clone()); leg_directions.push(legs[i].direction); leg_underlyings.push(underlying.clone()); leg_expirations.push(expiration.clone()); leg_strikes.push(strike); leg_data.push(LegTradeData { contract, underlying, expiration, opt_type, strike, cost, order: order.to_string(), }); total_cost += cost; original_total_cost += original_cost; // Accumulate greeks (will be scaled by qty later) entry_greeks.delta += delta * dir_sign; entry_greeks.gamma += gamma * dir_sign; entry_greeks.theta += theta * dir_sign; entry_greeks.vega += vega * dir_sign; } if original_total_cost.abs() == 0.0 { return Ok(None); } // Qty is computed from the original (pre-fill-model) cost, matching Python behavior // where qty = allocation // abs(original_cost) is computed before fill model repricing. let mut qty = (budget / original_total_cost.abs()).floor(); if qty <= 0.0 { return Ok(None); } // max_notional_pct: cap qty so total short notional stays under limit if let Some(max_pct) = max_notional_pct { let mut short_notional_per_contract = 0.0; for (i, leg) in legs.iter().enumerate() { if leg.direction == Direction::Sell { short_notional_per_contract += leg_strikes[i] * spc as f64; } } if short_notional_per_contract > 0.0 { let existing_short_notional: f64 = existing_positions.iter().map(|pos| { pos.leg_strikes.iter().enumerate() .filter(|(j, _)| pos.leg_directions[*j] == Direction::Sell) .map(|(_, &strike)| strike * pos.quantity * spc as f64) .sum::() }).sum(); let max_notional = max_pct * total_capital; let available = (max_notional - existing_short_notional).max(0.0); let max_qty = (available / short_notional_per_contract).floor(); qty = qty.min(max_qty); if qty <= 0.0 { return Ok(None); } } } // Scale greeks by quantity let scaled_greeks = entry_greeks.scale(qty); // Risk check: reject entry if any constraint fails if !risk_constraints.is_empty() && !risk::check_all(risk_constraints, portfolio_greeks, &scaled_greeks, total_capital, peak_value) { return Ok(None); } trade_rows.push(TradeRow { date, leg_data, total_cost, qty, }); Ok(Some(Position { leg_contracts, leg_types, leg_directions, quantity: qty, entry_cost: total_cost, greeks: scaled_greeks, leg_underlyings, leg_expirations, leg_strikes, })) } // --------------------------------------------------------------------------- // Compute balance for a date range — uses pre-partitioned data. // --------------------------------------------------------------------------- fn compute_balance_period( positions: &[Position], stock_holdings: &[StockHolding], partitioned: &PartitionedData, start_date: i64, end_date: i64, spc: i64, cash: f64, legs: &[LegConfig], balance_days: &mut Vec, ) { // Binary search for dates in [start_date, end_date). let dates = &partitioned.all_dates_sorted; let start_idx = dates.partition_point(|&d| d < start_date); let end_idx = dates.partition_point(|&d| d < end_date); for &d in &dates[start_idx..end_idx] { let day_opts = partitioned.options.get(&d); let day_stocks = partitioned.stocks.get(&d); // Compute calls/puts capital for each position let mut calls_cap = 0.0; let mut puts_cap = 0.0; let mut options_qty = 0.0; if let Some(opts) = day_opts { for pos in positions { options_qty += pos.quantity; for (j, leg) in legs.iter().enumerate() { if j >= pos.leg_contracts.len() { continue; } let exit_price_col = leg.direction.invert().price_column(); let price = opts.get_f64(&pos.leg_contracts[j], exit_price_col) .unwrap_or_else(|| { let spot = day_stocks .and_then(|ds| ds.get_price(&pos.leg_underlyings[j])) .unwrap_or(0.0); intrinsic_value(&pos.leg_types[j], pos.leg_strikes[j], spot) }); let sign = leg.direction.invert().sign(); let value = sign * price * pos.quantity * spc as f64; if pos.leg_types[j] == "call" { calls_cap += value; } else { puts_cap += value; } } } } // Compute stock values let mut stock_values = Vec::new(); let mut stock_qtys = Vec::new(); let mut stocks_qty = 0.0; for holding in stock_holdings { let price = day_stocks .and_then(|ds| ds.get_price(&holding.symbol)) .unwrap_or(holding.price); stock_values.push((holding.symbol.clone(), holding.qty * price)); stock_qtys.push((holding.symbol.clone(), holding.qty)); stocks_qty += holding.qty; } balance_days.push(BalanceDay { date: d, cash, calls_capital: calls_cap, puts_capital: puts_cap, options_qty, stocks_qty, stock_values, stock_qtys, }); } } // --------------------------------------------------------------------------- // SMA computation — uses pre-partitioned stocks data. // --------------------------------------------------------------------------- fn compute_sma_map( partitioned: &PartitionedData, symbols: &[String], sma_days: usize, ) -> HashMap> { let mut result: HashMap> = HashMap::new(); for symbol in symbols { // Collect (date_ns, price) pairs for this symbol from pre-partitioned data. let mut date_prices: Vec<(i64, f64)> = Vec::new(); for &date_ns in &partitioned.all_dates_sorted { if let Some(ds) = partitioned.stocks.get(&date_ns) { if let Some(price) = ds.get_price(symbol) { date_prices.push((date_ns, price)); } } } // Compute rolling SMA for (i, &(date_ns, _)) in date_prices.iter().enumerate() { if i + 1 < sma_days { continue; // Not enough data yet } let start = i + 1 - sma_days; let sum: f64 = date_prices[start..=i].iter().map(|&(_, p)| p).sum(); let sma = sum / sma_days as f64; result.entry(date_ns) .or_default() .insert(symbol.clone(), sma); } } result } // --------------------------------------------------------------------------- // Build result DataFrames. // --------------------------------------------------------------------------- fn build_result( trade_rows: &[TradeRow], balance_days: &[BalanceDay], legs: &[LegConfig], final_cash: f64, ) -> PolarsResult { // Build trade log as flat DataFrame (Python converts to MultiIndex) let n_trades = trade_rows.len(); let mut trade_dates: Vec = Vec::with_capacity(n_trades); let mut trade_total_costs: Vec = Vec::with_capacity(n_trades); let mut trade_qtys: Vec = Vec::with_capacity(n_trades); // Per-leg columns let mut leg_columns: Vec> = legs.iter().map(|_| Vec::with_capacity(n_trades)).collect(); for tr in trade_rows { trade_dates.push(ns_to_datestring(tr.date)); trade_total_costs.push(tr.total_cost); trade_qtys.push(tr.qty); for (j, ld) in tr.leg_data.iter().enumerate() { if j < leg_columns.len() { leg_columns[j].push(( ld.contract.clone(), ld.underlying.clone(), ld.expiration.clone(), ld.opt_type.clone(), ld.strike, ld.cost, ld.order.clone(), )); } } } let mut trade_cols: Vec = vec![ Column::new("totals__date".into(), &trade_dates), Column::new("totals__cost".into(), &trade_total_costs), Column::new("totals__qty".into(), &trade_qtys), ]; for (j, leg) in legs.iter().enumerate() { if j < leg_columns.len() { let data = &leg_columns[j]; let prefix = &leg.name; trade_cols.push(Column::new(format!("{prefix}__contract").into(), data.iter().map(|d| d.0.as_str()).collect::>())); trade_cols.push(Column::new(format!("{prefix}__underlying").into(), data.iter().map(|d| d.1.as_str()).collect::>())); trade_cols.push(Column::new(format!("{prefix}__expiration").into(), data.iter().map(|d| d.2.as_str()).collect::>())); trade_cols.push(Column::new(format!("{prefix}__type").into(), data.iter().map(|d| d.3.as_str()).collect::>())); trade_cols.push(Column::new(format!("{prefix}__strike").into(), data.iter().map(|d| d.4).collect::>())); trade_cols.push(Column::new(format!("{prefix}__cost").into(), data.iter().map(|d| d.5).collect::>())); trade_cols.push(Column::new(format!("{prefix}__order").into(), data.iter().map(|d| d.6.as_str()).collect::>())); } } let trade_log = DataFrame::new(trade_cols)?; // Build balance DataFrame let n_days = balance_days.len(); let mut bal_dates: Vec = Vec::with_capacity(n_days); let mut bal_cash: Vec = Vec::with_capacity(n_days); let mut bal_calls: Vec = Vec::with_capacity(n_days); let mut bal_puts: Vec = Vec::with_capacity(n_days); let mut bal_opts_qty: Vec = Vec::with_capacity(n_days); let mut bal_stocks_qty: Vec = Vec::with_capacity(n_days); // Collect all stock symbols let mut stock_symbols: Vec = Vec::new(); if let Some(first) = balance_days.first() { stock_symbols = first.stock_values.iter().map(|(s, _)| s.clone()).collect(); } let mut stock_val_cols: Vec> = stock_symbols.iter().map(|_| Vec::with_capacity(n_days)).collect(); let mut stock_qty_cols: Vec> = stock_symbols.iter().map(|_| Vec::with_capacity(n_days)).collect(); for day in balance_days { bal_dates.push(ns_to_datestring(day.date)); bal_cash.push(day.cash); bal_calls.push(day.calls_capital); bal_puts.push(day.puts_capital); bal_opts_qty.push(day.options_qty); bal_stocks_qty.push(day.stocks_qty); for (k, sym) in stock_symbols.iter().enumerate() { let val = day.stock_values.iter().find(|(s, _)| s == sym).map(|(_, v)| *v).unwrap_or(0.0); let qty = day.stock_qtys.iter().find(|(s, _)| s == sym).map(|(_, q)| *q).unwrap_or(0.0); stock_val_cols[k].push(val); stock_qty_cols[k].push(qty); } } let mut bal_cols: Vec = vec![ Column::new("date".into(), &bal_dates), Column::new("cash".into(), &bal_cash), Column::new("calls capital".into(), &bal_calls), Column::new("puts capital".into(), &bal_puts), Column::new("options qty".into(), &bal_opts_qty), Column::new("stocks qty".into(), &bal_stocks_qty), ]; for (k, sym) in stock_symbols.iter().enumerate() { bal_cols.push(Column::new(sym.as_str().into(), &stock_val_cols[k])); bal_cols.push(Column::new(format!("{sym} qty").into(), &stock_qty_cols[k])); } let balance = DataFrame::new(bal_cols)?; // Stats from balance let totals: Vec = balance_days.iter().map(|d| { let stock_val: f64 = d.stock_values.iter().map(|(_, v)| *v).sum(); d.cash + d.calls_capital + d.puts_capital + stock_val }).collect(); let daily_returns = compute_daily_returns(&totals); let result_stats = stats::compute_stats(&daily_returns, &[], 0.0); Ok(BacktestResult { balance, trade_log, final_cash, stats: result_stats }) } // --------------------------------------------------------------------------- // Small helpers. // --------------------------------------------------------------------------- /// Compute intrinsic value of an option: max(0, strike - spot) for puts, /// max(0, spot - strike) for calls. fn intrinsic_value(opt_type: &str, strike: f64, underlying_price: f64) -> f64 { if opt_type == "call" { (underlying_price - strike).max(0.0) } else { (strike - underlying_price).max(0.0) } } /// Compute the total exit cost for a position — O(1) per leg via DayOptions. fn compute_position_exit_cost(pos: &Position, day_opts: &DayOptions, spc: i64, day_stocks: Option<&DayStocks>) -> f64 { let mut total = 0.0; for (i, contract) in pos.leg_contracts.iter().enumerate() { let dir = pos.leg_directions[i]; let price = day_opts.get_f64(contract, dir.invert().price_column()) .unwrap_or_else(|| { let spot = day_stocks .and_then(|ds| ds.get_price(&pos.leg_underlyings[i])) .unwrap_or(0.0); intrinsic_value(&pos.leg_types[i], pos.leg_strikes[i], spot) }); let cash_sign = if dir == Direction::Buy { -1.0 } else { 1.0 }; total += cash_sign * price * spc as f64; } total } fn compute_stock_capital(holdings: &[StockHolding], day_stocks: Option<&DayStocks>) -> f64 { let ds = match day_stocks { Some(ds) => ds, None => return 0.0, }; holdings.iter().map(|h| { ds.get_price(&h.symbol).unwrap_or(0.0) * h.qty }).sum() } fn compute_options_capital( positions: &[Position], day_opts: &DayOptions, spc: i64, day_stocks: Option<&DayStocks>, ) -> f64 { positions.iter().map(|pos| { -compute_position_exit_cost(pos, day_opts, spc, day_stocks) * pos.quantity }).sum() } /// Compute aggregate portfolio greeks from CURRENT market data, matching /// Python's `_compute_portfolio_greeks(options)` which merges inventory /// contracts with today's options to get current delta/gamma/theta/vega. fn compute_portfolio_greeks_from_market( positions: &[Position], day_opts: &DayOptions, legs: &[LegConfig], ) -> Greeks { let mut total = Greeks::default(); for pos in positions { for (j, leg) in legs.iter().enumerate() { if j >= pos.leg_contracts.len() { continue; } let contract = &pos.leg_contracts[j]; let dir_sign = if leg.direction == Direction::Buy { 1.0 } else { -1.0 }; let delta = day_opts.get_f64(contract, "delta").unwrap_or(0.0); let gamma = day_opts.get_f64(contract, "gamma").unwrap_or(0.0); let theta = day_opts.get_f64(contract, "theta").unwrap_or(0.0); let vega = day_opts.get_f64(contract, "vega").unwrap_or(0.0); total.delta += delta * dir_sign * pos.quantity; total.gamma += gamma * dir_sign * pos.quantity; total.theta += theta * dir_sign * pos.quantity; total.vega += vega * dir_sign * pos.quantity; } } total } fn buy_stocks( symbols: &[String], percentages: &[f64], day_stocks: Option<&DayStocks>, allocation: f64, holdings: &mut Vec, cost_model: &CostModel, cash: &mut f64, sma_prices: Option<&HashMap>, ) { let ds = match day_stocks { Some(ds) => ds, None => return, }; let mut stock_cost_total = 0.0; let mut commission_total = 0.0; for (symbol, pct) in symbols.iter().zip(percentages) { if let Some(price) = ds.get_price(symbol) { if price > 0.0 { // SMA gating: only buy if sma < price if let Some(sma_map) = sma_prices { if let Some(&sma_val) = sma_map.get(symbol) { if sma_val >= price { holdings.push(StockHolding { symbol: symbol.clone(), qty: 0.0, price }); continue; } } } let qty = (allocation * pct / price).floor(); commission_total += cost_model.stock_cost(price, qty); stock_cost_total += qty * price; holdings.push(StockHolding { symbol: symbol.clone(), qty, price }); } } } *cash -= stock_cost_total + commission_total; } fn compute_daily_returns(totals: &[f64]) -> Vec { if totals.len() < 2 { return Vec::new(); } totals.windows(2).map(|w| if w[0] != 0.0 { (w[1] - w[0]) / w[0] } else { 0.0 }).collect() } #[cfg(test)] mod tests { use super::*; #[test] fn daily_returns_basic() { let totals = vec![100.0, 110.0, 105.0]; let returns = compute_daily_returns(&totals); assert_eq!(returns.len(), 2); assert!((returns[0] - 0.1).abs() < 1e-10); assert!((returns[1] - (-5.0 / 110.0)).abs() < 1e-10); } #[test] fn daily_returns_empty() { assert!(compute_daily_returns(&[]).is_empty()); assert!(compute_daily_returns(&[100.0]).is_empty()); } #[test] fn ns_to_datestring_epoch() { assert_eq!(ns_to_datestring(0), "1970-01-01 00:00:00"); } #[test] fn ns_roundtrip() { let s = "2024-06-15 00:00:00"; let ns = parse_datestring_to_ns(s).unwrap(); assert_eq!(ns_to_datestring(ns), s); } #[test] fn intrinsic_value_put_itm() { // Put with strike 400, spot 380 → intrinsic = 20 assert!((intrinsic_value("put", 400.0, 380.0) - 20.0).abs() < 1e-10); } #[test] fn intrinsic_value_put_otm() { // Put with strike 400, spot 420 → intrinsic = 0 assert!((intrinsic_value("put", 400.0, 420.0)).abs() < 1e-10); } #[test] fn intrinsic_value_call_itm() { // Call with strike 400, spot 420 → intrinsic = 20 assert!((intrinsic_value("call", 400.0, 420.0) - 20.0).abs() < 1e-10); } #[test] fn intrinsic_value_call_otm() { // Call with strike 400, spot 380 → intrinsic = 0 assert!((intrinsic_value("call", 400.0, 380.0)).abs() < 1e-10); } #[test] fn intrinsic_value_atm() { // ATM: strike == spot → intrinsic = 0 for both assert!((intrinsic_value("put", 400.0, 400.0)).abs() < 1e-10); assert!((intrinsic_value("call", 400.0, 400.0)).abs() < 1e-10); } #[test] fn exit_cost_uses_intrinsic_when_missing() { // Position with a put at strike 400, spot at 380 // Contract not in day options → should use intrinsic (20.0) let pos = Position { leg_contracts: vec!["MISSING_CONTRACT".into()], leg_types: vec!["put".into()], leg_directions: vec![Direction::Sell], quantity: 1.0, entry_cost: -100.0, greeks: Greeks::default(), leg_underlyings: vec!["SPY".into()], leg_expirations: vec!["2024-06-01".into()], leg_strikes: vec![400.0], }; let empty_opts = DayOptions { df: DataFrame::default(), contract_idx: HashMap::new(), }; let mut prices = HashMap::new(); prices.insert("SPY".to_string(), 380.0); let day_stocks = DayStocks { prices }; // Sell direction → exit price col is "ask" (invert of Sell = Buy → price_column = "ask") // cash_sign for Sell = +1 // exit_cost = +1 * 20.0 * 100 = 2000.0 let exit_cost = compute_position_exit_cost(&pos, &empty_opts, 100, Some(&day_stocks)); assert!((exit_cost - 2000.0).abs() < 1e-10, "Expected exit cost 2000.0 for ITM short put, got {exit_cost}"); } } ================================================ FILE: rust/ob_core/src/balance.rs ================================================ //! Full _update_balance orchestration in Rust. //! //! Mirrors Python's BacktestEngine._update_balance: for a date range, //! join inventory to market data, compute calls/puts capital, stock values, //! and assemble balance rows. use polars::prelude::*; use crate::inventory::{aggregate_by_type, join_inventory_to_market}; use crate::types::Direction; /// Leg inventory data needed for balance computation. pub struct LegInventory { pub contracts: Vec, pub qtys: Vec, pub types: Vec, pub direction: Direction, pub underlyings: Vec, pub strikes: Vec, } /// Stock inventory data. pub struct StockInventory { pub symbols: Vec, pub qtys: Vec, } /// Compute balance for a date range. /// /// This is the full orchestration of the hot path: /// 1. For each leg, join inventory to market data /// 2. Aggregate calls/puts capital by date /// 3. Compute stock values /// 4. Assemble balance rows pub fn compute_balance( legs: &[LegInventory], stocks: &StockInventory, options_data: &DataFrame, stocks_data: &DataFrame, contract_col: &str, date_col: &str, stocks_date_col: &str, stocks_sym_col: &str, stocks_price_col: &str, shares_per_contract: i64, cash: f64, ) -> PolarsResult { // Build a stocks snapshot for intrinsic value fallback: // For balance computation we pass the full stocks_data to inventory join // so it can look up spot prices for missing contracts. // Get unique dates from options let dates = options_data .column(date_col)? .unique()?; let mut calls_total = Series::new("calls_capital".into(), vec![0.0f64; dates.len()]); let mut puts_total = Series::new("puts_capital".into(), vec![0.0f64; dates.len()]); // Process each leg for leg in legs { if leg.contracts.is_empty() { continue; } let cost_field = match leg.direction { Direction::Buy => "bid", // exit price for buy = bid Direction::Sell => "ask", // exit price for sell = ask }; let joined = join_inventory_to_market( &leg.contracts, &leg.qtys, &leg.types, &leg.underlyings, &leg.strikes, options_data, Some(stocks_data), contract_col, date_col, cost_field, Some(stocks_sym_col), Some(stocks_price_col), leg.direction.invert(), shares_per_contract, )?; let (calls_df, puts_df) = aggregate_by_type(&joined, date_col)?; // Add to running totals (would need date alignment in production) if calls_df.height() > 0 { if let Ok(col) = calls_df.column("calls_capital") { let vals = col.f64()?; let total_vals = calls_total.f64()?; let new: Float64Chunked = total_vals .into_iter() .zip(vals.into_iter()) .map(|(a, b)| Some(a.unwrap_or(0.0) + b.unwrap_or(0.0))) .collect(); calls_total = new.into_series(); } } if puts_df.height() > 0 { if let Ok(col) = puts_df.column("puts_capital") { let vals = col.f64()?; let total_vals = puts_total.f64()?; let new: Float64Chunked = total_vals .into_iter() .zip(vals.into_iter()) .map(|(a, b)| Some(a.unwrap_or(0.0) + b.unwrap_or(0.0))) .collect(); puts_total = new.into_series(); } } } // Compute stock values let stock_values = compute_stock_values( stocks, stocks_data, stocks_date_col, stocks_sym_col, stocks_price_col, )?; // Assemble balance DataFrame let cash_series = Series::new("cash".into(), vec![cash; dates.len()]); let options_qty: f64 = legs.iter().flat_map(|l| &l.qtys).sum(); let options_qty_series = Series::new("options_qty".into(), vec![options_qty; dates.len()]); let stocks_qty: f64 = stocks.qtys.iter().sum(); let stocks_qty_series = Series::new("stocks_qty".into(), vec![stocks_qty; dates.len()]); let mut columns = vec![ dates.clone().into_column(), cash_series.into_column(), options_qty_series.into_column(), calls_total.with_name("calls_capital".into()).into_column(), puts_total.with_name("puts_capital".into()).into_column(), stocks_qty_series.into_column(), ]; // Add stock value columns for col in stock_values.get_columns() { columns.push(col.clone()); } DataFrame::new(columns) } fn compute_stock_values( stocks: &StockInventory, stocks_data: &DataFrame, date_col: &str, sym_col: &str, price_col: &str, ) -> PolarsResult { if stocks.symbols.is_empty() { return Ok(DataFrame::default()); } let mut result_cols: Vec = Vec::new(); for (symbol, qty) in stocks.symbols.iter().zip(stocks.qtys.iter()) { let filtered = stocks_data .clone() .lazy() .filter(col(sym_col).eq(lit(symbol.as_str()))) .select([ col(date_col), (col(price_col) * lit(*qty)).alias(symbol.as_str()), ]) .collect()?; if let Ok(val_col) = filtered.column(symbol.as_str()) { result_cols.push(val_col.clone()); } } if result_cols.is_empty() { return Ok(DataFrame::default()); } DataFrame::new(result_cols) } #[cfg(test)] mod tests { use super::*; #[test] fn compute_balance_empty_legs() { let opts = df!( "optionroot" => &["A"], "quotedate" => &["2024-01-01"], "ask" => &[1.0], "bid" => &[0.9], ) .unwrap(); let stocks_df = df!( "date" => &["2024-01-01"], "symbol" => &["SPY"], "adjClose" => &[450.0], ) .unwrap(); let result = compute_balance( &[], // no legs &StockInventory { symbols: vec!["SPY".into()], qtys: vec![100.0], }, &opts, &stocks_df, "optionroot", "quotedate", "date", "symbol", "adjClose", 100, 1_000_000.0, ); assert!(result.is_ok()); } } ================================================ FILE: rust/ob_core/src/convexity_backtest.rs ================================================ /// Backtest engine: monthly rebalance loop for tail hedge overlay. /// /// Model: 100% invested in equity (SPY). Each month, sell a fixed budget /// worth of equity to buy ~10-delta puts. Put proceeds are reinvested /// into equity at settlement. Budget is fixed at initial_capital * budget_pct /// (not scaled with portfolio growth) to avoid unrealistic compounding. use chrono::{DateTime, Datelike}; use crate::convexity_scoring; struct Position { strike: f64, expiration_ns: i64, entry_ask: f64, contracts: i32, } pub struct MonthRecord { pub date_ns: i64, pub shares: f64, pub stock_price: f64, pub equity_value: f64, pub put_cost: f64, pub put_exit_value: f64, pub put_pnl: f64, pub portfolio_value: f64, pub convexity_ratio: f64, pub strike: f64, pub contracts: i32, } pub struct BacktestResult { pub records: Vec, pub daily_dates_ns: Vec, pub daily_balances: Vec, } fn ns_to_year_month(ns: i64) -> (i32, u32) { let secs = ns / 1_000_000_000; let dt = DateTime::from_timestamp(secs, 0).expect("valid timestamp"); (dt.year(), dt.month()) } /// Extract monthly rebalance dates (first trading day of each month). fn monthly_rebalance_dates(stock_dates_ns: &[i64]) -> Vec { let mut dates = Vec::new(); if stock_dates_ns.is_empty() { return dates; } let mut prev_ym = ns_to_year_month(stock_dates_ns[0]); dates.push(stock_dates_ns[0]); for &d in &stock_dates_ns[1..] { let ym = ns_to_year_month(d); if ym != prev_ym { dates.push(d); prev_ym = ym; } } dates } /// Binary search for first index where arr[i] >= target. fn lower_bound(arr: &[i64], target: i64) -> usize { arr.partition_point(|&x| x < target) } /// Find the index range [start, end) for a specific date in sorted data. fn find_date_range(dates_ns: &[i64], target: i64) -> (usize, usize) { let start = lower_bound(dates_ns, target); if start >= dates_ns.len() || dates_ns[start] != target { return (start, start); } let mut end = start + 1; while end < dates_ns.len() && dates_ns[end] == target { end += 1; } (start, end) } /// Find stock price on or before a given date. fn stock_price_on(stock_dates_ns: &[i64], stock_prices: &[f64], target_ns: i64) -> Option { let idx = lower_bound(stock_dates_ns, target_ns); if idx < stock_dates_ns.len() && stock_dates_ns[idx] == target_ns { Some(stock_prices[idx]) } else if idx > 0 { Some(stock_prices[idx - 1]) } else { None } } /// Close a position: find exit value from options data or use intrinsic. fn close_position( pos: &Position, rebal_date_ns: i64, put_dates_ns: &[i64], put_expirations_ns: &[i64], put_strikes: &[f64], put_bids: &[f64], stock_dates_ns: &[i64], stock_prices: &[f64], current_stock_price: f64, ) -> f64 { if pos.expiration_ns <= rebal_date_ns { let exp_price = stock_price_on(stock_dates_ns, stock_prices, pos.expiration_ns) .unwrap_or(current_stock_price); let intrinsic = (pos.strike - exp_price).max(0.0); intrinsic * 100.0 * pos.contracts as f64 } else { let (start, end) = find_date_range(put_dates_ns, rebal_date_ns); for j in start..end { if (put_strikes[j] - pos.strike).abs() < 0.001 && put_expirations_ns[j] == pos.expiration_ns { return put_bids[j] * 100.0 * pos.contracts as f64; } } let intrinsic = (pos.strike - current_stock_price).max(0.0); intrinsic * 100.0 * pos.contracts as f64 } } #[allow(clippy::too_many_arguments)] pub fn run_backtest( put_dates_ns: &[i64], put_expirations_ns: &[i64], put_strikes: &[f64], put_bids: &[f64], put_asks: &[f64], put_deltas: &[f64], put_underlying: &[f64], put_dtes: &[i32], _put_ivs: &[f64], stock_dates_ns: &[i64], stock_prices: &[f64], initial_capital: f64, budget_pct: f64, target_delta: f64, dte_min: i32, dte_max: i32, tail_drop: f64, ) -> BacktestResult { let rebalance_dates = monthly_rebalance_dates(stock_dates_ns); let mut records = Vec::with_capacity(rebalance_dates.len()); let mut daily_dates: Vec = Vec::with_capacity(stock_dates_ns.len()); let mut daily_balances: Vec = Vec::with_capacity(stock_dates_ns.len()); if rebalance_dates.is_empty() || stock_dates_ns.is_empty() { return BacktestResult { records, daily_dates_ns: daily_dates, daily_balances, }; } let first_price = stock_price_on(stock_dates_ns, stock_prices, rebalance_dates[0]) .unwrap_or(stock_prices[0]); let mut shares = initial_capital / first_price; let mut position: Option = None; let fixed_budget = initial_capital * budget_pct; for (i, &rebal_date) in rebalance_dates.iter().enumerate() { let stock_price = stock_price_on(stock_dates_ns, stock_prices, rebal_date).unwrap_or(first_price); // 1. Close existing position — reinvest proceeds into equity let (put_exit_value, prev_put_cost) = if let Some(ref pos) = position { let cost = pos.entry_ask * 100.0 * pos.contracts as f64; let exit_val = close_position( pos, rebal_date, put_dates_ns, put_expirations_ns, put_strikes, put_bids, stock_dates_ns, stock_prices, stock_price, ); if stock_price > 0.0 { shares += exit_val / stock_price; } (exit_val, cost) } else { (0.0, 0.0) }; let put_pnl = put_exit_value - prev_put_cost; // 2. Fixed budget — sell equity worth fixed_budget to fund puts let budget = fixed_budget; if stock_price > 0.0 { shares -= budget / stock_price; } // 3. Open new position let (opt_start, opt_end) = find_date_range(put_dates_ns, rebal_date); let mut new_cost = 0.0; let mut new_contracts = 0i32; let mut new_strike = 0.0; let mut new_ratio = 0.0; if opt_start < opt_end { let slice_deltas = &put_deltas[opt_start..opt_end]; let slice_dtes = &put_dtes[opt_start..opt_end]; let slice_asks = &put_asks[opt_start..opt_end]; if let Some(rel_idx) = convexity_scoring::find_target_put( slice_deltas, slice_dtes, slice_asks, target_delta, dte_min, dte_max, ) { let idx = opt_start + rel_idx; let ask = put_asks[idx]; let strike = put_strikes[idx]; let underlying = put_underlying[idx]; if ask > 0.0 { let contracts = (budget / (ask * 100.0)) as i32; if contracts > 0 { let cost = ask * 100.0 * contracts as f64; new_cost = cost; new_contracts = contracts; new_strike = strike; let (ratio, _, _) = convexity_scoring::convexity_ratio(strike, underlying, ask, tail_drop); new_ratio = ratio; position = Some(Position { strike, expiration_ns: put_expirations_ns[idx], entry_ask: ask, contracts, }); // Reinvest leftover let leftover = budget - cost; if stock_price > 0.0 { shares += leftover / stock_price; } } else { position = None; if stock_price > 0.0 { shares += budget / stock_price; } } } else { position = None; if stock_price > 0.0 { shares += budget / stock_price; } } } else { position = None; if stock_price > 0.0 { shares += budget / stock_price; } } } else { position = None; if stock_price > 0.0 { shares += budget / stock_price; } } let final_value = shares * stock_price; records.push(MonthRecord { date_ns: rebal_date, shares, stock_price, equity_value: final_value, put_cost: new_cost, put_exit_value, put_pnl, portfolio_value: final_value, convexity_ratio: new_ratio, strike: new_strike, contracts: new_contracts, }); // 4. Record daily balances until next rebalance let stock_idx = lower_bound(stock_dates_ns, rebal_date); let next_rebal = if i + 1 < rebalance_dates.len() { rebalance_dates[i + 1] } else { i64::MAX }; for si in stock_idx..stock_dates_ns.len() { if stock_dates_ns[si] >= next_rebal { break; } daily_dates.push(stock_dates_ns[si]); daily_balances.push(shares * stock_prices[si]); } } BacktestResult { records, daily_dates_ns: daily_dates, daily_balances, } } #[cfg(test)] mod tests { use super::*; fn make_ts(year: i32, month: u32, day: u32) -> i64 { use chrono::NaiveDate; let dt = NaiveDate::from_ymd_opt(year, month, day) .unwrap() .and_hms_opt(0, 0, 0) .unwrap(); dt.and_utc().timestamp_nanos_opt().unwrap() } #[test] fn test_monthly_rebalance_dates() { let dates = vec![ make_ts(2020, 1, 2), make_ts(2020, 1, 3), make_ts(2020, 1, 6), make_ts(2020, 2, 3), make_ts(2020, 2, 4), make_ts(2020, 3, 2), ]; let rebal = monthly_rebalance_dates(&dates); assert_eq!(rebal.len(), 3); assert_eq!(rebal[0], dates[0]); assert_eq!(rebal[1], dates[3]); assert_eq!(rebal[2], dates[5]); } #[test] fn test_stock_price_on_exact() { let dates = vec![100, 200, 300]; let prices = vec![10.0, 20.0, 30.0]; assert_eq!(stock_price_on(&dates, &prices, 200), Some(20.0)); } #[test] fn test_stock_price_on_before() { let dates = vec![100, 200, 300]; let prices = vec![10.0, 20.0, 30.0]; assert_eq!(stock_price_on(&dates, &prices, 250), Some(20.0)); } #[test] fn test_run_backtest_no_options() { let stock_dates = vec![make_ts(2020, 1, 2), make_ts(2020, 2, 3)]; let stock_prices = vec![100.0, 105.0]; let result = run_backtest( &[], &[], &[], &[], &[], &[], &[], &[], &[], &stock_dates, &stock_prices, 100_000.0, 0.005, -0.10, 14, 60, 0.20, ); assert_eq!(result.records.len(), 2); assert!(result.records[0].contracts == 0); } } ================================================ FILE: rust/ob_core/src/convexity_scoring.rs ================================================ /// Convexity ratio scoring: find cheapest tail protection per day. pub struct DailyScore { pub date_ns: i64, pub convexity_ratio: f64, pub strike: f64, pub ask: f64, pub bid: f64, pub delta: f64, pub underlying_price: f64, pub implied_vol: f64, pub dte: i32, pub annual_cost: f64, pub tail_payoff: f64, } /// Find the put closest to target_delta within DTE range. /// Returns the index within the provided slices, or None. pub fn find_target_put( deltas: &[f64], dtes: &[i32], asks: &[f64], target_delta: f64, dte_min: i32, dte_max: i32, ) -> Option { let mut best_idx: Option = None; let mut best_delta_diff = f64::MAX; for i in 0..deltas.len() { if dtes[i] < dte_min || dtes[i] > dte_max { continue; } if asks[i] <= 0.0 || asks[i].is_nan() { continue; } if deltas[i].is_nan() { continue; } let delta_diff = (deltas[i] - target_delta).abs(); if delta_diff < best_delta_diff { best_delta_diff = delta_diff; best_idx = Some(i); } } best_idx } /// Compute convexity ratio for a single put. /// Returns (ratio, tail_payoff, annual_cost). pub fn convexity_ratio(strike: f64, underlying: f64, ask: f64, tail_drop: f64) -> (f64, f64, f64) { let tail_price = underlying * (1.0 - tail_drop); let tail_payoff = (strike - tail_price).max(0.0) * 100.0; let annual_cost = ask * 100.0 * 12.0; let ratio = if annual_cost > 0.0 { tail_payoff / annual_cost } else { 0.0 }; (ratio, tail_payoff, annual_cost) } /// Compute daily convexity scores from sorted puts data. /// Input arrays must be sorted by date. Only put options should be passed. pub fn compute_daily_scores( dates_ns: &[i64], strikes: &[f64], bids: &[f64], asks: &[f64], deltas: &[f64], underlying_prices: &[f64], dtes: &[i32], implied_vols: &[f64], target_delta: f64, dte_min: i32, dte_max: i32, tail_drop: f64, ) -> Vec { let n = dates_ns.len(); let mut results = Vec::new(); if n == 0 { return results; } // Walk through date groups (consecutive rows with same date) let mut start = 0; while start < n { let current_date = dates_ns[start]; let mut end = start + 1; while end < n && dates_ns[end] == current_date { end += 1; } // Find target put in this date's options if let Some(rel_idx) = find_target_put( &deltas[start..end], &dtes[start..end], &asks[start..end], target_delta, dte_min, dte_max, ) { let idx = start + rel_idx; let (ratio, tail_payoff, annual_cost) = convexity_ratio(strikes[idx], underlying_prices[idx], asks[idx], tail_drop); results.push(DailyScore { date_ns: current_date, convexity_ratio: ratio, strike: strikes[idx], ask: asks[idx], bid: bids[idx], delta: deltas[idx], underlying_price: underlying_prices[idx], implied_vol: implied_vols[idx], dte: dtes[idx], annual_cost, tail_payoff, }); } start = end; } results } #[cfg(test)] mod tests { use super::*; #[test] fn test_convexity_ratio_basic() { let (ratio, payoff, cost) = convexity_ratio(360.0, 400.0, 3.0, 0.20); assert!((payoff - 4000.0).abs() < 0.01); assert!((cost - 3600.0).abs() < 0.01); assert!((ratio - 1.111).abs() < 0.01); } #[test] fn test_convexity_ratio_otm_after_crash() { let (ratio, payoff, _) = convexity_ratio(300.0, 400.0, 3.0, 0.20); assert_eq!(payoff, 0.0); assert_eq!(ratio, 0.0); } #[test] fn test_find_target_put() { let deltas = vec![-0.05, -0.10, -0.15, -0.25, -0.50]; let dtes = vec![30, 30, 30, 30, 30]; let asks = vec![1.0, 2.0, 3.0, 5.0, 10.0]; let idx = find_target_put(&deltas, &dtes, &asks, -0.10, 20, 45); assert_eq!(idx, Some(1)); } #[test] fn test_find_target_put_dte_filter() { let deltas = vec![-0.10, -0.10, -0.10]; let dtes = vec![10, 30, 60]; let asks = vec![1.0, 2.0, 3.0]; let idx = find_target_put(&deltas, &dtes, &asks, -0.10, 20, 45); assert_eq!(idx, Some(1)); } #[test] fn test_find_target_put_skips_zero_ask() { let deltas = vec![-0.10, -0.11]; let dtes = vec![30, 30]; let asks = vec![0.0, 2.0]; let idx = find_target_put(&deltas, &dtes, &asks, -0.10, 20, 45); assert_eq!(idx, Some(1)); } #[test] fn test_compute_daily_scores() { let dates_ns = vec![100, 100, 100, 200, 200, 200]; let strikes = vec![360.0, 370.0, 380.0, 360.0, 370.0, 380.0]; let bids = vec![2.5, 3.5, 5.0, 2.0, 3.0, 4.5]; let asks = vec![3.0, 4.0, 5.5, 2.5, 3.5, 5.0]; let deltas = vec![-0.08, -0.12, -0.18, -0.09, -0.11, -0.17]; let underlying = vec![400.0; 6]; let dtes = vec![30, 30, 30, 30, 30, 30]; let ivs = vec![0.20, 0.22, 0.25, 0.19, 0.21, 0.24]; let scores = compute_daily_scores( &dates_ns, &strikes, &bids, &asks, &deltas, &underlying, &dtes, &ivs, -0.10, 20, 45, 0.20, ); assert_eq!(scores.len(), 2); assert_eq!(scores[0].date_ns, 100); assert_eq!(scores[1].date_ns, 200); } } ================================================ FILE: rust/ob_core/src/cost_model.rs ================================================ //! Transaction cost models for options and stocks. //! //! Mirrors Python's `options_portfolio_backtester.execution.cost_model`. #[derive(Debug, Clone, Default)] pub enum CostModel { /// Zero transaction costs. #[default] NoCosts, /// Fixed per-contract commission (e.g., $0.65/contract for IBKR). PerContract { rate: f64, stock_rate: f64 }, /// Tiered commission schedule with volume discounts. /// Tiers are (max_contracts, rate) pairs sorted by max_contracts ascending. Tiered { tiers: Vec<(i64, f64)>, stock_rate: f64 }, } impl CostModel { /// Compute option trade commission. #[inline] pub fn option_cost(&self, _price: f64, quantity: f64, _spc: i64) -> f64 { let qty = quantity.abs(); match self { CostModel::NoCosts => 0.0, CostModel::PerContract { rate, .. } => rate * qty, CostModel::Tiered { tiers, .. } => { let mut total = 0.0; let mut remaining = qty; let mut prev_bound: i64 = 0; for &(max_qty, rate) in tiers { let tier_qty = remaining.min((max_qty - prev_bound) as f64); if tier_qty <= 0.0 { prev_bound = max_qty; continue; } total += tier_qty * rate; remaining -= tier_qty; prev_bound = max_qty; if remaining <= 0.0 { break; } } if remaining > 0.0 { if let Some(&(_, last_rate)) = tiers.last() { total += remaining * last_rate; } } total } } } /// Compute stock trade commission. #[inline] pub fn stock_cost(&self, _price: f64, quantity: f64) -> f64 { let qty = quantity.abs(); match self { CostModel::NoCosts => 0.0, CostModel::PerContract { stock_rate, .. } => stock_rate * qty, CostModel::Tiered { stock_rate, .. } => stock_rate * qty, } } } #[cfg(test)] mod tests { use super::*; #[test] fn no_costs() { let m = CostModel::NoCosts; assert_eq!(m.option_cost(10.0, 5.0, 100), 0.0); assert_eq!(m.stock_cost(150.0, 100.0), 0.0); } #[test] fn per_contract() { let m = CostModel::PerContract { rate: 0.65, stock_rate: 0.005 }; assert!((m.option_cost(10.0, 10.0, 100) - 6.5).abs() < 1e-10); assert!((m.option_cost(10.0, -10.0, 100) - 6.5).abs() < 1e-10); assert!((m.stock_cost(150.0, 100.0) - 0.5).abs() < 1e-10); } #[test] fn tiered_within_first_tier() { let m = CostModel::Tiered { tiers: vec![(10_000, 0.65), (50_000, 0.50), (100_000, 0.25)], stock_rate: 0.005, }; // 100 contracts, all in first tier assert!((m.option_cost(10.0, 100.0, 100) - 65.0).abs() < 1e-10); } #[test] fn tiered_spanning_tiers() { let m = CostModel::Tiered { tiers: vec![(10_000, 0.65), (50_000, 0.50), (100_000, 0.25)], stock_rate: 0.005, }; // 15000 contracts: 10000 * 0.65 + 5000 * 0.50 let expected = 10_000.0 * 0.65 + 5_000.0 * 0.50; assert!((m.option_cost(10.0, 15_000.0, 100) - expected).abs() < 1e-10); } #[test] fn tiered_beyond_all() { let m = CostModel::Tiered { tiers: vec![(10_000, 0.65), (50_000, 0.50), (100_000, 0.25)], stock_rate: 0.005, }; // 120_000: 10k*0.65 + 40k*0.50 + 50k*0.25 + 20k*0.25 let expected = 10_000.0 * 0.65 + 40_000.0 * 0.50 + 50_000.0 * 0.25 + 20_000.0 * 0.25; assert!((m.option_cost(10.0, 120_000.0, 100) - expected).abs() < 1e-10); } #[test] fn tiered_stock_cost() { let m = CostModel::Tiered { tiers: vec![(10_000, 0.65)], stock_rate: 0.005, }; assert!((m.stock_cost(150.0, 100.0) - 0.5).abs() < 1e-10); } } ================================================ FILE: rust/ob_core/src/entries.rs ================================================ //! Entry signal computation in Rust. //! //! Mirrors Python's _execute_option_entries: //! 1. Anti-join to exclude held contracts //! 2. Apply entry filter //! 3. Sort by entry_sort //! 4. Select signal fields //! 5. Compute totals (cost, qty) use polars::prelude::*; use crate::filter::CompiledFilter; /// Compute entry candidates for a single leg. /// /// Steps: /// 1. Anti-join options with inventory contracts /// 2. Apply compiled entry filter /// 3. Sort if entry_sort specified /// 4. Select and rename signal fields pub fn compute_leg_entries( options: &DataFrame, inventory_contracts: &[String], entry_filter: &CompiledFilter, contract_col: &str, cost_field: &str, entry_sort_col: Option<&str>, entry_sort_asc: bool, shares_per_contract: i64, is_sell: bool, extra_columns: &[String], ) -> PolarsResult { // Anti-join: exclude already-held contracts let inv_contracts = Series::new("_held".into(), inventory_contracts); let inv_df = DataFrame::new(vec![inv_contracts.into_column()])?; let mut lazy = options .clone() .lazy() .join( inv_df.lazy(), [col(contract_col)], [col("_held")], JoinArgs::new(JoinType::Anti), ); // Apply entry filter lazy = lazy.filter(entry_filter.polars_expr.clone()); // Sort if specified if let Some(sort_col) = entry_sort_col { lazy = lazy.sort( [sort_col], SortMultipleOptions::default().with_order_descending(!entry_sort_asc), ); } // Select signal fields and compute cost let sign = if is_sell { lit(-1.0) } else { lit(1.0) }; let spc = lit(shares_per_contract as f64); let mut select_exprs = vec![ col(contract_col).alias("contract"), col("underlying"), col("expiration"), col("type"), col("strike").cast(DataType::Float64), (sign * col(cost_field) * spc).alias("cost"), ]; // Include extra columns needed by signal selectors (e.g. delta, openinterest) for extra in extra_columns { select_exprs.push(col(extra)); } lazy = lazy.select(select_exprs); lazy.collect() } /// Compute entry quantities given total costs and available allocation. pub fn compute_entry_qty(total_costs: &Series, allocation: f64) -> PolarsResult { let abs_costs = total_costs.f64()?.apply(|v| v.map(|x| x.abs())); let qty: Float64Chunked = abs_costs .into_iter() .map(|c| c.map(|cost| if cost > 0.0 { (allocation / cost).floor() } else { 0.0 })) .collect(); Ok(qty.into_series()) } #[cfg(test)] mod tests { use super::*; fn sample_options() -> DataFrame { df!( "optionroot" => &["A", "B", "C", "D"], "underlying" => &["SPX", "SPX", "SPX", "SPX"], "type" => &["put", "put", "call", "put"], "expiration" => &["2024-06-01", "2024-06-01", "2024-06-01", "2024-06-01"], "strike" => &[4000.0, 4100.0, 4200.0, 4300.0], "ask" => &[10.0, 15.0, 20.0, 25.0], "bid" => &[9.0, 14.0, 19.0, 24.0], "dte" => &[90i64, 90, 90, 90], ) .unwrap() } #[test] fn compute_entries_excludes_held() { let opts = sample_options(); let filter = CompiledFilter::new("type == 'put'").unwrap(); let result = compute_leg_entries( &opts, &["A".into()], // A is held &filter, "optionroot", "ask", None, true, 100, false, &[], ) .unwrap(); // A is excluded, C is a call (filtered out), so B and D remain assert_eq!(result.height(), 2); } #[test] fn compute_qty() { let costs = Series::new("cost".into(), &[100.0, 200.0, 50.0]); let qty = compute_entry_qty(&costs, 1000.0).unwrap(); let vals: Vec = qty.f64().unwrap().into_no_null_iter().collect(); assert_eq!(vals, vec![10.0, 5.0, 20.0]); } } ================================================ FILE: rust/ob_core/src/exits.rs ================================================ //! Exit mask computation in Rust. //! //! Mirrors Python's _execute_option_exits: //! 1. Compute current option quotes for each leg //! 2. Apply exit filters to get filter masks //! 3. Apply threshold exits (profit/loss targets) //! 4. Combine masks with OR use polars::prelude::*; /// Compute exit mask from profit/loss thresholds. /// /// exit if: current_cost <= entry_cost * (1 - loss_pct) [loss] /// or: current_cost >= entry_cost * (1 + profit_pct) [profit] pub fn threshold_exit_mask( entry_costs: &Series, current_costs: &Series, profit_pct: Option, loss_pct: Option, ) -> PolarsResult { let entry = entry_costs.f64()?; let current = current_costs.f64()?; let mask: BooleanChunked = entry .into_iter() .zip(current.into_iter()) .map(|(e, c)| { match (e, c) { (Some(entry_val), Some(curr_val)) => { let mut should_exit = false; if let Some(p) = profit_pct { if entry_val != 0.0 { let pnl_pct = (curr_val - entry_val) / entry_val.abs(); if pnl_pct >= p { should_exit = true; } } } if let Some(l) = loss_pct { if entry_val != 0.0 { let pnl_pct = (curr_val - entry_val) / entry_val.abs(); if pnl_pct <= -l { should_exit = true; } } } Some(should_exit) } _ => Some(false), } }) .collect(); Ok(mask) } /// Combine multiple boolean masks with OR. pub fn combine_masks_or(masks: &[BooleanChunked]) -> BooleanChunked { if masks.is_empty() { return BooleanChunked::new("mask".into(), &[] as &[bool]); } let mut result = masks[0].clone(); for mask in &masks[1..] { result = result | mask.clone(); } result } #[cfg(test)] mod tests { use super::*; #[test] fn threshold_profit_exit() { let entry = Series::new("entry".into(), &[100.0, 100.0, 100.0]); let current = Series::new("current".into(), &[160.0, 110.0, 80.0]); let mask = threshold_exit_mask(&entry, ¤t, Some(0.50), None).unwrap(); let vals: Vec = mask.into_no_null_iter().collect(); assert_eq!(vals, vec![true, false, false]); // 60% profit > 50% } #[test] fn threshold_loss_exit() { let entry = Series::new("entry".into(), &[100.0, 100.0, 100.0]); let current = Series::new("current".into(), &[160.0, 110.0, 70.0]); let mask = threshold_exit_mask(&entry, ¤t, None, Some(0.20)).unwrap(); let vals: Vec = mask.into_no_null_iter().collect(); assert_eq!(vals, vec![false, false, true]); // 30% loss > 20% } #[test] fn combine_masks() { let a = BooleanChunked::new("a".into(), &[true, false, false]); let b = BooleanChunked::new("b".into(), &[false, false, true]); let result = combine_masks_or(&[a, b]); let vals: Vec = result.into_no_null_iter().collect(); assert_eq!(vals, vec![true, false, true]); } } ================================================ FILE: rust/ob_core/src/fill_model.rs ================================================ //! Fill models — determine the execution price for trades. //! //! Mirrors Python's `options_portfolio_backtester.execution.fill_model`. #[derive(Debug, Clone, Default)] pub enum FillModel { /// Fill at bid (sell) or ask (buy) — matches original behavior. #[default] MarketAtBidAsk, /// Fill at the midpoint of bid and ask. MidPrice, /// Fill price adjusts for volume impact. Low volume pushes toward mid. VolumeAware { full_volume_threshold: i64 }, } impl FillModel { /// Compute fill price given bid, ask, volume, and whether this is a buy. /// /// `is_buy`: true for BUY direction (fills at ask), false for SELL (fills at bid). #[inline] pub fn fill_price(&self, bid: f64, ask: f64, volume: Option, is_buy: bool) -> f64 { match self { FillModel::MarketAtBidAsk => { if is_buy { ask } else { bid } } FillModel::MidPrice => { (bid + ask) / 2.0 } FillModel::VolumeAware { full_volume_threshold } => { let mid = (bid + ask) / 2.0; let target = if is_buy { ask } else { bid }; let vol = volume.unwrap_or(*full_volume_threshold as f64); if vol >= *full_volume_threshold as f64 { return target; } let ratio = vol / *full_volume_threshold as f64; mid + ratio * (target - mid) } } } } #[cfg(test)] mod tests { use super::*; #[test] fn market_at_bid_ask_buy() { let m = FillModel::MarketAtBidAsk; assert!((m.fill_price(9.0, 10.0, None, true) - 10.0).abs() < 1e-10); } #[test] fn market_at_bid_ask_sell() { let m = FillModel::MarketAtBidAsk; assert!((m.fill_price(9.0, 10.0, None, false) - 9.0).abs() < 1e-10); } #[test] fn mid_price() { let m = FillModel::MidPrice; assert!((m.fill_price(9.0, 11.0, None, true) - 10.0).abs() < 1e-10); assert!((m.fill_price(9.0, 11.0, None, false) - 10.0).abs() < 1e-10); } #[test] fn volume_aware_full_volume() { let m = FillModel::VolumeAware { full_volume_threshold: 100 }; // At or above threshold, same as market assert!((m.fill_price(9.0, 10.0, Some(100.0), true) - 10.0).abs() < 1e-10); assert!((m.fill_price(9.0, 10.0, Some(200.0), false) - 9.0).abs() < 1e-10); } #[test] fn volume_aware_zero_volume() { let m = FillModel::VolumeAware { full_volume_threshold: 100 }; // At volume=0, fill at mid let mid = (9.0 + 10.0) / 2.0; assert!((m.fill_price(9.0, 10.0, Some(0.0), true) - mid).abs() < 1e-10); assert!((m.fill_price(9.0, 10.0, Some(0.0), false) - mid).abs() < 1e-10); } #[test] fn volume_aware_half_volume() { let m = FillModel::VolumeAware { full_volume_threshold: 100 }; let mid = (9.0 + 10.0) / 2.0; // At 50% volume: mid + 0.5 * (ask - mid) = 9.5 + 0.25 = 9.75 let expected_buy = mid + 0.5 * (10.0 - mid); assert!((m.fill_price(9.0, 10.0, Some(50.0), true) - expected_buy).abs() < 1e-10); let expected_sell = mid + 0.5 * (9.0 - mid); assert!((m.fill_price(9.0, 10.0, Some(50.0), false) - expected_sell).abs() < 1e-10); } #[test] fn volume_aware_no_volume_data() { let m = FillModel::VolumeAware { full_volume_threshold: 100 }; // Missing volume defaults to threshold -> market price assert!((m.fill_price(9.0, 10.0, None, true) - 10.0).abs() < 1e-10); } } ================================================ FILE: rust/ob_core/src/filter.rs ================================================ //! Filter expression parser and evaluator. //! //! Parses the pandas-eval query strings generated by the Python Filter DSL //! into an AST, then evaluates against Polars DataFrames. //! //! Supported patterns (all generated by schema.py): //! "(type == 'put') & (ask > 0)" //! "(underlying == 'SPX') & (dte >= 60) & (dte <= 120)" //! "(strike >= underlying_last * 1.02)" //! "dte <= 30" use polars::prelude::*; use thiserror::Error; #[derive(Error, Debug)] pub enum FilterError { #[error("parse error: {0}")] Parse(String), #[error("polars error: {0}")] Polars(#[from] PolarsError), } /// A parsed value literal. #[derive(Debug, Clone, PartialEq)] pub enum Value { Int(i64), Float(f64), Str(String), Column(String), } /// Comparison operator. #[derive(Debug, Clone, Copy, PartialEq)] pub enum CmpOp { Eq, Ne, Lt, Le, Gt, Ge, } /// Arithmetic operator for column expressions. #[derive(Debug, Clone, Copy, PartialEq)] pub enum ArithOp { Add, Sub, Mul, Div, } /// Compiled filter expression AST. #[derive(Debug, Clone, PartialEq)] pub enum FilterExpr { Cmp(String, CmpOp, Value), /// column value value /// e.g. strike >= underlying_last * 1.02 ColArith(String, ArithOp, Value, CmpOp, Value), And(Box, Box), Or(Box, Box), Not(Box), } /// Tokenizer for filter expressions. #[derive(Debug, Clone, PartialEq)] enum Token { Ident(String), StrLit(String), IntLit(i64), FloatLit(f64), Eq, // == Ne, // != Lt, // < Le, // <= Gt, // > Ge, // >= And, // & Or, // | Not, // ! LParen, RParen, Plus, Minus, Star, Slash, } fn tokenize(input: &str) -> Result, FilterError> { let mut tokens = Vec::new(); let chars: Vec = input.chars().collect(); let mut i = 0; while i < chars.len() { match chars[i] { ' ' | '\t' | '\n' => i += 1, '(' => { tokens.push(Token::LParen); i += 1; } ')' => { tokens.push(Token::RParen); i += 1; } '&' => { tokens.push(Token::And); i += 1; } '|' => { tokens.push(Token::Or); i += 1; } '+' => { tokens.push(Token::Plus); i += 1; } '-' => { tokens.push(Token::Minus); i += 1; } '*' => { tokens.push(Token::Star); i += 1; } '/' => { tokens.push(Token::Slash); i += 1; } '!' => { if i + 1 < chars.len() && chars[i + 1] == '=' { tokens.push(Token::Ne); i += 2; } else { tokens.push(Token::Not); i += 1; } } '=' => { if i + 1 < chars.len() && chars[i + 1] == '=' { tokens.push(Token::Eq); i += 2; } else { return Err(FilterError::Parse(format!("unexpected '=' at {i}"))); } } '<' => { if i + 1 < chars.len() && chars[i + 1] == '=' { tokens.push(Token::Le); i += 2; } else { tokens.push(Token::Lt); i += 1; } } '>' => { if i + 1 < chars.len() && chars[i + 1] == '=' { tokens.push(Token::Ge); i += 2; } else { tokens.push(Token::Gt); i += 1; } } '\'' | '"' => { let quote = chars[i]; i += 1; let start = i; while i < chars.len() && chars[i] != quote { i += 1; } let s: String = chars[start..i].iter().collect(); tokens.push(Token::StrLit(s)); i += 1; // skip closing quote } c if c.is_ascii_digit() || c == '.' => { let start = i; let mut has_dot = c == '.'; let mut has_exp = false; i += 1; while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') { if chars[i] == '.' { has_dot = true; } i += 1; } // Scientific notation: e/E followed by optional +/- and digits if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') { has_exp = true; i += 1; if i < chars.len() && (chars[i] == '+' || chars[i] == '-') { i += 1; } while i < chars.len() && chars[i].is_ascii_digit() { i += 1; } } let num_str: String = chars[start..i].iter().collect(); if has_dot || has_exp { tokens.push(Token::FloatLit( num_str.parse().map_err(|e| FilterError::Parse(format!("{e}")))?, )); } else { tokens.push(Token::IntLit( num_str.parse().map_err(|e| FilterError::Parse(format!("{e}")))?, )); } } c if c.is_ascii_alphabetic() || c == '_' => { let start = i; i += 1; while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') { i += 1; } let ident: String = chars[start..i].iter().collect(); tokens.push(Token::Ident(ident)); } c => return Err(FilterError::Parse(format!("unexpected char '{c}' at {i}"))), } } Ok(tokens) } /// Recursive descent parser. struct Parser { tokens: Vec, pos: usize, } impl Parser { fn new(tokens: Vec) -> Self { Self { tokens, pos: 0 } } fn peek(&self) -> Option<&Token> { self.tokens.get(self.pos) } fn advance(&mut self) -> Option { let tok = self.tokens.get(self.pos)?.clone(); self.pos += 1; Some(tok) } fn expect(&mut self, expected: &Token) -> Result<(), FilterError> { let tok = self.advance().ok_or_else(|| FilterError::Parse("unexpected end".into()))?; if &tok != expected { return Err(FilterError::Parse(format!("expected {expected:?}, got {tok:?}"))); } Ok(()) } /// expr = or_expr fn parse_expr(&mut self) -> Result { self.parse_or() } /// or_expr = and_expr ( '|' and_expr )* fn parse_or(&mut self) -> Result { let mut left = self.parse_and()?; while matches!(self.peek(), Some(Token::Or)) { self.advance(); let right = self.parse_and()?; left = FilterExpr::Or(Box::new(left), Box::new(right)); } Ok(left) } /// and_expr = unary ( '&' unary )* fn parse_and(&mut self) -> Result { let mut left = self.parse_unary()?; while matches!(self.peek(), Some(Token::And)) { self.advance(); let right = self.parse_unary()?; left = FilterExpr::And(Box::new(left), Box::new(right)); } Ok(left) } /// unary = '!' unary | primary fn parse_unary(&mut self) -> Result { if matches!(self.peek(), Some(Token::Not)) { self.advance(); let inner = self.parse_unary()?; return Ok(FilterExpr::Not(Box::new(inner))); } self.parse_primary() } /// primary = '(' expr ')' | comparison fn parse_primary(&mut self) -> Result { if matches!(self.peek(), Some(Token::LParen)) { self.advance(); let expr = self.parse_expr()?; self.expect(&Token::RParen)?; return Ok(expr); } self.parse_comparison() } /// comparison = value cmp_op value /// value can be: ident, ident arith_op literal, literal fn parse_comparison(&mut self) -> Result { let left = self.parse_value_expr()?; let cmp = self.parse_cmp_op()?; let right = self.parse_value_expr()?; match (left, right) { // column cmp literal/column (ValueExpr::Column(name), ValueExpr::Literal(val)) => { Ok(FilterExpr::Cmp(name, cmp, val)) } (ValueExpr::Column(name), ValueExpr::Column(rhs)) => { Ok(FilterExpr::Cmp(name, cmp, Value::Column(rhs))) } // column_arith cmp value (ValueExpr::Arith(name, op, operand), rhs) => { Ok(FilterExpr::ColArith(name, op, operand, cmp, self.value_expr_to_value(rhs)?)) } // column cmp column_arith → flip to ColArith form // e.g. strike >= underlying_last * 1.02 // → ColArith("underlying_last", Mul, 1.02, Le, Column("strike")) (ValueExpr::Column(name), ValueExpr::Arith(rhs_col, op, operand)) => { Ok(FilterExpr::ColArith(rhs_col, op, operand, flip_cmp(cmp), Value::Column(name))) } // literal cmp column → flip (ValueExpr::Literal(val), ValueExpr::Column(name)) => { Ok(FilterExpr::Cmp(name, flip_cmp(cmp), val)) } _ => Err(FilterError::Parse("unsupported comparison form".into())), } } fn parse_value_expr(&mut self) -> Result { let tok = self.advance().ok_or_else(|| FilterError::Parse("unexpected end".into()))?; match tok { Token::Ident(name) => { // Check for arithmetic: ident * 1.02 if let Some(arith) = self.try_parse_arith() { return Ok(ValueExpr::Arith(name, arith.0, arith.1)); } Ok(ValueExpr::Column(name)) } Token::IntLit(n) => { // Check for arithmetic: 1.02 * ident (reversed) Ok(ValueExpr::Literal(Value::Int(n))) } Token::FloatLit(f) => Ok(ValueExpr::Literal(Value::Float(f))), Token::StrLit(s) => Ok(ValueExpr::Literal(Value::Str(s))), // Unary minus: negate the next numeric literal Token::Minus => { let next = self.advance().ok_or_else(|| FilterError::Parse("unexpected end after '-'".into()))?; match next { Token::IntLit(n) => Ok(ValueExpr::Literal(Value::Int(-n))), Token::FloatLit(f) => Ok(ValueExpr::Literal(Value::Float(-f))), t => Err(FilterError::Parse(format!("expected number after '-', got {t:?}"))), } } t => Err(FilterError::Parse(format!("unexpected token in value: {t:?}"))), } } fn try_parse_arith(&mut self) -> Option<(ArithOp, Value)> { let op = match self.peek()? { Token::Plus => ArithOp::Add, Token::Minus => ArithOp::Sub, Token::Star => ArithOp::Mul, Token::Slash => ArithOp::Div, _ => return None, }; self.advance(); let val = match self.advance()? { Token::IntLit(n) => Value::Int(n), Token::FloatLit(f) => Value::Float(f), Token::Ident(s) => Value::Column(s), _ => return None, }; Some((op, val)) } fn parse_cmp_op(&mut self) -> Result { match self.advance() { Some(Token::Eq) => Ok(CmpOp::Eq), Some(Token::Ne) => Ok(CmpOp::Ne), Some(Token::Lt) => Ok(CmpOp::Lt), Some(Token::Le) => Ok(CmpOp::Le), Some(Token::Gt) => Ok(CmpOp::Gt), Some(Token::Ge) => Ok(CmpOp::Ge), t => Err(FilterError::Parse(format!("expected comparison op, got {t:?}"))), } } fn value_expr_to_value(&self, ve: ValueExpr) -> Result { match ve { ValueExpr::Column(name) => Ok(Value::Column(name)), ValueExpr::Literal(val) => Ok(val), ValueExpr::Arith(..) => Err(FilterError::Parse( "arithmetic expressions only supported on left side".into(), )), } } } #[derive(Debug)] enum ValueExpr { Column(String), Literal(Value), Arith(String, ArithOp, Value), } fn flip_cmp(op: CmpOp) -> CmpOp { match op { CmpOp::Lt => CmpOp::Gt, CmpOp::Le => CmpOp::Ge, CmpOp::Gt => CmpOp::Lt, CmpOp::Ge => CmpOp::Le, other => other, } } /// Parse a query string into a FilterExpr AST. pub fn parse(query: &str) -> Result { let tokens = tokenize(query)?; let mut parser = Parser::new(tokens); let expr = parser.parse_expr()?; if parser.pos != parser.tokens.len() { return Err(FilterError::Parse(format!( "unexpected tokens after position {}", parser.pos ))); } Ok(expr) } /// Convert a FilterExpr to a Polars Expr for lazy evaluation. pub fn to_polars_expr(filter: &FilterExpr) -> Expr { match filter { FilterExpr::Cmp(column, op, value) => { let c = col(column.as_str()); let v = value_to_lit(value); apply_cmp(c, *op, v) } FilterExpr::ColArith(column, arith_op, arith_val, cmp_op, cmp_val) => { let c = col(column.as_str()); let av = value_to_lit(arith_val); let arith_expr = match arith_op { ArithOp::Add => c + av, ArithOp::Sub => c - av, ArithOp::Mul => c * av, ArithOp::Div => c / av, }; let cv = value_to_lit(cmp_val); apply_cmp(arith_expr, *cmp_op, cv) } FilterExpr::And(left, right) => { to_polars_expr(left).and(to_polars_expr(right)) } FilterExpr::Or(left, right) => { to_polars_expr(left).or(to_polars_expr(right)) } FilterExpr::Not(inner) => { to_polars_expr(inner).not() } } } fn value_to_lit(value: &Value) -> Expr { match value { // Always use f64 for numeric literals to avoid Int128 issues in polars 0.48 Value::Int(n) => lit(*n as f64), Value::Float(f) => lit(*f), Value::Str(s) => lit(s.as_str()), Value::Column(name) => col(name.as_str()), } } fn apply_cmp(left: Expr, op: CmpOp, right: Expr) -> Expr { match op { CmpOp::Eq => left.eq(right), CmpOp::Ne => left.neq(right), CmpOp::Lt => left.lt(right), CmpOp::Le => left.lt_eq(right), CmpOp::Gt => left.gt(right), CmpOp::Ge => left.gt_eq(right), } } /// A compiled filter: parsed once, evaluated many times. pub struct CompiledFilter { pub expr: FilterExpr, pub polars_expr: Expr, } impl CompiledFilter { pub fn new(query: &str) -> Result { let expr = parse(query)?; let polars_expr = to_polars_expr(&expr); Ok(Self { expr, polars_expr }) } pub fn apply(&self, df: &DataFrame) -> PolarsResult { let mask = df .clone() .lazy() .select([self.polars_expr.clone().alias("_mask")]) .collect()?; let bool_mask = mask.column("_mask")?.bool()?.clone(); df.filter(&bool_mask) } /// Evaluate filter against a single row — O(1) per comparison, no Polars overhead. #[inline] pub fn eval_row(&self, df: &DataFrame, row_idx: usize) -> bool { eval_expr_row(&self.expr, df, row_idx) } } /// Read a numeric value from a column at a given row index. #[inline] fn read_f64(col: &Column, row: usize) -> Option { if let Ok(ca) = col.f64() { return ca.get(row); } if let Ok(ca) = col.i64() { return ca.get(row).map(|v| v as f64); } if let Ok(ca) = col.i32() { return ca.get(row).map(|v| v as f64); } None } /// Read a string value from a column at a given row index. #[inline] fn read_str<'a>(col: &'a Column, row: usize) -> Option<&'a str> { col.str().ok().and_then(|ca| ca.get(row)) } /// Compare two f64 values with the given operator. #[inline] fn cmp_f64(lhs: f64, op: CmpOp, rhs: f64) -> bool { match op { CmpOp::Eq => (lhs - rhs).abs() < f64::EPSILON, CmpOp::Ne => (lhs - rhs).abs() >= f64::EPSILON, CmpOp::Lt => lhs < rhs, CmpOp::Le => lhs <= rhs, CmpOp::Gt => lhs > rhs, CmpOp::Ge => lhs >= rhs, } } /// Resolve a Value to f64 given a DataFrame and row index. #[inline] fn resolve_f64(val: &Value, df: &DataFrame, row: usize) -> Option { match val { Value::Int(n) => Some(*n as f64), Value::Float(f) => Some(*f), Value::Column(name) => df.column(name).ok().and_then(|c| read_f64(c, row)), Value::Str(_) => None, } } /// Evaluate a filter expression against a single row directly. fn eval_expr_row(expr: &FilterExpr, df: &DataFrame, row: usize) -> bool { match expr { FilterExpr::Cmp(col_name, op, val) => { let col = match df.column(col_name) { Ok(c) => c, Err(_) => return false, }; match val { Value::Str(s) => { match read_str(col, row) { Some(cell) => match op { CmpOp::Eq => cell == s.as_str(), CmpOp::Ne => cell != s.as_str(), _ => false, }, None => false, } } _ => { match (read_f64(col, row), resolve_f64(val, df, row)) { (Some(lhs), Some(rhs)) => cmp_f64(lhs, *op, rhs), _ => false, } } } } FilterExpr::ColArith(col_name, arith_op, arith_val, cmp_op, cmp_val) => { let col = match df.column(col_name) { Ok(c) => c, Err(_) => return false, }; let base = match read_f64(col, row) { Some(v) => v, None => return false, }; let operand = match resolve_f64(arith_val, df, row) { Some(v) => v, None => return false, }; let arith_result = match arith_op { ArithOp::Add => base + operand, ArithOp::Sub => base - operand, ArithOp::Mul => base * operand, ArithOp::Div => base / operand, }; let rhs = match resolve_f64(cmp_val, df, row) { Some(v) => v, None => return false, }; cmp_f64(arith_result, *cmp_op, rhs) } FilterExpr::And(l, r) => eval_expr_row(l, df, row) && eval_expr_row(r, df, row), FilterExpr::Or(l, r) => eval_expr_row(l, df, row) || eval_expr_row(r, df, row), FilterExpr::Not(inner) => !eval_expr_row(inner, df, row), } } #[cfg(test)] mod tests { use super::*; #[test] fn parse_simple_eq() { let expr = parse("type == 'put'").unwrap(); assert_eq!( expr, FilterExpr::Cmp("type".into(), CmpOp::Eq, Value::Str("put".into())) ); } #[test] fn parse_simple_gte() { let expr = parse("dte >= 60").unwrap(); assert_eq!( expr, FilterExpr::Cmp("dte".into(), CmpOp::Ge, Value::Int(60)) ); } #[test] fn parse_and() { let expr = parse("(type == 'put') & (ask > 0)").unwrap(); match expr { FilterExpr::And(left, right) => { assert_eq!( *left, FilterExpr::Cmp("type".into(), CmpOp::Eq, Value::Str("put".into())) ); assert_eq!( *right, FilterExpr::Cmp("ask".into(), CmpOp::Gt, Value::Int(0)) ); } _ => panic!("expected And"), } } #[test] fn parse_col_arith() { // "strike >= underlying_last * 1.02" flips to: // ColArith("underlying_last", Mul, 1.02, Le, Column("strike")) let expr = parse("strike >= underlying_last * 1.02").unwrap(); match expr { FilterExpr::ColArith(ref col, ArithOp::Mul, Value::Float(f), CmpOp::Le, Value::Column(ref rhs)) => { assert_eq!(col, "underlying_last"); assert!((f - 1.02).abs() < 1e-10); assert_eq!(rhs, "strike"); } _ => panic!("expected ColArith, got {expr:?}"), } } #[test] fn parse_chained_and() { let expr = parse("(underlying == 'SPX') & (dte >= 60) & (dte <= 120)").unwrap(); // Should be And(And(eq, gte), lte) match expr { FilterExpr::And(_, _) => {} // OK _ => panic!("expected chained And"), } } #[test] fn compiled_filter_apply() { let df = DataFrame::new(vec![ Column::new("type".into(), &["call", "put", "put"]), Column::new("ask".into(), &[1.0f64, 2.0, 0.0]), ]) .unwrap(); let f = CompiledFilter::new("(type == 'put') & (ask > 0)").unwrap(); let result = f.apply(&df).unwrap(); assert_eq!(result.height(), 1); } #[test] fn compiled_filter_dte_range() { let df = DataFrame::new(vec![ Column::new("underlying".into(), &["SPX", "SPX", "AAPL", "SPX"]), Column::new("dte".into(), &[30i32, 90, 90, 150]), ]) .unwrap(); let f = CompiledFilter::new("(underlying == 'SPX') & (dte >= 60) & (dte <= 120)").unwrap(); let result = f.apply(&df).unwrap(); assert_eq!(result.height(), 1); // Only SPX with dte=90 } #[test] fn parse_scientific_notation() { let expr = parse("ask > 1e-5").unwrap(); match expr { FilterExpr::Cmp(col, CmpOp::Gt, Value::Float(f)) => { assert_eq!(col, "ask"); assert!((f - 1e-5).abs() < 1e-15); } _ => panic!("expected Cmp with float, got {expr:?}"), } } #[test] fn parse_scientific_notation_positive_exp() { let expr = parse("strike >= 1.5E3").unwrap(); match expr { FilterExpr::Cmp(col, CmpOp::Ge, Value::Float(f)) => { assert_eq!(col, "strike"); assert!((f - 1500.0).abs() < 1e-10); } _ => panic!("expected Cmp with float, got {expr:?}"), } } #[test] fn parse_scientific_notation_no_sign() { let expr = parse("delta >= 1e2").unwrap(); match expr { FilterExpr::Cmp(_, CmpOp::Ge, Value::Float(f)) => { assert!((f - 100.0).abs() < 1e-10); } _ => panic!("expected float, got {expr:?}"), } } #[test] fn compiled_filter_scientific_notation() { let df = DataFrame::new(vec![ Column::new("ask".into(), &[0.0f64, 0.00001, 0.1, 5.0]), ]).unwrap(); let f = CompiledFilter::new("ask > 1e-3").unwrap(); let result = f.apply(&df).unwrap(); assert_eq!(result.height(), 2); // 0.1 and 5.0 } #[test] fn parse_negative_float_literal() { let expr = parse("delta >= -0.25").unwrap(); assert_eq!( expr, FilterExpr::Cmp("delta".into(), CmpOp::Ge, Value::Float(-0.25)) ); } #[test] fn parse_negative_int_literal() { let expr = parse("dte >= -30").unwrap(); assert_eq!( expr, FilterExpr::Cmp("dte".into(), CmpOp::Ge, Value::Int(-30)) ); } #[test] fn parse_negative_delta_range() { // The exact pattern that fails for Cash-Secured Put / Short Strangle let expr = parse("(delta >= -0.30) & (delta <= -0.15)").unwrap(); match expr { FilterExpr::And(left, right) => { assert_eq!( *left, FilterExpr::Cmp("delta".into(), CmpOp::Ge, Value::Float(-0.30)) ); assert_eq!( *right, FilterExpr::Cmp("delta".into(), CmpOp::Le, Value::Float(-0.15)) ); } _ => panic!("expected And, got {expr:?}"), } } #[test] fn compiled_filter_negative_delta() { let df = DataFrame::new(vec![ Column::new("delta".into(), &[-0.40f64, -0.25, -0.15, -0.05, 0.10]), ]).unwrap(); let f = CompiledFilter::new("(delta >= -0.30) & (delta <= -0.10)").unwrap(); let result = f.apply(&df).unwrap(); assert_eq!(result.height(), 2); // -0.25 and -0.15 } // --- eval_row tests --- #[test] fn eval_row_simple_eq() { let df = DataFrame::new(vec![ Column::new("type".into(), &["call", "put", "put"]), Column::new("ask".into(), &[1.0f64, 2.0, 0.0]), ]).unwrap(); let f = CompiledFilter::new("type == 'put'").unwrap(); assert!(!f.eval_row(&df, 0)); // call assert!(f.eval_row(&df, 1)); // put assert!(f.eval_row(&df, 2)); // put } #[test] fn eval_row_and_filter() { let df = DataFrame::new(vec![ Column::new("type".into(), &["call", "put", "put"]), Column::new("ask".into(), &[1.0f64, 2.0, 0.0]), ]).unwrap(); let f = CompiledFilter::new("(type == 'put') & (ask > 0)").unwrap(); assert!(!f.eval_row(&df, 0)); // call assert!(f.eval_row(&df, 1)); // put, ask=2 assert!(!f.eval_row(&df, 2)); // put, ask=0 } #[test] fn eval_row_col_arith() { let df = DataFrame::new(vec![ Column::new("strike".into(), &[110.0f64, 95.0, 105.0]), Column::new("underlying_last".into(), &[100.0f64, 100.0, 100.0]), ]).unwrap(); // strike >= underlying_last * 1.02 → ColArith("underlying_last", Mul, 1.02, Le, Column("strike")) let f = CompiledFilter::new("strike >= underlying_last * 1.02").unwrap(); assert!(f.eval_row(&df, 0)); // 110 >= 102 assert!(!f.eval_row(&df, 1)); // 95 < 102 assert!(f.eval_row(&df, 2)); // 105 >= 102 } #[test] fn eval_row_negative_delta() { let df = DataFrame::new(vec![ Column::new("delta".into(), &[-0.40f64, -0.25, -0.15, -0.05, 0.10]), ]).unwrap(); let f = CompiledFilter::new("(delta >= -0.30) & (delta <= -0.10)").unwrap(); assert!(!f.eval_row(&df, 0)); // -0.40 assert!(f.eval_row(&df, 1)); // -0.25 assert!(f.eval_row(&df, 2)); // -0.15 assert!(!f.eval_row(&df, 3)); // -0.05 assert!(!f.eval_row(&df, 4)); // 0.10 } #[test] fn eval_row_matches_apply() { // Verify eval_row matches apply for all rows let df = DataFrame::new(vec![ Column::new("underlying".into(), &["SPX", "SPX", "AAPL", "SPX"]), Column::new("dte".into(), &[30i32, 90, 90, 150]), ]).unwrap(); let f = CompiledFilter::new("(underlying == 'SPX') & (dte >= 60) & (dte <= 120)").unwrap(); let apply_result = f.apply(&df).unwrap(); assert_eq!(apply_result.height(), 1); // eval_row should match assert!(!f.eval_row(&df, 0)); // SPX, dte=30 assert!(f.eval_row(&df, 1)); // SPX, dte=90 assert!(!f.eval_row(&df, 2)); // AAPL assert!(!f.eval_row(&df, 3)); // SPX, dte=150 } } ================================================ FILE: rust/ob_core/src/inventory.rs ================================================ //! Inventory join — THE hot path. //! //! Mirrors the inner loop of Python's `_update_balance`: //! inv_info.merge(options_data, left_on="_contract", right_on=contract_col) //! then compute _value = sign * price * qty * shares_per_contract //! then groupby(date).sum() split by call/put. use polars::prelude::*; use crate::types::Direction; /// Join inventory contracts with current market data and compute leg values. /// /// Returns a DataFrame with columns: [date, _value, _type] /// where _value = sign * price * qty * shares_per_contract. pub fn join_inventory_to_market( contracts: &[String], qtys: &[f64], types: &[String], underlyings: &[String], strikes: &[f64], options_data: &DataFrame, stocks_data: Option<&DataFrame>, contract_col: &str, _date_col: &str, cost_field: &str, stocks_sym_col: Option<&str>, stocks_price_col: Option<&str>, direction: Direction, shares_per_contract: i64, ) -> PolarsResult { let contract_series = Series::new("_contract".into(), contracts); let qty_series = Series::new("_qty".into(), qtys); let type_series = Series::new("_type".into(), types); let underlying_series = Series::new("_underlying".into(), underlyings); let strike_series = Series::new("_strike".into(), strikes); let inv = DataFrame::new(vec![ contract_series.into_column(), qty_series.into_column(), type_series.into_column(), underlying_series.into_column(), strike_series.into_column(), ])?; let mut joined = inv .lazy() .join( options_data.clone().lazy(), [col("_contract")], [col(contract_col)], JoinArgs::new(JoinType::Left), ) .collect()?; // Fill null cost fields with intrinsic value from stocks data if let Some(cost_col) = joined.column(cost_field).ok() { let null_mask = cost_col.is_null(); if null_mask.sum().unwrap_or(0) > 0 { // Build a price lookup from stocks data (latest price per symbol) let mut price_map: std::collections::HashMap = std::collections::HashMap::new(); if let (Some(sdf), Some(sym_c), Some(price_c)) = (stocks_data, stocks_sym_col, stocks_price_col) { if let (Ok(sym_ca), Ok(price_raw)) = (sdf.column(sym_c), sdf.column(price_c)) { if let Ok(sym_str) = sym_ca.str() { let price_casted = price_raw.cast(&DataType::Float64).unwrap_or(price_raw.clone()); if let Ok(price_ca) = price_casted.f64() { for i in 0..sdf.height() { if let (Some(s), Some(p)) = (sym_str.get(i), price_ca.get(i)) { price_map.insert(s.to_string(), p); } } } } } } let types_ca = joined.column("_type")?.str()?; let strikes_ca = joined.column("_strike")?.f64()?; let underlyings_ca = joined.column("_underlying")?.str()?; let cost_ca = joined.column(cost_field)?.f64()?; let filled: Vec> = (0..joined.height()) .map(|i| { if cost_ca.get(i).is_some() { cost_ca.get(i) } else { let opt_type = types_ca.get(i).unwrap_or("put"); let strike = strikes_ca.get(i).unwrap_or(0.0); let underlying = underlyings_ca.get(i).unwrap_or(""); let spot = price_map.get(underlying).copied().unwrap_or(0.0); let iv = if opt_type == "call" { (spot - strike).max(0.0) } else { (strike - spot).max(0.0) }; Some(iv) } }) .collect(); let filled_series = Float64Chunked::from_iter_options(cost_field.into(), filled.into_iter()); let _ = joined.replace(cost_field, filled_series.into_series()); } } // Compute _value after filling nulls let joined = joined .lazy() .with_column( (lit(direction.sign()) * col(cost_field) * col("_qty") * lit(shares_per_contract as f64)) .alias("_value"), ) .collect()?; Ok(joined) } /// Aggregate values by date, split into calls and puts capital. /// /// Returns (calls_by_date, puts_by_date) as Series indexed by date. pub fn aggregate_by_type( joined: &DataFrame, date_col: &str, ) -> PolarsResult<(DataFrame, DataFrame)> { let calls = joined .clone() .lazy() .filter(col("_type").eq(lit("call"))) .group_by([col(date_col)]) .agg([col("_value").sum().alias("calls_capital")]) .sort([date_col], Default::default()) .collect()?; let puts = joined .clone() .lazy() .filter(col("_type").neq(lit("call"))) .group_by([col(date_col)]) .agg([col("_value").sum().alias("puts_capital")]) .sort([date_col], Default::default()) .collect()?; Ok((calls, puts)) } #[cfg(test)] mod tests { use super::*; fn sample_options() -> DataFrame { df!( "optionroot" => &["SPX_A", "SPX_A", "SPX_B", "SPX_B"], "quotedate" => &["2024-01-01", "2024-01-02", "2024-01-01", "2024-01-02"], "ask" => &[2.0, 2.5, 3.0, 3.5], "bid" => &[1.8, 2.3, 2.8, 3.3], ) .unwrap() } #[test] fn join_computes_values() { let opts = sample_options(); let result = join_inventory_to_market( &["SPX_A".into(), "SPX_B".into()], &[10.0, 5.0], &["call".into(), "put".into()], &["SPY".into(), "SPY".into()], &[400.0, 400.0], &opts, None, "optionroot", "quotedate", "bid", None, None, Direction::Buy, 100, ) .unwrap(); assert!(result.height() > 0); let values = result.column("_value").unwrap(); // Direction::Buy sign = -1, so values should be negative let first_val: f64 = values.f64().unwrap().get(0).unwrap(); assert!(first_val < 0.0); } #[test] fn aggregate_splits_calls_puts() { let opts = sample_options(); let joined = join_inventory_to_market( &["SPX_A".into(), "SPX_B".into()], &[10.0, 5.0], &["call".into(), "put".into()], &["SPY".into(), "SPY".into()], &[400.0, 400.0], &opts, None, "optionroot", "quotedate", "bid", None, None, Direction::Buy, 100, ) .unwrap(); let (calls, puts) = aggregate_by_type(&joined, "quotedate").unwrap(); assert!(calls.height() > 0); assert!(puts.height() > 0); } } ================================================ FILE: rust/ob_core/src/lib.rs ================================================ pub mod types; pub mod inventory; pub mod balance; pub mod filter; pub mod entries; pub mod exits; pub mod stats; pub mod cost_model; pub mod fill_model; pub mod signal_selector; pub mod risk; pub mod backtest; pub mod convexity_scoring; pub mod convexity_backtest; ================================================ FILE: rust/ob_core/src/risk.rs ================================================ //! Risk management — constraints checked before entering positions. //! //! Mirrors Python's `options_portfolio_backtester.portfolio.risk`. use crate::types::Greeks; #[derive(Debug, Clone)] pub enum RiskConstraint { /// Reject trades that would push portfolio delta beyond a limit. MaxDelta { limit: f64 }, /// Reject trades that would push portfolio vega beyond a limit. MaxVega { limit: f64 }, /// Reject new entries if portfolio drawdown exceeds a threshold. MaxDrawdown { max_dd_pct: f64 }, } impl RiskConstraint { /// Check whether a proposed trade is allowed. /// /// Returns true if the trade passes this constraint. pub fn check( &self, current_greeks: &Greeks, proposed_greeks: &Greeks, portfolio_value: f64, peak_value: f64, ) -> bool { match self { RiskConstraint::MaxDelta { limit } => { let new_delta = current_greeks.delta + proposed_greeks.delta; new_delta.abs() <= *limit } RiskConstraint::MaxVega { limit } => { let new_vega = current_greeks.vega + proposed_greeks.vega; new_vega.abs() <= *limit } RiskConstraint::MaxDrawdown { max_dd_pct } => { if peak_value <= 0.0 { return true; } let dd = (peak_value - portfolio_value) / peak_value; dd < *max_dd_pct } } } } /// Check all constraints. Returns (allowed, failing_constraint_index). pub fn check_all( constraints: &[RiskConstraint], current_greeks: &Greeks, proposed_greeks: &Greeks, portfolio_value: f64, peak_value: f64, ) -> bool { constraints.iter().all(|c| c.check(current_greeks, proposed_greeks, portfolio_value, peak_value)) } #[cfg(test)] mod tests { use super::*; #[test] fn max_delta_allows() { let c = RiskConstraint::MaxDelta { limit: 100.0 }; let current = Greeks::new(50.0, 0.0, 0.0, 0.0); let proposed = Greeks::new(30.0, 0.0, 0.0, 0.0); assert!(c.check(¤t, &proposed, 1_000_000.0, 1_000_000.0)); } #[test] fn max_delta_rejects() { let c = RiskConstraint::MaxDelta { limit: 100.0 }; let current = Greeks::new(80.0, 0.0, 0.0, 0.0); let proposed = Greeks::new(30.0, 0.0, 0.0, 0.0); assert!(!c.check(¤t, &proposed, 1_000_000.0, 1_000_000.0)); } #[test] fn max_delta_negative() { let c = RiskConstraint::MaxDelta { limit: 100.0 }; let current = Greeks::new(-80.0, 0.0, 0.0, 0.0); let proposed = Greeks::new(-30.0, 0.0, 0.0, 0.0); assert!(!c.check(¤t, &proposed, 1_000_000.0, 1_000_000.0)); } #[test] fn max_vega_allows() { let c = RiskConstraint::MaxVega { limit: 50.0 }; let current = Greeks::new(0.0, 0.0, 0.0, 20.0); let proposed = Greeks::new(0.0, 0.0, 0.0, 10.0); assert!(c.check(¤t, &proposed, 1_000_000.0, 1_000_000.0)); } #[test] fn max_vega_rejects() { let c = RiskConstraint::MaxVega { limit: 50.0 }; let current = Greeks::new(0.0, 0.0, 0.0, 40.0); let proposed = Greeks::new(0.0, 0.0, 0.0, 20.0); assert!(!c.check(¤t, &proposed, 1_000_000.0, 1_000_000.0)); } #[test] fn max_drawdown_allows() { let c = RiskConstraint::MaxDrawdown { max_dd_pct: 0.20 }; let g = Greeks::default(); // 10% drawdown from peak assert!(c.check(&g, &g, 900_000.0, 1_000_000.0)); } #[test] fn max_drawdown_rejects() { let c = RiskConstraint::MaxDrawdown { max_dd_pct: 0.20 }; let g = Greeks::default(); // 25% drawdown from peak assert!(!c.check(&g, &g, 750_000.0, 1_000_000.0)); } #[test] fn max_drawdown_zero_peak() { let c = RiskConstraint::MaxDrawdown { max_dd_pct: 0.20 }; let g = Greeks::default(); assert!(c.check(&g, &g, 100.0, 0.0)); } #[test] fn check_all_passes() { let constraints = vec![ RiskConstraint::MaxDelta { limit: 100.0 }, RiskConstraint::MaxVega { limit: 50.0 }, ]; let current = Greeks::new(30.0, 0.0, 0.0, 10.0); let proposed = Greeks::new(10.0, 0.0, 0.0, 5.0); assert!(super::check_all(&constraints, ¤t, &proposed, 1_000_000.0, 1_000_000.0)); } #[test] fn check_all_fails_one() { let constraints = vec![ RiskConstraint::MaxDelta { limit: 100.0 }, RiskConstraint::MaxVega { limit: 50.0 }, ]; let current = Greeks::new(30.0, 0.0, 0.0, 40.0); let proposed = Greeks::new(10.0, 0.0, 0.0, 20.0); // Delta OK (40), but Vega fails (60 > 50) assert!(!super::check_all(&constraints, ¤t, &proposed, 1_000_000.0, 1_000_000.0)); } #[test] fn check_all_empty_passes() { let g = Greeks::default(); assert!(super::check_all(&[], &g, &g, 1_000_000.0, 1_000_000.0)); } } ================================================ FILE: rust/ob_core/src/signal_selector.rs ================================================ //! Signal selectors — choose which contract to trade from candidates. //! //! Mirrors Python's `options_portfolio_backtester.execution.signal_selector`. use polars::prelude::*; #[derive(Debug, Clone, Default)] pub enum SignalSelector { /// Pick the first row (default — matches original iloc[0] behavior). #[default] FirstMatch, /// Pick the contract whose column value is closest to `target`. NearestDelta { target: f64, column: String }, /// Pick the contract with the highest value in `column`. MaxOpenInterest { column: String }, } impl SignalSelector { /// Extra columns this selector needs preserved through the entry pipeline. pub fn column_requirements(&self) -> Vec<&str> { match self { SignalSelector::FirstMatch => vec![], SignalSelector::NearestDelta { column, .. } => vec![column.as_str()], SignalSelector::MaxOpenInterest { column } => vec![column.as_str()], } } /// Select one row index from a DataFrame of candidates. Returns 0-based row index. #[inline] pub fn select_index(&self, candidates: &DataFrame) -> usize { if candidates.height() == 0 { return 0; } match self { SignalSelector::FirstMatch => 0, SignalSelector::NearestDelta { target, column } => { match candidates.column(column).ok() { Some(col) => { match col.f64() { Ok(ca) => { let mut best_idx = 0; let mut best_diff = f64::MAX; for (i, val) in ca.into_iter().enumerate() { if let Some(v) = val { let diff = (v - target).abs(); if diff < best_diff { best_diff = diff; best_idx = i; } } } best_idx } Err(_) => 0, } } None => 0, // column not found, fall back to first } } SignalSelector::MaxOpenInterest { column } => { match candidates.column(column).ok() { Some(col) => { match col.f64() { Ok(ca) => { let mut best_idx = 0; let mut best_val = f64::MIN; for (i, val) in ca.into_iter().enumerate() { if let Some(v) = val { if v > best_val { best_val = v; best_idx = i; } } } best_idx } Err(_) => { // Try i64 column match col.i64() { Ok(ca) => { let mut best_idx = 0; let mut best_val = i64::MIN; for (i, val) in ca.into_iter().enumerate() { if let Some(v) = val { if v > best_val { best_val = v; best_idx = i; } } } best_idx } Err(_) => 0, } } } } None => 0, } } } } } #[cfg(test)] mod tests { use super::*; fn sample_candidates() -> DataFrame { df!( "contract" => &["A", "B", "C"], "cost" => &[100.0, 200.0, 150.0], "delta" => &[-0.20, -0.30, -0.45], "openinterest" => &[500.0, 1200.0, 800.0], ).unwrap() } #[test] fn first_match() { let df = sample_candidates(); let sel = SignalSelector::FirstMatch; assert_eq!(sel.select_index(&df), 0); } #[test] fn nearest_delta() { let df = sample_candidates(); let sel = SignalSelector::NearestDelta { target: -0.30, column: "delta".into(), }; assert_eq!(sel.select_index(&df), 1); // B has delta=-0.30 } #[test] fn nearest_delta_between() { let df = sample_candidates(); let sel = SignalSelector::NearestDelta { target: -0.35, column: "delta".into(), }; // -0.30 is 0.05 away, -0.45 is 0.10 away → B wins assert_eq!(sel.select_index(&df), 1); } #[test] fn max_open_interest() { let df = sample_candidates(); let sel = SignalSelector::MaxOpenInterest { column: "openinterest".into(), }; assert_eq!(sel.select_index(&df), 1); // B has OI=1200 } #[test] fn missing_column_falls_back() { let df = sample_candidates(); let sel = SignalSelector::NearestDelta { target: -0.30, column: "nonexistent".into(), }; assert_eq!(sel.select_index(&df), 0); } #[test] fn column_requirements_check() { assert!(SignalSelector::FirstMatch.column_requirements().is_empty()); let sel = SignalSelector::NearestDelta { target: 0.0, column: "delta".into() }; assert_eq!(sel.column_requirements(), vec!["delta"]); let sel = SignalSelector::MaxOpenInterest { column: "oi".into() }; assert_eq!(sel.column_requirements(), vec!["oi"]); } } ================================================ FILE: rust/ob_core/src/stats.rs ================================================ //! Performance statistics computation. //! //! Comprehensive stats matching Python's BacktestStats: return metrics, //! drawdown analysis, period stats, lookback returns, trade stats, //! portfolio metrics (turnover, Herfindahl). const TRADING_DAYS_PER_YEAR: f64 = 252.0; const MONTHS_PER_YEAR: f64 = 12.0; // --------------------------------------------------------------------------- // Public result types // --------------------------------------------------------------------------- /// Stats for a specific return frequency (daily, monthly, yearly). #[derive(Debug, Clone, Default)] pub struct PeriodStats { pub mean: f64, pub vol: f64, pub sharpe: f64, pub sortino: f64, pub skew: f64, pub kurtosis: f64, pub best: f64, pub worst: f64, } /// Trailing-period returns as of the last date. #[derive(Debug, Clone, Default)] pub struct LookbackReturns { pub mtd: Option, pub three_month: Option, pub six_month: Option, pub ytd: Option, pub one_year: Option, pub three_year: Option, pub five_year: Option, pub ten_year: Option, } /// Comprehensive backtest statistics. #[derive(Debug, Clone, Default)] pub struct FullStats { // Trade stats pub total_trades: u32, pub wins: u32, pub losses: u32, pub win_pct: f64, pub profit_factor: f64, pub largest_win: f64, pub largest_loss: f64, pub avg_win: f64, pub avg_loss: f64, pub avg_trade: f64, // Return stats pub total_return: f64, pub annualized_return: f64, pub sharpe_ratio: f64, pub sortino_ratio: f64, pub calmar_ratio: f64, // Risk stats pub max_drawdown: f64, pub max_drawdown_duration: u32, pub avg_drawdown: f64, pub avg_drawdown_duration: u32, pub volatility: f64, pub tail_ratio: f64, // Period stats pub daily: PeriodStats, pub monthly: PeriodStats, pub yearly: PeriodStats, // Lookback pub lookback: LookbackReturns, // Portfolio metrics pub turnover: f64, pub herfindahl: f64, } // --------------------------------------------------------------------------- // Legacy Stats (kept for backward compat with existing callers) // --------------------------------------------------------------------------- /// Legacy stats struct used by run_backtest_py / parallel_sweep. #[derive(Debug, Clone, Default)] pub struct Stats { pub total_return: f64, pub annualized_return: f64, pub sharpe_ratio: f64, pub sortino_ratio: f64, pub calmar_ratio: f64, pub max_drawdown: f64, pub max_drawdown_duration: u32, pub profit_factor: f64, pub win_rate: f64, pub total_trades: u32, } // --------------------------------------------------------------------------- // Main entry points // --------------------------------------------------------------------------- /// Compute legacy stats (backward compat). pub fn compute_stats( daily_returns: &[f64], trade_pnls: &[f64], risk_free_rate: f64, ) -> Stats { let n = daily_returns.len(); if n == 0 { return Stats::default(); } let total_return = cum_return(daily_returns); let years = n as f64 / TRADING_DAYS_PER_YEAR; let annualized_return = annualize(total_return, years); let sharpe_ratio = sharpe(daily_returns, risk_free_rate, TRADING_DAYS_PER_YEAR); let sortino_ratio = sortino(daily_returns, risk_free_rate, TRADING_DAYS_PER_YEAR); let dd = compute_drawdown_full(daily_returns); let calmar_ratio = if dd.max_drawdown > 0.0 { annualized_return / dd.max_drawdown } else { 0.0 }; let ts = compute_trade_stats(trade_pnls); Stats { total_return, annualized_return, sharpe_ratio, sortino_ratio, calmar_ratio, max_drawdown: dd.max_drawdown, max_drawdown_duration: dd.max_drawdown_duration, profit_factor: ts.profit_factor, win_rate: ts.win_pct / 100.0, // legacy uses 0-1 scale total_trades: ts.total_trades, } } /// Compute comprehensive stats from total_capital series + optional trade PnLs. /// /// `total_capital`: daily total capital values (one per trading day). /// `timestamps_ns`: nanosecond timestamps for each capital value (for monthly/yearly resampling). /// `trade_pnls`: per-trade profit/loss values. /// `stock_weights`: flattened [n_days × n_stocks] matrix of portfolio weights (row-major). /// `n_stocks`: number of stock columns. /// `risk_free_rate`: annualized risk-free rate. pub fn compute_full_stats( total_capital: &[f64], timestamps_ns: &[i64], trade_pnls: &[f64], stock_weights: &[f64], n_stocks: usize, risk_free_rate: f64, ) -> FullStats { let mut fs = FullStats::default(); if total_capital.len() < 2 { return fs; } // Daily returns from capital series let daily_returns: Vec = total_capital .windows(2) .map(|w| if w[0] != 0.0 { w[1] / w[0] - 1.0 } else { 0.0 }) .collect(); let n = daily_returns.len(); if n == 0 { return fs; } // -- Return metrics -- fs.total_return = total_capital.last().unwrap() / total_capital[0] - 1.0; let years = n as f64 / TRADING_DAYS_PER_YEAR; fs.annualized_return = annualize(fs.total_return, years); fs.volatility = std_dev(&daily_returns) * TRADING_DAYS_PER_YEAR.sqrt(); fs.sharpe_ratio = sharpe(&daily_returns, risk_free_rate, TRADING_DAYS_PER_YEAR); fs.sortino_ratio = sortino(&daily_returns, risk_free_rate, TRADING_DAYS_PER_YEAR); // -- Drawdown -- let dd = compute_drawdown_full(&daily_returns); fs.max_drawdown = dd.max_drawdown; fs.max_drawdown_duration = dd.max_drawdown_duration; fs.avg_drawdown = dd.avg_drawdown; fs.avg_drawdown_duration = dd.avg_drawdown_duration; // Calmar if fs.max_drawdown > 0.0 { fs.calmar_ratio = fs.annualized_return / fs.max_drawdown; } // Tail ratio if n > 20 { let p95 = percentile(&daily_returns, 95.0); let p5 = percentile(&daily_returns, 5.0).abs(); if p5 > 0.0 { fs.tail_ratio = p95 / p5; } } // -- Daily period stats -- fs.daily = compute_period_stats(&daily_returns, risk_free_rate, TRADING_DAYS_PER_YEAR); // -- Monthly period stats -- let monthly_returns = resample_returns(total_capital, timestamps_ns, ResampleFreq::Monthly); if !monthly_returns.is_empty() { fs.monthly = compute_period_stats(&monthly_returns, risk_free_rate, MONTHS_PER_YEAR); } // -- Yearly period stats -- let yearly_returns = resample_returns(total_capital, timestamps_ns, ResampleFreq::Yearly); if !yearly_returns.is_empty() { fs.yearly = compute_period_stats(&yearly_returns, risk_free_rate, 1.0); } // -- Lookback returns -- fs.lookback = compute_lookback(total_capital, timestamps_ns); // -- Turnover -- fs.turnover = compute_turnover(stock_weights, n_stocks); // -- Herfindahl -- fs.herfindahl = compute_herfindahl(stock_weights, n_stocks); // -- Trade stats -- let ts = compute_trade_stats(trade_pnls); fs.total_trades = ts.total_trades; fs.wins = ts.wins; fs.losses = ts.losses; fs.win_pct = ts.win_pct; fs.profit_factor = ts.profit_factor; fs.largest_win = ts.largest_win; fs.largest_loss = ts.largest_loss; fs.avg_win = ts.avg_win; fs.avg_loss = ts.avg_loss; fs.avg_trade = ts.avg_trade; fs } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- fn cum_return(returns: &[f64]) -> f64 { returns.iter().fold(1.0, |acc, &r| acc * (1.0 + r)) - 1.0 } fn annualize(total_return: f64, years: f64) -> f64 { if years > 0.0 { (1.0 + total_return).powf(1.0 / years) - 1.0 } else { 0.0 } } fn mean(values: &[f64]) -> f64 { if values.is_empty() { return 0.0; } values.iter().sum::() / values.len() as f64 } fn std_dev(values: &[f64]) -> f64 { if values.len() < 2 { return 0.0; } let m = mean(values); let variance = values.iter().map(|&x| (x - m).powi(2)).sum::() / (values.len() - 1) as f64; variance.sqrt() } fn skewness(values: &[f64]) -> f64 { let n = values.len(); if n < 8 { return 0.0; } let m = mean(values); let s = std_dev(values); if s == 0.0 { return 0.0; } let nf = n as f64; let m3: f64 = values.iter().map(|&x| ((x - m) / s).powi(3)).sum::() / nf; // Adjusted Fisher-Pearson (matches pandas default) let adj = (nf * (nf - 1.0)).sqrt() / (nf - 2.0); adj * m3 } fn kurtosis_excess(values: &[f64]) -> f64 { let n = values.len(); if n < 8 { return 0.0; } let m = mean(values); let s = std_dev(values); if s == 0.0 { return 0.0; } let nf = n as f64; let m4: f64 = values.iter().map(|&x| ((x - m) / s).powi(4)).sum::() / nf; // Excess kurtosis with bias correction (matches pandas default) let raw = m4 - 3.0; let adj = (nf - 1.0) / ((nf - 2.0) * (nf - 3.0)) * ((nf + 1.0) * raw + 6.0); adj } fn sharpe(returns: &[f64], risk_free_rate: f64, periods_per_year: f64) -> f64 { if returns.len() < 2 { return 0.0; } let rf_per_period = (1.0 + risk_free_rate).powf(1.0 / periods_per_year) - 1.0; let excess: Vec = returns.iter().map(|&r| r - rf_per_period).collect(); let s = std_dev(&excess); if s == 0.0 { return 0.0; } mean(&excess) / s * periods_per_year.sqrt() } fn sortino(returns: &[f64], risk_free_rate: f64, periods_per_year: f64) -> f64 { if returns.len() < 2 { return 0.0; } let rf_per_period = (1.0 + risk_free_rate).powf(1.0 / periods_per_year) - 1.0; let excess: Vec = returns.iter().map(|&r| r - rf_per_period).collect(); let downside: Vec = excess.iter().filter(|&&r| r < 0.0).copied().collect(); if downside.is_empty() { return 0.0; } let s = std_dev(&downside); if s == 0.0 { return 0.0; } mean(&excess) / s * periods_per_year.sqrt() } fn percentile(values: &[f64], pct: f64) -> f64 { if values.is_empty() { return 0.0; } let mut sorted: Vec = values.to_vec(); sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); let idx = (pct / 100.0) * (sorted.len() - 1) as f64; let lo = idx.floor() as usize; let hi = idx.ceil() as usize; if lo == hi || hi >= sorted.len() { sorted[lo.min(sorted.len() - 1)] } else { let frac = idx - lo as f64; sorted[lo] * (1.0 - frac) + sorted[hi] * frac } } // -- Drawdown -- struct DrawdownResult { max_drawdown: f64, max_drawdown_duration: u32, avg_drawdown: f64, avg_drawdown_duration: u32, } fn compute_drawdown_full(daily_returns: &[f64]) -> DrawdownResult { let mut peak = 1.0_f64; let mut equity = 1.0_f64; let mut max_dd = 0.0_f64; let mut max_dd_dur: u32 = 0; let mut current_dur: u32 = 0; // Track drawdown episodes for avg computation let mut episode_depths: Vec = Vec::new(); let mut episode_durations: Vec = Vec::new(); let mut current_min_dd = 0.0_f64; // deepest dd in current episode for &r in daily_returns { equity *= 1.0 + r; if equity > peak { // End of drawdown episode (if we were in one) if current_dur > 0 { episode_depths.push(current_min_dd); episode_durations.push(current_dur); } peak = equity; current_dur = 0; current_min_dd = 0.0; } else { current_dur += 1; max_dd_dur = max_dd_dur.max(current_dur); } let dd = (peak - equity) / peak; if dd > max_dd { max_dd = dd; } if dd > current_min_dd { current_min_dd = dd; } } // Close last episode if still in drawdown if current_dur > 0 { episode_depths.push(current_min_dd); episode_durations.push(current_dur); } let avg_drawdown = if episode_depths.is_empty() { 0.0 } else { mean(&episode_depths) }; let avg_drawdown_duration = if episode_durations.is_empty() { 0 } else { let dur_f: Vec = episode_durations.iter().map(|&d| d as f64).collect(); mean(&dur_f) as u32 }; DrawdownResult { max_drawdown: max_dd, max_drawdown_duration: max_dd_dur, avg_drawdown, avg_drawdown_duration, } } // -- Period stats -- fn compute_period_stats(returns: &[f64], risk_free_rate: f64, periods_per_year: f64) -> PeriodStats { if returns.is_empty() { return PeriodStats::default(); } PeriodStats { mean: mean(returns), vol: std_dev(returns), sharpe: sharpe(returns, risk_free_rate, periods_per_year), sortino: sortino(returns, risk_free_rate, periods_per_year), skew: skewness(returns), kurtosis: kurtosis_excess(returns), best: returns.iter().cloned().fold(f64::NEG_INFINITY, f64::max), worst: returns.iter().cloned().fold(f64::INFINITY, f64::min), } } // -- Resampling -- #[derive(Clone, Copy)] enum ResampleFreq { Monthly, Yearly, } /// Resample total_capital to end-of-period values, then compute returns. fn resample_returns( total_capital: &[f64], timestamps_ns: &[i64], freq: ResampleFreq, ) -> Vec { if total_capital.len() < 2 || timestamps_ns.len() != total_capital.len() { return Vec::new(); } // Group by period key, take last value in each period let mut period_vals: Vec = Vec::new(); let mut last_key: Option<(i32, u32)> = None; for (i, &ts_ns) in timestamps_ns.iter().enumerate() { let key = period_key(ts_ns, freq); match last_key { Some(prev) if prev != key => { // Previous period ended at i-1 period_vals.push(total_capital[i - 1]); last_key = Some(key); } None => { last_key = Some(key); } _ => {} } } // Push the last period value if let Some(_) = last_key { period_vals.push(*total_capital.last().unwrap()); } // Compute returns from period values if period_vals.len() < 2 { return Vec::new(); } period_vals .windows(2) .map(|w| if w[0] != 0.0 { w[1] / w[0] - 1.0 } else { 0.0 }) .collect() } /// Convert nanosecond timestamp to (year, period) key. fn period_key(ts_ns: i64, freq: ResampleFreq) -> (i32, u32) { // Convert nanoseconds since epoch to days let days_since_epoch = (ts_ns / 86_400_000_000_000) as i32; // Simple calendar calculation from days since 1970-01-01 let (year, month, _day) = days_to_ymd(days_since_epoch); match freq { ResampleFreq::Monthly => (year, month), ResampleFreq::Yearly => (year, 0), } } /// Convert days since epoch (1970-01-01) to (year, month, day). fn days_to_ymd(days: i32) -> (i32, u32, u32) { // Algorithm from Howard Hinnant's date library (public domain) let z = days + 719468; let era = if z >= 0 { z } else { z - 146096 } / 146097; let doe = (z - era * 146097) as u32; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe as i32 + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let year = if m <= 2 { y + 1 } else { y }; (year, m, d) } // -- Lookback returns -- fn compute_lookback(total_capital: &[f64], timestamps_ns: &[i64]) -> LookbackReturns { let mut lb = LookbackReturns::default(); if total_capital.len() < 2 || timestamps_ns.len() != total_capital.len() { return lb; } let end_val = *total_capital.last().unwrap(); let end_ts = *timestamps_ns.last().unwrap(); let (end_year, end_month, _end_day) = days_to_ymd((end_ts / 86_400_000_000_000) as i32); // Helper: find return since the first data point on or after target_ns let return_since = |target_ns: i64| -> Option { match timestamps_ns.iter().position(|&ts| ts >= target_ns) { Some(idx) => { let start_val = total_capital[idx]; if start_val == 0.0 { None } else { Some(end_val / start_val - 1.0) } } None => None, } }; // MTD: start of current month lb.mtd = return_since(ymd_to_ns(end_year, end_month, 1)); // YTD: start of current year lb.ytd = return_since(ymd_to_ns(end_year, 1, 1)); // Fixed offsets (in months) let offsets: [(fn(&mut LookbackReturns, Option), u32); 6] = [ (|lb, v| lb.three_month = v, 3), (|lb, v| lb.six_month = v, 6), (|lb, v| lb.one_year = v, 12), (|lb, v| lb.three_year = v, 36), (|lb, v| lb.five_year = v, 60), (|lb, v| lb.ten_year = v, 120), ]; for (setter, months) in offsets { let target_ns = subtract_months_ns(end_ts, months); setter(&mut lb, return_since(target_ns)); } lb } fn ymd_to_ns(year: i32, month: u32, day: u32) -> i64 { // Inverse of days_to_ymd: compute days since epoch let y = if month <= 2 { year - 1 } else { year } as i64; let m = if month <= 2 { month + 9 } else { month - 3 } as i64; let era = if y >= 0 { y } else { y - 399 } / 400; let yoe = y - era * 400; let doy = (153 * m + 2) / 5 + day as i64 - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; let days = era * 146097 + doe - 719468; days * 86_400_000_000_000 } fn subtract_months_ns(ts_ns: i64, months: u32) -> i64 { let (year, month, day) = days_to_ymd((ts_ns / 86_400_000_000_000) as i32); let total_months = year * 12 + month as i32 - months as i32; let new_year = if total_months > 0 { (total_months - 1) / 12 } else { (total_months - 12) / 12 }; let new_month = total_months - new_year * 12; ymd_to_ns(new_year, new_month as u32, day.min(28)) // clamp day to avoid invalid dates } // -- Portfolio metrics -- fn compute_turnover(stock_weights: &[f64], n_stocks: usize) -> f64 { if n_stocks == 0 || stock_weights.is_empty() { return 0.0; } let n_days = stock_weights.len() / n_stocks; if n_days < 2 { return 0.0; } let mut total_change = 0.0; for day in 1..n_days { let mut day_change = 0.0; for s in 0..n_stocks { let prev = stock_weights[(day - 1) * n_stocks + s]; let curr = stock_weights[day * n_stocks + s]; day_change += (curr - prev).abs(); } total_change += day_change; } total_change / (n_days - 1) as f64 / 2.0 } fn compute_herfindahl(stock_weights: &[f64], n_stocks: usize) -> f64 { if n_stocks == 0 || stock_weights.is_empty() { return 0.0; } let n_days = stock_weights.len() / n_stocks; if n_days == 0 { return 0.0; } let mut total_hhi = 0.0; for day in 0..n_days { let mut hhi = 0.0; for s in 0..n_stocks { let w = stock_weights[day * n_stocks + s]; hhi += w * w; } total_hhi += hhi; } total_hhi / n_days as f64 } // -- Trade stats -- struct TradeStatsResult { total_trades: u32, wins: u32, losses: u32, win_pct: f64, profit_factor: f64, largest_win: f64, largest_loss: f64, avg_win: f64, avg_loss: f64, avg_trade: f64, } fn compute_trade_stats(pnls: &[f64]) -> TradeStatsResult { if pnls.is_empty() { return TradeStatsResult { total_trades: 0, wins: 0, losses: 0, win_pct: 0.0, profit_factor: 0.0, largest_win: 0.0, largest_loss: 0.0, avg_win: 0.0, avg_loss: 0.0, avg_trade: 0.0, }; } let mut gross_profit = 0.0; let mut gross_loss = 0.0; let mut wins: u32 = 0; let mut losses: u32 = 0; let mut largest_win = 0.0_f64; let mut largest_loss = 0.0_f64; let mut sum_wins = 0.0; let mut sum_losses = 0.0; for &pnl in pnls { if pnl > 0.0 { gross_profit += pnl; wins += 1; sum_wins += pnl; if pnl > largest_win { largest_win = pnl; } } else { gross_loss += pnl.abs(); losses += 1; sum_losses += pnl; if pnl < largest_loss { largest_loss = pnl; } } } let total = pnls.len() as u32; let win_pct = if total > 0 { wins as f64 / total as f64 * 100.0 } else { 0.0 }; let profit_factor = if gross_loss > 0.0 { gross_profit / gross_loss } else if gross_profit > 0.0 { f64::INFINITY } else { 0.0 }; TradeStatsResult { total_trades: total, wins, losses, win_pct, profit_factor, largest_win, largest_loss, avg_win: if wins > 0 { sum_wins / wins as f64 } else { 0.0 }, avg_loss: if losses > 0 { sum_losses / losses as f64 } else { 0.0 }, avg_trade: pnls.iter().sum::() / total as f64, } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn stats_empty() { let s = compute_stats(&[], &[], 0.0); assert_eq!(s.total_return, 0.0); } #[test] fn stats_simple_returns() { let returns = vec![0.01, -0.005, 0.02, -0.01, 0.015]; let s = compute_stats(&returns, &[], 0.0); assert!(s.total_return > 0.0); assert!(s.sharpe_ratio != 0.0); } #[test] fn drawdown_calculation() { let returns = vec![0.10, -0.18182]; // 1.0 -> 1.1 -> 0.9 let dd = compute_drawdown_full(&returns); assert!((dd.max_drawdown - 0.18182).abs() < 0.01); } #[test] fn profit_factor_calculation() { let pnls = vec![100.0, -50.0, 200.0, -30.0]; let s = compute_stats(&[0.01; 4], &pnls, 0.0); assert!((s.profit_factor - 300.0 / 80.0).abs() < 0.01); assert_eq!(s.total_trades, 4); assert_eq!(s.win_rate, 0.5); } #[test] fn full_stats_empty() { let fs = compute_full_stats(&[], &[], &[], &[], 0, 0.0); assert_eq!(fs.total_return, 0.0); assert_eq!(fs.total_trades, 0); } #[test] fn full_stats_basic() { // 10 days of varying positive returns let daily = vec![0.01, 0.005, 0.02, -0.003, 0.015, 0.008, -0.002, 0.012, 0.007, 0.01]; let mut capital = vec![100_000.0]; for &r in &daily { capital.push(capital.last().unwrap() * (1.0 + r)); } // Generate fake timestamps (2020-01-01 + daily) let base_ns: i64 = 1577836800_000_000_000; // 2020-01-01 let ts: Vec = (0..capital.len()) .map(|i| base_ns + i as i64 * 86_400_000_000_000) .collect(); let fs = compute_full_stats(&capital, &ts, &[], &[], 0, 0.0); assert!(fs.total_return > 0.0); assert!(fs.volatility > 0.0); assert!(fs.daily.mean > 0.0); } #[test] fn full_stats_drawdown_avg() { // Up, crash, recover, crash again let returns = vec![0.10, -0.15, -0.05, 0.30, 0.05, -0.10, 0.20]; let mut capital = vec![100_000.0]; for &r in &returns { capital.push(capital.last().unwrap() * (1.0 + r)); } let base_ns: i64 = 1577836800_000_000_000; let ts: Vec = (0..capital.len()) .map(|i| base_ns + i as i64 * 86_400_000_000_000) .collect(); let fs = compute_full_stats(&capital, &ts, &[], &[], 0, 0.0); assert!(fs.max_drawdown > 0.0); assert!(fs.avg_drawdown > 0.0); assert!(fs.avg_drawdown <= fs.max_drawdown); } #[test] fn full_stats_trade_pnls() { let capital = vec![100_000.0, 101_000.0, 102_000.0]; let base_ns: i64 = 1577836800_000_000_000; let ts: Vec = (0..3).map(|i| base_ns + i * 86_400_000_000_000).collect(); let pnls = vec![100.0, 200.0, -50.0]; let fs = compute_full_stats(&capital, &ts, &pnls, &[], 0, 0.0); assert_eq!(fs.total_trades, 3); assert_eq!(fs.wins, 2); assert_eq!(fs.losses, 1); assert!((fs.profit_factor - 6.0).abs() < 0.01); } #[test] fn full_stats_turnover() { // 3 days, 2 stocks let weights = vec![ 0.5, 0.5, // day 0 0.6, 0.4, // day 1: 0.1 change each 0.6, 0.4, // day 2: no change ]; let t = compute_turnover(&weights, 2); // day 1: sum(|0.1|+|0.1|)/2 = 0.1, day 2: 0 → avg = 0.05 assert!((t - 0.05).abs() < 1e-10); } #[test] fn full_stats_herfindahl() { // 2 equal stocks → HHI = 0.5 let weights = vec![0.5, 0.5, 0.5, 0.5]; let h = compute_herfindahl(&weights, 2); assert!((h - 0.5).abs() < 1e-10); } #[test] fn percentile_basic() { let vals = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]; let p50 = percentile(&vals, 50.0); assert!((p50 - 5.5).abs() < 0.01); let p0 = percentile(&vals, 0.0); assert!((p0 - 1.0).abs() < 0.01); let p100 = percentile(&vals, 100.0); assert!((p100 - 10.0).abs() < 0.01); } #[test] fn days_to_ymd_epoch() { let (y, m, d) = days_to_ymd(0); assert_eq!((y, m, d), (1970, 1, 1)); } #[test] fn days_to_ymd_known_date() { // 2020-01-01 = 18262 days since epoch let (y, m, d) = days_to_ymd(18262); assert_eq!((y, m, d), (2020, 1, 1)); } #[test] fn ymd_roundtrip() { let ns = ymd_to_ns(2020, 6, 15); let days = (ns / 86_400_000_000_000) as i32; let (y, m, d) = days_to_ymd(days); assert_eq!((y, m, d), (2020, 6, 15)); } #[test] fn lookback_basic() { // ~500 trading days from 2020-01-01 let n = 500; let mut capital = vec![100_000.0]; for i in 0..n { capital.push(capital[i] * 1.001); // small daily growth } let base_ns: i64 = ymd_to_ns(2020, 1, 1); let ts: Vec = (0..=n) .map(|i| base_ns + i as i64 * 86_400_000_000_000) .collect(); let lb = compute_lookback(&capital, &ts); assert!(lb.mtd.is_some()); assert!(lb.ytd.is_some()); assert!(lb.one_year.is_some()); } #[test] fn monthly_resample() { // Generate 90 days of data spanning ~3 months let n = 90; let mut capital = vec![100_000.0]; for i in 0..n { capital.push(capital[i] * 1.001); } let base_ns: i64 = ymd_to_ns(2020, 1, 1); let ts: Vec = (0..=n) .map(|i| base_ns + i as i64 * 86_400_000_000_000) .collect(); let monthly = resample_returns(&capital, &ts, ResampleFreq::Monthly); assert!(monthly.len() >= 2); // At least 2 monthly returns from 3 months } } ================================================ FILE: rust/ob_core/src/types.rs ================================================ /// Core domain types mirroring Python's options_portfolio_backtester.core.types. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Direction { Buy, Sell, } impl Direction { #[inline] pub fn sign(self) -> f64 { match self { Direction::Buy => -1.0, Direction::Sell => 1.0, } } #[inline] pub fn price_column(self) -> &'static str { match self { Direction::Buy => "ask", Direction::Sell => "bid", } } #[inline] pub fn invert(self) -> Direction { match self { Direction::Buy => Direction::Sell, Direction::Sell => Direction::Buy, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum OptionType { Call, Put, } impl OptionType { pub fn as_str(self) -> &'static str { match self { OptionType::Call => "call", OptionType::Put => "put", } } } /// Configuration for a single strategy leg. #[derive(Debug, Clone)] pub struct LegConfig { pub name: String, pub option_type: OptionType, pub direction: Direction, pub entry_filter_query: Option, pub exit_filter_query: Option, pub entry_sort_col: Option, pub entry_sort_asc: bool, /// Per-leg signal selector override (None = use engine-level selector). pub signal_selector: Option, /// Per-leg fill model override (None = use engine-level fill model). pub fill_model: Option, } /// Aggregated Greeks for a position or portfolio. #[derive(Debug, Clone, Copy, Default)] pub struct Greeks { pub delta: f64, pub gamma: f64, pub theta: f64, pub vega: f64, } impl Greeks { pub fn new(delta: f64, gamma: f64, theta: f64, vega: f64) -> Self { Self { delta, gamma, theta, vega } } pub fn scale(self, s: f64) -> Self { Self { delta: self.delta * s, gamma: self.gamma * s, theta: self.theta * s, vega: self.vega * s, } } } impl std::ops::Add for Greeks { type Output = Self; fn add(self, rhs: Self) -> Self { Self { delta: self.delta + rhs.delta, gamma: self.gamma + rhs.gamma, theta: self.theta + rhs.theta, vega: self.vega + rhs.vega, } } } impl std::ops::AddAssign for Greeks { fn add_assign(&mut self, rhs: Self) { self.delta += rhs.delta; self.gamma += rhs.gamma; self.theta += rhs.theta; self.vega += rhs.vega; } } /// Balance row for a single date. #[derive(Debug, Clone, Default)] pub struct BalanceRow { pub cash: f64, pub options_qty: f64, pub calls_capital: f64, pub puts_capital: f64, pub stocks_qty: f64, pub stock_holdings: Vec<(String, f64)>, pub stock_qtys: Vec<(String, f64)>, } #[cfg(test)] mod tests { use super::*; #[test] fn direction_sign() { assert_eq!(Direction::Buy.sign(), -1.0); assert_eq!(Direction::Sell.sign(), 1.0); } #[test] fn direction_invert() { assert_eq!(Direction::Buy.invert(), Direction::Sell); assert_eq!(Direction::Sell.invert(), Direction::Buy); } #[test] fn greeks_add() { let a = Greeks::new(1.0, 2.0, 3.0, 4.0); let b = Greeks::new(0.5, 0.5, 0.5, 0.5); let c = a + b; assert!((c.delta - 1.5).abs() < 1e-10); assert!((c.gamma - 2.5).abs() < 1e-10); } #[test] fn greeks_scale() { let g = Greeks::new(1.0, 2.0, 3.0, 4.0).scale(2.0); assert!((g.delta - 2.0).abs() < 1e-10); assert!((g.vega - 8.0).abs() < 1e-10); } } ================================================ FILE: rust/ob_python/Cargo.toml ================================================ [package] name = "ob_python" version = "0.1.0" edition = "2021" [lib] name = "_ob_rust" crate-type = ["cdylib"] [dependencies] ob_core = { path = "../ob_core" } pyo3 = { version = "0.24", features = ["extension-module"] } pyo3-polars = "0.21" polars = { version = "0.48", features = ["lazy"] } numpy = "0.24" rayon = "1.10" ================================================ FILE: rust/ob_python/src/arrow_bridge.rs ================================================ //! Arrow C Data Interface bridge: pyarrow <-> Polars zero-copy. //! //! Uses pyo3-polars for direct DataFrame conversions between //! Python (pandas/pyarrow) and Rust (Polars). use pyo3_polars::PyDataFrame; use polars::prelude::DataFrame; /// Convert a PyDataFrame (from Python) to a Polars DataFrame. pub fn py_to_polars(py_df: PyDataFrame) -> DataFrame { py_df.0 } /// Convert a Polars DataFrame to a PyDataFrame (for Python). pub fn polars_to_py(df: DataFrame) -> PyDataFrame { PyDataFrame(df) } ================================================ FILE: rust/ob_python/src/lib.rs ================================================ use pyo3::prelude::*; mod arrow_bridge; mod py_balance; mod py_backtest; mod py_convexity; mod py_filter; mod py_entries; mod py_exits; mod py_stats; mod py_execution; mod py_sweep; #[pymodule] fn _ob_rust(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(py_balance::update_balance, m)?)?; m.add_function(wrap_pyfunction!(py_backtest::run_backtest_py, m)?)?; m.add_function(wrap_pyfunction!(py_backtest::run_multi_strategy_py, m)?)?; m.add_function(wrap_pyfunction!(py_filter::compile_filter, m)?)?; m.add_function(wrap_pyfunction!(py_filter::apply_filter, m)?)?; m.add_function(wrap_pyfunction!(py_entries::compute_entries, m)?)?; m.add_function(wrap_pyfunction!(py_exits::compute_exit_mask, m)?)?; m.add_function(wrap_pyfunction!(py_stats::compute_stats, m)?)?; m.add_function(wrap_pyfunction!(py_stats::compute_full_stats, m)?)?; m.add_function(wrap_pyfunction!(py_sweep::parallel_sweep, m)?)?; m.add_function(wrap_pyfunction!(py_convexity::compute_daily_scores, m)?)?; m.add_function(wrap_pyfunction!(py_convexity::run_convexity_backtest, m)?)?; m.add_function(wrap_pyfunction!(py_execution::rust_option_cost, m)?)?; m.add_function(wrap_pyfunction!(py_execution::rust_stock_cost, m)?)?; m.add_function(wrap_pyfunction!(py_execution::rust_fill_price, m)?)?; m.add_function(wrap_pyfunction!(py_execution::rust_nearest_delta_index, m)?)?; m.add_function(wrap_pyfunction!(py_execution::rust_max_value_index, m)?)?; m.add_function(wrap_pyfunction!(py_execution::rust_risk_check, m)?)?; m.add_class::()?; Ok(()) } ================================================ FILE: rust/ob_python/src/py_backtest.rs ================================================ //! PyO3 bindings for full backtest loop. use pyo3::prelude::*; use pyo3::types::{PyDict, PyList}; use pyo3_polars::PyDataFrame; use ob_core::backtest::{run_backtest, run_multi_strategy, prepartition_data, BacktestConfig, StrategySlotConfig, SchemaMapping}; use ob_core::cost_model::CostModel; use ob_core::fill_model::FillModel; use ob_core::risk::RiskConstraint; use ob_core::signal_selector::SignalSelector; use ob_core::types::{Direction, LegConfig, OptionType}; use crate::arrow_bridge::{polars_to_py, py_to_polars}; /// Parse schema dict -> SchemaMapping. pub fn parse_schema(schema: &Bound<'_, PyDict>) -> PyResult { Ok(SchemaMapping { contract: get_str(schema, "contract", "optionroot")?, date: get_str(schema, "date", "quotedate")?, stocks_date: get_str(schema, "stocks_date", "date")?, stocks_sym: get_str(schema, "stocks_symbol", "symbol")?, stocks_price: get_str(schema, "stocks_price", "adjClose")?, underlying: get_str(schema, "underlying", "underlying")?, expiration: get_str(schema, "expiration", "expiration")?, option_type: get_str(schema, "type", "type")?, strike: get_str(schema, "strike", "strike")?, }) } /// Parse a CostModel from a Python dict. /// /// Expected formats: /// {"type": "NoCosts"} /// {"type": "PerContract", "rate": 0.65, "stock_rate": 0.005} /// {"type": "Tiered", "tiers": [[10000, 0.65], [50000, 0.50]], "stock_rate": 0.005} pub fn parse_cost_model(d: &Bound<'_, PyDict>) -> PyResult { let model_type = get_str(d, "type", "NoCosts")?; match model_type.as_str() { "NoCosts" => Ok(CostModel::NoCosts), "PerContract" => { let rate = get_f64(d, "rate", 0.65)?; let stock_rate = get_f64(d, "stock_rate", 0.005)?; Ok(CostModel::PerContract { rate, stock_rate }) } "Tiered" => { let tiers_raw: Vec<(i64, f64)> = d .get_item("tiers")? .map(|v| v.extract::>()) .transpose()? .unwrap_or_default(); let stock_rate = get_f64(d, "stock_rate", 0.005)?; Ok(CostModel::Tiered { tiers: tiers_raw, stock_rate }) } other => Err(pyo3::exceptions::PyValueError::new_err( format!("unknown cost model type: {other}"), )), } } /// Parse a FillModel from a Python dict. /// /// Expected formats: /// {"type": "MarketAtBidAsk"} /// {"type": "MidPrice"} /// {"type": "VolumeAware", "full_volume_threshold": 100} pub fn parse_fill_model(d: &Bound<'_, PyDict>) -> PyResult { let model_type = get_str(d, "type", "MarketAtBidAsk")?; match model_type.as_str() { "MarketAtBidAsk" => Ok(FillModel::MarketAtBidAsk), "MidPrice" => Ok(FillModel::MidPrice), "VolumeAware" => { let threshold = get_i64(d, "full_volume_threshold", 100)?; Ok(FillModel::VolumeAware { full_volume_threshold: threshold }) } other => Err(pyo3::exceptions::PyValueError::new_err( format!("unknown fill model type: {other}"), )), } } /// Parse a SignalSelector from a Python dict. /// /// Expected formats: /// {"type": "FirstMatch"} /// {"type": "NearestDelta", "target": -0.30, "column": "delta"} /// {"type": "MaxOpenInterest", "column": "openinterest"} pub fn parse_signal_selector(d: &Bound<'_, PyDict>) -> PyResult { let sel_type = get_str(d, "type", "FirstMatch")?; match sel_type.as_str() { "FirstMatch" => Ok(SignalSelector::FirstMatch), "NearestDelta" => { let target = get_f64(d, "target", -0.30)?; let column = get_str(d, "column", "delta")?; Ok(SignalSelector::NearestDelta { target, column }) } "MaxOpenInterest" => { let column = get_str(d, "column", "openinterest")?; Ok(SignalSelector::MaxOpenInterest { column }) } other => Err(pyo3::exceptions::PyValueError::new_err( format!("unknown signal selector type: {other}"), )), } } /// Parse a single RiskConstraint from a Python dict. /// /// Expected formats: /// {"type": "MaxDelta", "limit": 100.0} /// {"type": "MaxVega", "limit": 50.0} /// {"type": "MaxDrawdown", "max_dd_pct": 0.20} pub fn parse_risk_constraint(d: &Bound<'_, PyDict>) -> PyResult { let c_type = get_str(d, "type", "")?; match c_type.as_str() { "MaxDelta" => { let limit = get_f64(d, "limit", 100.0)?; Ok(RiskConstraint::MaxDelta { limit }) } "MaxVega" => { let limit = get_f64(d, "limit", 50.0)?; Ok(RiskConstraint::MaxVega { limit }) } "MaxDrawdown" => { let max_dd_pct = get_f64(d, "max_dd_pct", 0.20)?; Ok(RiskConstraint::MaxDrawdown { max_dd_pct }) } other => Err(pyo3::exceptions::PyValueError::new_err( format!("unknown risk constraint type: {other}"), )), } } /// Parse config dict -> BacktestConfig. pub fn parse_config_from_dict(config: &Bound<'_, PyDict>) -> PyResult { let alloc_obj = config .get_item("allocation")? .ok_or_else(|| pyo3::exceptions::PyKeyError::new_err("allocation"))?; let alloc: &Bound<'_, PyDict> = alloc_obj .downcast::() .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?; let alloc_stocks = get_f64(alloc, "stocks", 0.0)?; let alloc_options = get_f64(alloc, "options", 0.0)?; let alloc_cash = get_f64(alloc, "cash", 0.0)?; let initial_capital = get_f64(config, "initial_capital", 1_000_000.0)?; let spc = get_i64(config, "shares_per_contract", 100)?; let profit_pct: Option = config .get_item("profit_pct")? .and_then(|v| v.extract::().ok()); let loss_pct: Option = config .get_item("loss_pct")? .and_then(|v| v.extract::().ok()); let rebalance_dates: Vec = config .get_item("rebalance_dates")? .map(|v| v.extract::>()) .transpose()? .unwrap_or_default(); let legs_list: Vec> = config .get_item("legs")? .ok_or_else(|| pyo3::exceptions::PyKeyError::new_err("legs"))? .extract::>>()?; let legs: Vec = legs_list .iter() .map(|d| parse_leg_config(d)) .collect::>>()?; let stocks_list: Vec<(String, f64)> = config .get_item("stocks")? .map(|v| v.extract::>()) .transpose()? .unwrap_or_default(); let stock_symbols: Vec = stocks_list.iter().map(|(s, _)| s.clone()).collect(); let stock_percentages: Vec = stocks_list.iter().map(|(_, p)| *p).collect(); // Parse new execution model configs (optional — defaults to NoCosts/MarketAtBidAsk/FirstMatch/empty) let cost_model = match config.get_item("cost_model")? { Some(v) if !v.is_none() => { let d = v.downcast::() .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?; parse_cost_model(d)? } _ => CostModel::NoCosts, }; let fill_model = match config.get_item("fill_model")? { Some(v) if !v.is_none() => { let d = v.downcast::() .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?; parse_fill_model(d)? } _ => FillModel::MarketAtBidAsk, }; let signal_selector = match config.get_item("signal_selector")? { Some(v) if !v.is_none() => { let d = v.downcast::() .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?; parse_signal_selector(d)? } _ => SignalSelector::FirstMatch, }; let risk_constraints: Vec = match config.get_item("risk_constraints")? { Some(v) if !v.is_none() => { let list = v.downcast::() .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?; list.iter() .map(|item| { let d = item.downcast::() .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?; parse_risk_constraint(d) }) .collect::>>()? } _ => Vec::new(), }; let sma_days: Option = config .get_item("sma_days")? .and_then(|v| v.extract::().ok()); let options_budget_pct: Option = config .get_item("options_budget_pct")? .and_then(|v| v.extract::().ok()); let options_budget_annual_pct: Option = config .get_item("options_budget_annual_pct")? .and_then(|v| v.extract::().ok()); let stop_if_broke: bool = config .get_item("stop_if_broke")? .map(|v| v.extract::()) .transpose()? .unwrap_or(false); let max_notional_pct: Option = config .get_item("max_notional_pct")? .and_then(|v| v.extract::().ok()); let check_exits_daily: bool = config .get_item("check_exits_daily")? .map(|v| v.extract::()) .transpose()? .unwrap_or(false); let options_budget_fresh_spend: bool = config .get_item("options_budget_fresh_spend")? .map(|v| v.extract::()) .transpose()? .unwrap_or(false); let rebalance_stocks_on_exit: bool = config .get_item("rebalance_stocks_on_exit")? .map(|v| v.extract::()) .transpose()? .unwrap_or(false); Ok(BacktestConfig { allocation_stocks: alloc_stocks, allocation_options: alloc_options, allocation_cash: alloc_cash, initial_capital, shares_per_contract: spc, legs, profit_pct, loss_pct, stock_symbols, stock_percentages, rebalance_dates, cost_model, fill_model, signal_selector, risk_constraints, sma_days, options_budget_pct, options_budget_annual_pct, stop_if_broke, max_notional_pct, check_exits_daily, options_budget_fresh_spend, rebalance_stocks_on_exit, }) } /// Run a full backtest and return (balance_df, trade_log_df, stats_dict). #[pyfunction] #[pyo3(signature = (options_data, stocks_data, config, schema_mapping))] pub fn run_backtest_py( py: Python<'_>, options_data: PyDataFrame, stocks_data: PyDataFrame, config: &Bound<'_, PyDict>, schema_mapping: &Bound<'_, PyDict>, ) -> PyResult { let opts = py_to_polars(options_data); let stocks = py_to_polars(stocks_data); let schema = parse_schema(schema_mapping)?; let bt_config = parse_config_from_dict(config)?; let result = run_backtest(&bt_config, &opts, &stocks, &schema) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; // Build result tuple let balance_py = polars_to_py(result.balance); let trade_log_py = polars_to_py(result.trade_log); let stats_dict = PyDict::new(py); stats_dict.set_item("total_return", result.stats.total_return)?; stats_dict.set_item("annualized_return", result.stats.annualized_return)?; stats_dict.set_item("sharpe_ratio", result.stats.sharpe_ratio)?; stats_dict.set_item("sortino_ratio", result.stats.sortino_ratio)?; stats_dict.set_item("calmar_ratio", result.stats.calmar_ratio)?; stats_dict.set_item("max_drawdown", result.stats.max_drawdown)?; stats_dict.set_item("max_drawdown_duration", result.stats.max_drawdown_duration)?; stats_dict.set_item("profit_factor", result.stats.profit_factor)?; stats_dict.set_item("win_rate", result.stats.win_rate)?; stats_dict.set_item("total_trades", result.stats.total_trades)?; stats_dict.set_item("final_cash", result.final_cash)?; let result_tuple = pyo3::types::PyTuple::new(py, [ balance_py.into_pyobject(py)?.into_any(), trade_log_py.into_pyobject(py)?.into_any(), stats_dict.into_any(), ])?; Ok(result_tuple.into()) } pub fn parse_leg_config(d: &Bound<'_, PyDict>) -> PyResult { let name = get_str(d, "name", "")?; let direction_str = get_str(d, "direction", "ask")?; let type_str = get_str(d, "type", "call")?; let entry_filter: Option = d.get_item("entry_filter")?.and_then(|v| v.extract().ok()); let exit_filter: Option = d.get_item("exit_filter")?.and_then(|v| v.extract().ok()); let entry_sort_col: Option = d.get_item("entry_sort_col")?.and_then(|v| v.extract().ok()); let entry_sort_asc: bool = d.get_item("entry_sort_asc")? .map(|v| v.extract::()).transpose()?.unwrap_or(true); // Per-leg overrides (optional) let signal_selector = match d.get_item("signal_selector")? { Some(v) if !v.is_none() => { let sd = v.downcast::() .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?; Some(parse_signal_selector(sd)?) } _ => None, }; let fill_model = match d.get_item("fill_model")? { Some(v) if !v.is_none() => { let fd = v.downcast::() .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?; Some(parse_fill_model(fd)?) } _ => None, }; Ok(LegConfig { name, option_type: if type_str == "put" { OptionType::Put } else { OptionType::Call }, direction: if direction_str == "bid" { Direction::Sell } else { Direction::Buy }, entry_filter_query: entry_filter, exit_filter_query: exit_filter, entry_sort_col, entry_sort_asc, signal_selector, fill_model, }) } /// Parse a StrategySlotConfig from a Python dict. fn parse_slot_config(d: &Bound<'_, PyDict>) -> PyResult { let name = get_str(d, "name", "")?; let weight = get_f64(d, "weight", 1.0)?; let legs_list: Vec> = d .get_item("legs")? .ok_or_else(|| pyo3::exceptions::PyKeyError::new_err("legs"))? .extract::>>()?; let legs: Vec = legs_list .iter() .map(|ld| parse_leg_config(ld)) .collect::>>()?; let rebalance_dates: Vec = d .get_item("rebalance_dates")? .map(|v| v.extract::>()) .transpose()? .unwrap_or_default(); let profit_pct: Option = d .get_item("profit_pct")? .and_then(|v| v.extract::().ok()); let loss_pct: Option = d .get_item("loss_pct")? .and_then(|v| v.extract::().ok()); let check_exits_daily: bool = d .get_item("check_exits_daily")? .map(|v| v.extract::()) .transpose()? .unwrap_or(false); Ok(StrategySlotConfig { name, legs, weight, rebalance_dates, profit_pct, loss_pct, check_exits_daily, }) } /// Run a multi-strategy backtest and return (balance_df, trade_log_df, stats_dict). #[pyfunction] #[pyo3(signature = (options_data, stocks_data, config, schema_mapping, slots))] pub fn run_multi_strategy_py( py: Python<'_>, options_data: PyDataFrame, stocks_data: PyDataFrame, config: &Bound<'_, PyDict>, schema_mapping: &Bound<'_, PyDict>, slots: &Bound<'_, PyList>, ) -> PyResult { let opts = py_to_polars(options_data); let stocks = py_to_polars(stocks_data); let schema = parse_schema(schema_mapping)?; let bt_config = parse_config_from_dict(config)?; let slot_configs: Vec = slots .iter() .map(|item| { let d = item.downcast::() .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?; parse_slot_config(d) }) .collect::>>()?; let partitioned = prepartition_data(&opts, &stocks, &schema) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; let result = run_multi_strategy(&bt_config, &slot_configs, &partitioned, &schema) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; // Build result tuple (same format as run_backtest_py) let balance_py = polars_to_py(result.balance); let trade_log_py = polars_to_py(result.trade_log); let stats_dict = PyDict::new(py); stats_dict.set_item("total_return", result.stats.total_return)?; stats_dict.set_item("annualized_return", result.stats.annualized_return)?; stats_dict.set_item("sharpe_ratio", result.stats.sharpe_ratio)?; stats_dict.set_item("sortino_ratio", result.stats.sortino_ratio)?; stats_dict.set_item("calmar_ratio", result.stats.calmar_ratio)?; stats_dict.set_item("max_drawdown", result.stats.max_drawdown)?; stats_dict.set_item("max_drawdown_duration", result.stats.max_drawdown_duration)?; stats_dict.set_item("profit_factor", result.stats.profit_factor)?; stats_dict.set_item("win_rate", result.stats.win_rate)?; stats_dict.set_item("total_trades", result.stats.total_trades)?; stats_dict.set_item("final_cash", result.final_cash)?; let result_tuple = pyo3::types::PyTuple::new(py, [ balance_py.into_pyobject(py)?.into_any(), trade_log_py.into_pyobject(py)?.into_any(), stats_dict.into_any(), ])?; Ok(result_tuple.into()) } // Helper extractors pub fn get_str(d: &Bound<'_, PyDict>, key: &str, default: &str) -> PyResult { Ok(d.get_item(key)?.map(|v| v.extract::()).transpose()?.unwrap_or_else(|| default.into())) } pub fn get_f64(d: &Bound<'_, PyDict>, key: &str, default: f64) -> PyResult { Ok(d.get_item(key)?.map(|v| v.extract::()).transpose()?.unwrap_or(default)) } pub fn get_i64(d: &Bound<'_, PyDict>, key: &str, default: i64) -> PyResult { Ok(d.get_item(key)?.map(|v| v.extract::()).transpose()?.unwrap_or(default)) } ================================================ FILE: rust/ob_python/src/py_balance.rs ================================================ //! PyO3 bindings for balance update. use pyo3::prelude::*; use pyo3_polars::PyDataFrame; use ob_core::balance::{compute_balance, LegInventory, StockInventory}; use ob_core::types::Direction; use crate::arrow_bridge::{polars_to_py, py_to_polars}; #[pyfunction] #[pyo3(signature = ( leg_contracts, leg_qtys, leg_types, leg_directions, leg_underlyings, leg_strikes, stock_symbols, stock_qtys, options_data, stocks_data, contract_col, date_col, stocks_date_col, stocks_sym_col, stocks_price_col, shares_per_contract, cash, ))] pub fn update_balance( leg_contracts: Vec>, leg_qtys: Vec>, leg_types: Vec>, leg_directions: Vec, leg_underlyings: Vec>, leg_strikes: Vec>, stock_symbols: Vec, stock_qtys: Vec, options_data: PyDataFrame, stocks_data: PyDataFrame, contract_col: &str, date_col: &str, stocks_date_col: &str, stocks_sym_col: &str, stocks_price_col: &str, shares_per_contract: i64, cash: f64, ) -> PyResult { let opts_df = py_to_polars(options_data); let stocks_df = py_to_polars(stocks_data); let legs: Vec = leg_contracts .into_iter() .zip(leg_qtys) .zip(leg_types) .zip(leg_directions) .zip(leg_underlyings) .zip(leg_strikes) .map(|(((((contracts, qtys), types), dir), underlyings), strikes)| LegInventory { contracts, qtys, types, direction: if dir == "buy" { Direction::Buy } else { Direction::Sell }, underlyings, strikes, }) .collect(); let stocks = StockInventory { symbols: stock_symbols, qtys: stock_qtys, }; let result = compute_balance( &legs, &stocks, &opts_df, &stocks_df, contract_col, date_col, stocks_date_col, stocks_sym_col, stocks_price_col, shares_per_contract, cash, ) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; Ok(polars_to_py(result)) } ================================================ FILE: rust/ob_python/src/py_convexity.rs ================================================ use numpy::PyReadonlyArray1; use pyo3::prelude::*; use pyo3::types::PyDict; use ob_core::convexity_scoring; use ob_core::convexity_backtest; #[pyfunction] #[pyo3(signature = ( dates_ns, strikes, bids, asks, deltas, underlying_prices, dtes, implied_vols, target_delta, dte_min, dte_max, tail_drop ))] pub fn compute_daily_scores<'py>( py: Python<'py>, dates_ns: PyReadonlyArray1<'py, i64>, strikes: PyReadonlyArray1<'py, f64>, bids: PyReadonlyArray1<'py, f64>, asks: PyReadonlyArray1<'py, f64>, deltas: PyReadonlyArray1<'py, f64>, underlying_prices: PyReadonlyArray1<'py, f64>, dtes: PyReadonlyArray1<'py, i32>, implied_vols: PyReadonlyArray1<'py, f64>, target_delta: f64, dte_min: i32, dte_max: i32, tail_drop: f64, ) -> PyResult> { let scores = convexity_scoring::compute_daily_scores( dates_ns.as_slice()?, strikes.as_slice()?, bids.as_slice()?, asks.as_slice()?, deltas.as_slice()?, underlying_prices.as_slice()?, dtes.as_slice()?, implied_vols.as_slice()?, target_delta, dte_min, dte_max, tail_drop, ); let dict = PyDict::new(py); dict.set_item("dates_ns", scores.iter().map(|s| s.date_ns).collect::>())?; dict.set_item("convexity_ratios", scores.iter().map(|s| s.convexity_ratio).collect::>())?; dict.set_item("strikes", scores.iter().map(|s| s.strike).collect::>())?; dict.set_item("asks", scores.iter().map(|s| s.ask).collect::>())?; dict.set_item("bids", scores.iter().map(|s| s.bid).collect::>())?; dict.set_item("deltas", scores.iter().map(|s| s.delta).collect::>())?; dict.set_item("underlying_prices", scores.iter().map(|s| s.underlying_price).collect::>())?; dict.set_item("implied_vols", scores.iter().map(|s| s.implied_vol).collect::>())?; dict.set_item("dtes", scores.iter().map(|s| s.dte).collect::>())?; dict.set_item("annual_costs", scores.iter().map(|s| s.annual_cost).collect::>())?; dict.set_item("tail_payoffs", scores.iter().map(|s| s.tail_payoff).collect::>())?; Ok(dict) } #[pyfunction] #[pyo3(signature = ( put_dates_ns, put_expirations_ns, put_strikes, put_bids, put_asks, put_deltas, put_underlying, put_dtes, put_ivs, stock_dates_ns, stock_prices, initial_capital, budget_pct, target_delta, dte_min, dte_max, tail_drop ))] #[allow(clippy::too_many_arguments)] pub fn run_convexity_backtest<'py>( py: Python<'py>, put_dates_ns: PyReadonlyArray1<'py, i64>, put_expirations_ns: PyReadonlyArray1<'py, i64>, put_strikes: PyReadonlyArray1<'py, f64>, put_bids: PyReadonlyArray1<'py, f64>, put_asks: PyReadonlyArray1<'py, f64>, put_deltas: PyReadonlyArray1<'py, f64>, put_underlying: PyReadonlyArray1<'py, f64>, put_dtes: PyReadonlyArray1<'py, i32>, put_ivs: PyReadonlyArray1<'py, f64>, stock_dates_ns: PyReadonlyArray1<'py, i64>, stock_prices: PyReadonlyArray1<'py, f64>, initial_capital: f64, budget_pct: f64, target_delta: f64, dte_min: i32, dte_max: i32, tail_drop: f64, ) -> PyResult> { let result = convexity_backtest::run_backtest( put_dates_ns.as_slice()?, put_expirations_ns.as_slice()?, put_strikes.as_slice()?, put_bids.as_slice()?, put_asks.as_slice()?, put_deltas.as_slice()?, put_underlying.as_slice()?, put_dtes.as_slice()?, put_ivs.as_slice()?, stock_dates_ns.as_slice()?, stock_prices.as_slice()?, initial_capital, budget_pct, target_delta, dte_min, dte_max, tail_drop, ); let dict = PyDict::new(py); // Monthly records let records = PyDict::new(py); records.set_item("dates_ns", result.records.iter().map(|r| r.date_ns).collect::>())?; records.set_item("shares", result.records.iter().map(|r| r.shares).collect::>())?; records.set_item("stock_prices", result.records.iter().map(|r| r.stock_price).collect::>())?; records.set_item("equity_values", result.records.iter().map(|r| r.equity_value).collect::>())?; records.set_item("put_costs", result.records.iter().map(|r| r.put_cost).collect::>())?; records.set_item("put_exit_values", result.records.iter().map(|r| r.put_exit_value).collect::>())?; records.set_item("put_pnls", result.records.iter().map(|r| r.put_pnl).collect::>())?; records.set_item("portfolio_values", result.records.iter().map(|r| r.portfolio_value).collect::>())?; records.set_item("convexity_ratios", result.records.iter().map(|r| r.convexity_ratio).collect::>())?; records.set_item("strikes", result.records.iter().map(|r| r.strike).collect::>())?; records.set_item("contracts", result.records.iter().map(|r| r.contracts).collect::>())?; dict.set_item("records", records)?; // Daily balance series dict.set_item("daily_dates_ns", result.daily_dates_ns)?; dict.set_item("daily_balances", result.daily_balances)?; Ok(dict) } ================================================ FILE: rust/ob_python/src/py_entries.rs ================================================ //! PyO3 bindings for entry signal computation. use pyo3::prelude::*; use pyo3_polars::PyDataFrame; use ob_core::entries; use ob_core::filter; use crate::arrow_bridge::{polars_to_py, py_to_polars}; #[pyfunction] #[pyo3(signature = ( options_data, inventory_contracts, entry_filter_query, contract_col, cost_field, entry_sort_col, entry_sort_asc, shares_per_contract, is_sell, ))] pub fn compute_entries( options_data: PyDataFrame, inventory_contracts: Vec, entry_filter_query: &str, contract_col: &str, cost_field: &str, entry_sort_col: Option<&str>, entry_sort_asc: bool, shares_per_contract: i64, is_sell: bool, ) -> PyResult { let opts = py_to_polars(options_data); let compiled = filter::CompiledFilter::new(entry_filter_query) .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; let result = entries::compute_leg_entries( &opts, &inventory_contracts, &compiled, contract_col, cost_field, entry_sort_col, entry_sort_asc, shares_per_contract, is_sell, &[], ) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; Ok(polars_to_py(result)) } ================================================ FILE: rust/ob_python/src/py_execution.rs ================================================ //! PyO3 bindings for execution models: cost, fill, signal selection, risk. //! //! Exposes flat functions that call into `ob_core` implementations, //! allowing Python classes to delegate computation to Rust. use pyo3::prelude::*; use ob_core::cost_model::CostModel; use ob_core::fill_model::FillModel; use ob_core::risk::RiskConstraint; use ob_core::types::Greeks; // --------------------------------------------------------------------------- // Cost models // --------------------------------------------------------------------------- /// Compute option trade commission via Rust cost model. /// /// `model_type`: "PerContract" or "Tiered" /// `tiers`: list of (max_contracts, rate) pairs (only used for Tiered) #[pyfunction] #[pyo3(signature = (model_type, rate, stock_rate, tiers, price, quantity, spc))] pub fn rust_option_cost( model_type: &str, rate: f64, stock_rate: f64, tiers: Vec<(i64, f64)>, price: f64, quantity: f64, spc: i64, ) -> PyResult { let model = match model_type { "PerContract" => CostModel::PerContract { rate, stock_rate }, "Tiered" => CostModel::Tiered { tiers, stock_rate }, other => { return Err(pyo3::exceptions::PyValueError::new_err( format!("Unknown cost model type: {other}"), )) } }; Ok(model.option_cost(price, quantity, spc)) } /// Compute stock trade commission via Rust cost model. #[pyfunction] #[pyo3(signature = (model_type, rate, stock_rate, tiers, price, quantity))] pub fn rust_stock_cost( model_type: &str, rate: f64, stock_rate: f64, tiers: Vec<(i64, f64)>, price: f64, quantity: f64, ) -> PyResult { let model = match model_type { "PerContract" => CostModel::PerContract { rate, stock_rate }, "Tiered" => CostModel::Tiered { tiers, stock_rate }, other => { return Err(pyo3::exceptions::PyValueError::new_err( format!("Unknown cost model type: {other}"), )) } }; Ok(model.stock_cost(price, quantity)) } // --------------------------------------------------------------------------- // Fill models // --------------------------------------------------------------------------- /// Compute fill price via Rust fill model. /// /// `model_type`: "VolumeAware" /// `threshold`: full_volume_threshold (only used for VolumeAware) /// `volume`: None means missing volume data #[pyfunction] #[pyo3(signature = (model_type, threshold, bid, ask, volume, is_buy))] pub fn rust_fill_price( model_type: &str, threshold: i64, bid: f64, ask: f64, volume: Option, is_buy: bool, ) -> PyResult { let model = match model_type { "VolumeAware" => FillModel::VolumeAware { full_volume_threshold: threshold, }, other => { return Err(pyo3::exceptions::PyValueError::new_err( format!("Unknown fill model type: {other}"), )) } }; Ok(model.fill_price(bid, ask, volume, is_buy)) } // --------------------------------------------------------------------------- // Signal selectors // --------------------------------------------------------------------------- /// Find the index of the value nearest to `target` in a list of f64. /// NaN values are skipped. Returns 0 for empty input. #[pyfunction] #[pyo3(signature = (values, target))] pub fn rust_nearest_delta_index(values: Vec, target: f64) -> usize { if values.is_empty() { return 0; } let mut best_idx = 0; let mut best_diff = f64::MAX; for (i, &v) in values.iter().enumerate() { if v.is_nan() { continue; } let diff = (v - target).abs(); if diff < best_diff { best_diff = diff; best_idx = i; } } best_idx } /// Find the index of the maximum value in a list of f64. /// NaN values are skipped. Returns 0 for empty input. #[pyfunction] #[pyo3(signature = (values,))] pub fn rust_max_value_index(values: Vec) -> usize { if values.is_empty() { return 0; } let mut best_idx = 0; let mut best_val = f64::MIN; for (i, &v) in values.iter().enumerate() { if v.is_nan() { continue; } if v > best_val { best_val = v; best_idx = i; } } best_idx } // --------------------------------------------------------------------------- // Risk constraints // --------------------------------------------------------------------------- /// Check a single risk constraint via Rust. /// /// `constraint_type`: "MaxDelta", "MaxVega", or "MaxDrawdown" /// `limit`: the constraint limit (delta/vega limit, or max_dd_pct) /// `current_greeks`: [delta, gamma, theta, vega] /// `proposed_greeks`: [delta, gamma, theta, vega] #[pyfunction] #[pyo3(signature = (constraint_type, limit, current_greeks, proposed_greeks, portfolio_value, peak_value))] pub fn rust_risk_check( constraint_type: &str, limit: f64, current_greeks: [f64; 4], proposed_greeks: [f64; 4], portfolio_value: f64, peak_value: f64, ) -> PyResult { let constraint = match constraint_type { "MaxDelta" => RiskConstraint::MaxDelta { limit }, "MaxVega" => RiskConstraint::MaxVega { limit }, "MaxDrawdown" => RiskConstraint::MaxDrawdown { max_dd_pct: limit }, other => { return Err(pyo3::exceptions::PyValueError::new_err( format!("Unknown risk constraint type: {other}"), )) } }; let current = Greeks::new( current_greeks[0], current_greeks[1], current_greeks[2], current_greeks[3], ); let proposed = Greeks::new( proposed_greeks[0], proposed_greeks[1], proposed_greeks[2], proposed_greeks[3], ); Ok(constraint.check(¤t, &proposed, portfolio_value, peak_value)) } ================================================ FILE: rust/ob_python/src/py_exits.rs ================================================ //! PyO3 bindings for exit mask computation. use pyo3::prelude::*; use polars::prelude::{NamedFrom, Series}; use ob_core::exits; /// Compute threshold exit mask from entry and current costs. #[pyfunction] #[pyo3(signature = (entry_costs, current_costs, profit_pct = None, loss_pct = None))] pub fn compute_exit_mask( entry_costs: Vec, current_costs: Vec, profit_pct: Option, loss_pct: Option, ) -> PyResult> { let entry_series = Series::new("entry".into(), &entry_costs); let current_series = Series::new("current".into(), ¤t_costs); let mask = exits::threshold_exit_mask(&entry_series, ¤t_series, profit_pct, loss_pct) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; Ok(mask.into_no_null_iter().collect()) } ================================================ FILE: rust/ob_python/src/py_filter.rs ================================================ //! PyO3 bindings for filter compilation and evaluation. use pyo3::prelude::*; use pyo3_polars::PyDataFrame; use ob_core::filter; use crate::arrow_bridge::{polars_to_py, py_to_polars}; /// Compiled filter that can be reused across multiple evaluations. #[pyclass] pub struct CompiledFilter { inner: filter::CompiledFilter, } #[pymethods] impl CompiledFilter { #[new] fn new(query: &str) -> PyResult { let inner = filter::CompiledFilter::new(query) .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; Ok(Self { inner }) } fn apply(&self, data: PyDataFrame) -> PyResult { let df = py_to_polars(data); let result = self .inner .apply(&df) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; Ok(polars_to_py(result)) } fn __repr__(&self) -> String { format!("CompiledFilter({:?})", self.inner.expr) } } /// Compile a filter query string and return a CompiledFilter. #[pyfunction] pub fn compile_filter(query: &str) -> PyResult { CompiledFilter::new(query) } /// One-shot: compile and apply a filter in one call. #[pyfunction] pub fn apply_filter(query: &str, data: PyDataFrame) -> PyResult { let f = filter::CompiledFilter::new(query) .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; let df = py_to_polars(data); let result = f .apply(&df) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; Ok(polars_to_py(result)) } ================================================ FILE: rust/ob_python/src/py_stats.rs ================================================ //! PyO3 bindings for stats computation. use pyo3::prelude::*; use ob_core::stats; /// Compute backtest statistics from daily returns and trade PnLs (legacy). #[pyfunction] #[pyo3(signature = (daily_returns, trade_pnls, risk_free_rate = 0.0))] pub fn compute_stats( daily_returns: Vec, trade_pnls: Vec, risk_free_rate: f64, ) -> PyResult { let s = stats::compute_stats(&daily_returns, &trade_pnls, risk_free_rate); Python::with_gil(|py| { let dict = pyo3::types::PyDict::new(py); dict.set_item("total_return", s.total_return)?; dict.set_item("annualized_return", s.annualized_return)?; dict.set_item("sharpe_ratio", s.sharpe_ratio)?; dict.set_item("sortino_ratio", s.sortino_ratio)?; dict.set_item("calmar_ratio", s.calmar_ratio)?; dict.set_item("max_drawdown", s.max_drawdown)?; dict.set_item("max_drawdown_duration", s.max_drawdown_duration)?; dict.set_item("profit_factor", s.profit_factor)?; dict.set_item("win_rate", s.win_rate)?; dict.set_item("total_trades", s.total_trades)?; Ok(dict.into()) }) } /// Compute comprehensive backtest statistics from total capital series. /// /// Args: /// total_capital: list of daily total capital values /// timestamps_ns: list of nanosecond timestamps (one per capital value) /// trade_pnls: list of per-trade P&L values /// stock_weights: flattened [n_days × n_stocks] weight matrix (row-major) /// n_stocks: number of stock columns /// risk_free_rate: annualized risk-free rate (default 0.0) #[pyfunction] #[pyo3(signature = (total_capital, timestamps_ns, trade_pnls, stock_weights, n_stocks, risk_free_rate = 0.0))] pub fn compute_full_stats( total_capital: Vec, timestamps_ns: Vec, trade_pnls: Vec, stock_weights: Vec, n_stocks: usize, risk_free_rate: f64, ) -> PyResult { let fs = stats::compute_full_stats( &total_capital, ×tamps_ns, &trade_pnls, &stock_weights, n_stocks, risk_free_rate, ); Python::with_gil(|py| { let dict = pyo3::types::PyDict::new(py); // Trade stats dict.set_item("total_trades", fs.total_trades)?; dict.set_item("wins", fs.wins)?; dict.set_item("losses", fs.losses)?; dict.set_item("win_pct", fs.win_pct)?; dict.set_item("profit_factor", fs.profit_factor)?; dict.set_item("largest_win", fs.largest_win)?; dict.set_item("largest_loss", fs.largest_loss)?; dict.set_item("avg_win", fs.avg_win)?; dict.set_item("avg_loss", fs.avg_loss)?; dict.set_item("avg_trade", fs.avg_trade)?; // Return stats dict.set_item("total_return", fs.total_return)?; dict.set_item("annualized_return", fs.annualized_return)?; dict.set_item("sharpe_ratio", fs.sharpe_ratio)?; dict.set_item("sortino_ratio", fs.sortino_ratio)?; dict.set_item("calmar_ratio", fs.calmar_ratio)?; // Risk stats dict.set_item("max_drawdown", fs.max_drawdown)?; dict.set_item("max_drawdown_duration", fs.max_drawdown_duration)?; dict.set_item("avg_drawdown", fs.avg_drawdown)?; dict.set_item("avg_drawdown_duration", fs.avg_drawdown_duration)?; dict.set_item("volatility", fs.volatility)?; dict.set_item("tail_ratio", fs.tail_ratio)?; // Daily period stats let daily = pyo3::types::PyDict::new(py); daily.set_item("mean", fs.daily.mean)?; daily.set_item("vol", fs.daily.vol)?; daily.set_item("sharpe", fs.daily.sharpe)?; daily.set_item("sortino", fs.daily.sortino)?; daily.set_item("skew", fs.daily.skew)?; daily.set_item("kurtosis", fs.daily.kurtosis)?; daily.set_item("best", fs.daily.best)?; daily.set_item("worst", fs.daily.worst)?; dict.set_item("daily", daily)?; // Monthly period stats let monthly = pyo3::types::PyDict::new(py); monthly.set_item("mean", fs.monthly.mean)?; monthly.set_item("vol", fs.monthly.vol)?; monthly.set_item("sharpe", fs.monthly.sharpe)?; monthly.set_item("sortino", fs.monthly.sortino)?; monthly.set_item("skew", fs.monthly.skew)?; monthly.set_item("kurtosis", fs.monthly.kurtosis)?; monthly.set_item("best", fs.monthly.best)?; monthly.set_item("worst", fs.monthly.worst)?; dict.set_item("monthly", monthly)?; // Yearly period stats let yearly = pyo3::types::PyDict::new(py); yearly.set_item("mean", fs.yearly.mean)?; yearly.set_item("vol", fs.yearly.vol)?; yearly.set_item("sharpe", fs.yearly.sharpe)?; yearly.set_item("sortino", fs.yearly.sortino)?; yearly.set_item("skew", fs.yearly.skew)?; yearly.set_item("kurtosis", fs.yearly.kurtosis)?; yearly.set_item("best", fs.yearly.best)?; yearly.set_item("worst", fs.yearly.worst)?; dict.set_item("yearly", yearly)?; // Lookback returns let lookback = pyo3::types::PyDict::new(py); set_opt(&lookback, "mtd", fs.lookback.mtd)?; set_opt(&lookback, "three_month", fs.lookback.three_month)?; set_opt(&lookback, "six_month", fs.lookback.six_month)?; set_opt(&lookback, "ytd", fs.lookback.ytd)?; set_opt(&lookback, "one_year", fs.lookback.one_year)?; set_opt(&lookback, "three_year", fs.lookback.three_year)?; set_opt(&lookback, "five_year", fs.lookback.five_year)?; set_opt(&lookback, "ten_year", fs.lookback.ten_year)?; dict.set_item("lookback", lookback)?; // Portfolio metrics dict.set_item("turnover", fs.turnover)?; dict.set_item("herfindahl", fs.herfindahl)?; Ok(dict.into()) }) } fn set_opt(dict: &Bound<'_, pyo3::types::PyDict>, key: &str, val: Option) -> PyResult<()> { match val { Some(v) => dict.set_item(key, v)?, None => dict.set_item(key, pyo3::types::PyNone::get(dict.py()))?, } Ok(()) } ================================================ FILE: rust/ob_python/src/py_sweep.rs ================================================ //! Parallel grid sweep using Rayon with real run_backtest() per config. //! //! Receives options+stocks data as DataFrames once, shares via Arc, //! runs a full backtest per param override set in parallel. //! No pickle overhead — data stays in shared memory. use pyo3::prelude::*; use pyo3::types::{PyDict, PyList}; use pyo3_polars::PyDataFrame; use rayon::prelude::*; use ob_core::backtest::{ prepartition_data, run_backtest_with_filters, BacktestConfig, PartitionedData, PrecompiledFilters, SchemaMapping, }; use ob_core::cost_model::CostModel; use ob_core::fill_model::FillModel; use ob_core::risk::RiskConstraint; use ob_core::signal_selector::SignalSelector; use crate::arrow_bridge::py_to_polars; use crate::py_backtest::{ parse_config_from_dict, parse_cost_model, parse_fill_model, parse_risk_constraint, parse_schema, parse_signal_selector, }; /// Overrides parsed from each param dict (on GIL thread). struct SweepOverrides { label: String, profit_pct: Option>, // None=use base, Some(None)=clear, Some(Some(v))=override loss_pct: Option>, rebalance_dates: Option>, leg_entry_filters: Option>>, leg_exit_filters: Option>>, cost_model: Option, fill_model: Option, signal_selector: Option, risk_constraints: Option>, sma_days: Option>, options_budget_pct: Option>, options_budget_annual_pct: Option>, options_budget_fresh_spend: Option, rebalance_stocks_on_exit: Option, } struct SweepResult { label: String, stats: ob_core::stats::Stats, final_cash: f64, elapsed_ms: u128, error: Option, } /// Merge base config with overrides, returning a new BacktestConfig. fn merge_config(base: &BacktestConfig, overrides: &SweepOverrides) -> BacktestConfig { let mut cfg = base.clone(); if let Some(ref pp) = overrides.profit_pct { cfg.profit_pct = *pp; } if let Some(ref lp) = overrides.loss_pct { cfg.loss_pct = *lp; } if let Some(ref dates) = overrides.rebalance_dates { cfg.rebalance_dates = dates.clone(); } if let Some(ref filters) = overrides.leg_entry_filters { for (i, f) in filters.iter().enumerate() { if i < cfg.legs.len() { cfg.legs[i].entry_filter_query = f.clone(); } } } if let Some(ref filters) = overrides.leg_exit_filters { for (i, f) in filters.iter().enumerate() { if i < cfg.legs.len() { cfg.legs[i].exit_filter_query = f.clone(); } } } if let Some(ref cm) = overrides.cost_model { cfg.cost_model = cm.clone(); } if let Some(ref fm) = overrides.fill_model { cfg.fill_model = fm.clone(); } if let Some(ref ss) = overrides.signal_selector { cfg.signal_selector = ss.clone(); } if let Some(ref rc) = overrides.risk_constraints { cfg.risk_constraints = rc.clone(); } if let Some(ref sma) = overrides.sma_days { cfg.sma_days = *sma; } if let Some(ref bp) = overrides.options_budget_pct { cfg.options_budget_pct = *bp; } if let Some(ref ba) = overrides.options_budget_annual_pct { cfg.options_budget_annual_pct = *ba; } if let Some(fs) = overrides.options_budget_fresh_spend { cfg.options_budget_fresh_spend = fs; } if let Some(rs) = overrides.rebalance_stocks_on_exit { cfg.rebalance_stocks_on_exit = rs; } cfg } fn run_single_sweep( partitioned: &PartitionedData, base: &BacktestConfig, schema: &SchemaMapping, overrides: &SweepOverrides, base_filters: &PrecompiledFilters, ) -> SweepResult { let label = overrides.label.clone(); let cfg = merge_config(base, overrides); let start = std::time::Instant::now(); // Reuse base filters if this override didn't change any filter strings. let needs_recompile = overrides.leg_entry_filters.is_some() || overrides.leg_exit_filters.is_some(); let local_filters; let filters = if needs_recompile { local_filters = PrecompiledFilters::from_config(&cfg); &local_filters } else { base_filters }; match run_backtest_with_filters(&cfg, partitioned, schema, filters) { Ok(result) => SweepResult { label, final_cash: result.final_cash, stats: result.stats, elapsed_ms: start.elapsed().as_millis(), error: None, }, Err(e) => SweepResult { label, stats: Default::default(), final_cash: 0.0, elapsed_ms: start.elapsed().as_millis(), error: Some(format!("backtest error: {e}")), }, } } /// Parse an optional f64 that may be absent, null, or a float. /// None -> key missing (use base), Some(None) -> explicit null (clear), Some(Some(v)) -> override. fn parse_opt_f64(dict: &Bound<'_, PyDict>, key: &str) -> PyResult>> { match dict.get_item(key)? { None => Ok(None), Some(v) if v.is_none() => Ok(Some(None)), Some(v) => Ok(Some(Some(v.extract::()?))), } } /// Parse a single param override dict from Python. fn parse_overrides(dict: &Bound<'_, PyDict>) -> PyResult { let label = dict .get_item("label")? .map(|v| v.extract::()) .transpose()? .unwrap_or_default(); let profit_pct = parse_opt_f64(dict, "profit_pct")?; let loss_pct = parse_opt_f64(dict, "loss_pct")?; let rebalance_dates: Option> = dict .get_item("rebalance_dates")? .map(|v| v.extract::>()) .transpose()?; let leg_entry_filters: Option>> = dict .get_item("leg_entry_filters")? .map(|v| v.extract::>>()) .transpose()?; let leg_exit_filters: Option>> = dict .get_item("leg_exit_filters")? .map(|v| v.extract::>>()) .transpose()?; let cost_model = match dict.get_item("cost_model")? { Some(v) if !v.is_none() => { let d = v.downcast::() .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?; Some(parse_cost_model(d)?) } _ => None, }; let fill_model = match dict.get_item("fill_model")? { Some(v) if !v.is_none() => { let d = v.downcast::() .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?; Some(parse_fill_model(d)?) } _ => None, }; let signal_selector = match dict.get_item("signal_selector")? { Some(v) if !v.is_none() => { let d = v.downcast::() .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?; Some(parse_signal_selector(d)?) } _ => None, }; let risk_constraints: Option> = match dict.get_item("risk_constraints")? { Some(v) if !v.is_none() => { let list = v.downcast::() .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?; Some(list.iter() .map(|item| { let d = item.downcast::() .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?; parse_risk_constraint(d) }) .collect::>>()?) } _ => None, }; let sma_days: Option> = match dict.get_item("sma_days")? { None => None, Some(v) if v.is_none() => Some(None), Some(v) => Some(Some(v.extract::()?)), }; let options_budget_pct = parse_opt_f64(dict, "options_budget_pct")?; let options_budget_annual_pct = parse_opt_f64(dict, "options_budget_annual_pct")?; let options_budget_fresh_spend: Option = dict .get_item("options_budget_fresh_spend")? .map(|v| v.extract::()) .transpose()?; let rebalance_stocks_on_exit: Option = dict .get_item("rebalance_stocks_on_exit")? .map(|v| v.extract::()) .transpose()?; Ok(SweepOverrides { label, profit_pct, loss_pct, rebalance_dates, leg_entry_filters, leg_exit_filters, cost_model, fill_model, signal_selector, risk_constraints, sma_days, options_budget_pct, options_budget_annual_pct, options_budget_fresh_spend, rebalance_stocks_on_exit, }) } /// Run a parallel grid sweep over parameter combinations. /// /// For each param dict, merges overrides into the base config and runs /// a full backtest. All CPU-bound work runs on Rayon threads; only the /// result collection touches the GIL. #[pyfunction] #[pyo3(signature = (options_data, stocks_data, base_config, schema_mapping, param_grid, n_workers = None))] pub fn parallel_sweep( py: Python<'_>, options_data: PyDataFrame, stocks_data: PyDataFrame, base_config: &Bound<'_, PyDict>, schema_mapping: &Bound<'_, PyDict>, param_grid: &Bound<'_, PyList>, n_workers: Option, ) -> PyResult { let opts = py_to_polars(options_data); let stocks = py_to_polars(stocks_data); // Parse base config and schema on GIL thread let base = parse_config_from_dict(base_config)?; let schema = parse_schema(schema_mapping)?; // Parse all override dicts on main thread (needs GIL) let overrides: Vec = param_grid .iter() .map(|item| { let dict = item.downcast::().map_err(|e| { pyo3::exceptions::PyTypeError::new_err(format!("expected dict: {e}")) })?; parse_overrides(dict) }) .collect::>>()?; // Pre-partition data once — shared across all parallel configs via &ref. let partitioned = prepartition_data(&opts, &stocks, &schema) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("prepartition error: {e}")))?; // Pre-compile base filters once — reused by configs that don't override filters. let base_filters = PrecompiledFilters::from_config(&base); // Release GIL and run parallel computation with scoped Rayon pool let results: Vec = py.allow_threads(|| { let pool = rayon::ThreadPoolBuilder::new() .num_threads(n_workers.unwrap_or(0)) .build() .expect("failed to build rayon thread pool"); pool.install(|| { overrides .par_iter() .map(|ov| run_single_sweep(&partitioned, &base, &schema, ov, &base_filters)) .collect() }) }); // Convert results back to Python (needs GIL) let py_results = PyList::empty(py); for r in &results { let dict = PyDict::new(py); dict.set_item("label", &r.label)?; dict.set_item("total_return", r.stats.total_return)?; dict.set_item("annualized_return", r.stats.annualized_return)?; dict.set_item("sharpe_ratio", r.stats.sharpe_ratio)?; dict.set_item("sortino_ratio", r.stats.sortino_ratio)?; dict.set_item("calmar_ratio", r.stats.calmar_ratio)?; dict.set_item("max_drawdown", r.stats.max_drawdown)?; dict.set_item("max_drawdown_duration", r.stats.max_drawdown_duration)?; dict.set_item("profit_factor", r.stats.profit_factor)?; dict.set_item("win_rate", r.stats.win_rate)?; dict.set_item("total_trades", r.stats.total_trades)?; dict.set_item("final_cash", r.final_cash)?; dict.set_item("elapsed_ms", r.elapsed_ms)?; dict.set_item("error", &r.error)?; py_results.append(dict)?; } Ok(py_results.into()) } ================================================ FILE: setup.cfg ================================================ [mypy] python_version = 3.12 warn_unused_configs = True disallow_untyped_defs = False ignore_missing_imports = True # Existing codebase uses Optional types checked by asserts at runtime check_untyped_defs = False ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/analytics/__init__.py ================================================ ================================================ FILE: tests/analytics/test_analytics_pbt.py ================================================ """Property-based tests for BacktestStats via Rust compute_full_stats. Fuzzes the analytics pipeline with random balance series and trade P&Ls to verify statistical invariants hold across all inputs. """ import numpy as np import pandas as pd import pytest from hypothesis import given, settings, assume, HealthCheck from hypothesis import strategies as st from options_portfolio_backtester.analytics.stats import BacktestStats, PeriodStats # --------------------------------------------------------------------------- # Hypothesis strategies # --------------------------------------------------------------------------- daily_return = st.floats(min_value=-0.15, max_value=0.15, allow_nan=False, allow_infinity=False) positive_return = st.floats(min_value=0.0001, max_value=0.05, allow_nan=False, allow_infinity=False) negative_return = st.floats(min_value=-0.05, max_value=-0.0001, allow_nan=False, allow_infinity=False) initial_capital = st.floats(min_value=1000, max_value=1e7, allow_nan=False, allow_infinity=False) risk_free = st.floats(min_value=0.0, max_value=0.10, allow_nan=False, allow_infinity=False) trade_pnl = st.floats(min_value=-10_000, max_value=10_000, allow_nan=False, allow_infinity=False) def _make_balance(returns, initial=100_000.0): """Build a balance DataFrame from daily returns.""" dates = pd.date_range("2020-01-01", periods=len(returns) + 1, freq="B") capital = [initial] for r in returns: capital.append(capital[-1] * (1 + r)) df = pd.DataFrame({"total capital": capital}, index=dates) df["% change"] = df["total capital"].pct_change() return df # --------------------------------------------------------------------------- # BacktestStats invariants # --------------------------------------------------------------------------- class TestStatsInvariantsPBT: @given(st.lists(daily_return, min_size=20, max_size=500), initial_capital) @settings(max_examples=80, suppress_health_check=[HealthCheck.too_slow]) def test_max_drawdown_non_negative(self, returns, cap): balance = _make_balance(returns, cap) stats = BacktestStats.from_balance(balance) assert stats.max_drawdown >= -1e-10 @given(st.lists(daily_return, min_size=20, max_size=500), initial_capital) @settings(max_examples=80, suppress_health_check=[HealthCheck.too_slow]) def test_max_drawdown_at_most_one(self, returns, cap): balance = _make_balance(returns, cap) stats = BacktestStats.from_balance(balance) assert stats.max_drawdown <= 1.0 + 1e-10 @given(st.lists(daily_return, min_size=20, max_size=500), initial_capital) @settings(max_examples=80, suppress_health_check=[HealthCheck.too_slow]) def test_volatility_non_negative(self, returns, cap): balance = _make_balance(returns, cap) stats = BacktestStats.from_balance(balance) assert stats.volatility >= -1e-10 @given(st.lists(daily_return, min_size=20, max_size=500), initial_capital) @settings(max_examples=80, suppress_health_check=[HealthCheck.too_slow]) def test_total_return_matches_endpoints(self, returns, cap): """Total return = final_capital / initial_capital - 1.""" balance = _make_balance(returns, cap) stats = BacktestStats.from_balance(balance) expected = balance["total capital"].iloc[-1] / balance["total capital"].iloc[0] - 1 assert abs(stats.total_return - expected) < 1e-6 @given(st.lists(positive_return, min_size=20, max_size=200), initial_capital) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_all_positive_returns_positive_total(self, returns, cap): balance = _make_balance(returns, cap) stats = BacktestStats.from_balance(balance) assert stats.total_return > 0 @given(st.lists(negative_return, min_size=20, max_size=200), initial_capital) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_all_negative_returns_negative_total(self, returns, cap): balance = _make_balance(returns, cap) stats = BacktestStats.from_balance(balance) assert stats.total_return < 0 @given(st.lists(positive_return, min_size=20, max_size=200), initial_capital) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_all_positive_zero_drawdown(self, returns, cap): """Strictly increasing capital -> zero drawdown.""" balance = _make_balance(returns, cap) stats = BacktestStats.from_balance(balance) assert stats.max_drawdown < 1e-10 @given(st.lists(daily_return, min_size=20, max_size=500), initial_capital) @settings(max_examples=80, suppress_health_check=[HealthCheck.too_slow]) def test_max_drawdown_duration_non_negative(self, returns, cap): balance = _make_balance(returns, cap) stats = BacktestStats.from_balance(balance) assert stats.max_drawdown_duration >= 0 @given(st.lists(daily_return, min_size=20, max_size=300), initial_capital) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_calmar_sign_matches_return(self, returns, cap): """Calmar ratio has same sign as annualized return (when dd > 0).""" balance = _make_balance(returns, cap) stats = BacktestStats.from_balance(balance) if stats.max_drawdown > 1e-10: if stats.annualized_return > 0: assert stats.calmar_ratio > -1e-10 elif stats.annualized_return < 0: assert stats.calmar_ratio < 1e-10 class TestStatsEmptyEdgePBT: def test_empty_balance(self): stats = BacktestStats.from_balance(pd.DataFrame()) assert stats.total_return == 0.0 assert stats.max_drawdown == 0.0 @given(initial_capital) @settings(max_examples=20) def test_single_row(self, cap): balance = _make_balance([], cap) stats = BacktestStats.from_balance(balance) assert stats.total_return == 0.0 # --------------------------------------------------------------------------- # Trade stats invariants # --------------------------------------------------------------------------- class TestTradeStatsPBT: @given(st.lists(trade_pnl, min_size=5, max_size=200)) @settings(max_examples=100, suppress_health_check=[HealthCheck.too_slow]) def test_wins_plus_losses_equals_total(self, pnls): balance = _make_balance([0.001] * 50) pnl_arr = np.array(pnls) stats = BacktestStats.from_balance(balance, trade_pnls=pnl_arr) assert stats.wins + stats.losses == stats.total_trades @given(st.lists(trade_pnl, min_size=5, max_size=200)) @settings(max_examples=100, suppress_health_check=[HealthCheck.too_slow]) def test_total_trades_matches_input(self, pnls): balance = _make_balance([0.001] * 50) pnl_arr = np.array(pnls) stats = BacktestStats.from_balance(balance, trade_pnls=pnl_arr) assert stats.total_trades == len(pnls) @given(st.lists(st.floats(min_value=1.0, max_value=10_000, allow_nan=False, allow_infinity=False), min_size=5, max_size=50)) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_all_winners(self, pnls): balance = _make_balance([0.001] * 50) pnl_arr = np.array(pnls) stats = BacktestStats.from_balance(balance, trade_pnls=pnl_arr) assert stats.wins == len(pnls) assert stats.losses == 0 assert stats.win_pct == 100.0 @given(st.lists(st.floats(min_value=-10_000, max_value=-0.01, allow_nan=False, allow_infinity=False), min_size=5, max_size=50)) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_all_losers(self, pnls): balance = _make_balance([0.001] * 50) pnl_arr = np.array(pnls) stats = BacktestStats.from_balance(balance, trade_pnls=pnl_arr) assert stats.wins == 0 assert stats.losses == len(pnls) assert stats.win_pct == 0.0 @given(st.lists(trade_pnl, min_size=5, max_size=200)) @settings(max_examples=100, suppress_health_check=[HealthCheck.too_slow]) def test_largest_win_gte_avg_win(self, pnls): balance = _make_balance([0.001] * 50) pnl_arr = np.array(pnls) stats = BacktestStats.from_balance(balance, trade_pnls=pnl_arr) if stats.wins > 0: assert stats.largest_win >= stats.avg_win - 1e-10 @given(st.lists(trade_pnl, min_size=5, max_size=200)) @settings(max_examples=100, suppress_health_check=[HealthCheck.too_slow]) def test_largest_loss_lte_avg_loss(self, pnls): balance = _make_balance([0.001] * 50) pnl_arr = np.array(pnls) stats = BacktestStats.from_balance(balance, trade_pnls=pnl_arr) if stats.losses > 0: assert stats.largest_loss <= stats.avg_loss + 1e-10 @given(st.lists(trade_pnl, min_size=5, max_size=200)) @settings(max_examples=100, suppress_health_check=[HealthCheck.too_slow]) def test_profit_factor_non_negative(self, pnls): balance = _make_balance([0.001] * 50) pnl_arr = np.array(pnls) stats = BacktestStats.from_balance(balance, trade_pnls=pnl_arr) assert stats.profit_factor >= 0 # --------------------------------------------------------------------------- # Sharpe / Sortino via BacktestStats # --------------------------------------------------------------------------- class TestSharpePBT: @given(st.lists(daily_return, min_size=10, max_size=300), risk_free) @settings(max_examples=100) def test_finite(self, returns, rf): balance = _make_balance(returns) stats = BacktestStats.from_balance(balance, risk_free_rate=rf) assert np.isfinite(stats.sharpe_ratio) @given(st.lists(daily_return, min_size=10, max_size=300)) @settings(max_examples=50) def test_higher_rf_lower_sharpe(self, returns): """Higher risk-free rate reduces Sharpe (excess returns shrink).""" balance = _make_balance(returns) s_low = BacktestStats.from_balance(balance, risk_free_rate=0.0) s_high = BacktestStats.from_balance(balance, risk_free_rate=0.05) if s_low.daily.vol > 1e-8: assert s_high.sharpe_ratio <= s_low.sharpe_ratio + 1e-6 def test_fewer_than_two_returns_zero(self): balance = _make_balance([0.01]) stats = BacktestStats.from_balance(balance) # With 1-2 data points, Sharpe should be 0 or very close assert np.isfinite(stats.sharpe_ratio) class TestSortinoPBT: @given(st.lists(daily_return, min_size=10, max_size=300), risk_free) @settings(max_examples=100) def test_finite(self, returns, rf): balance = _make_balance(returns) stats = BacktestStats.from_balance(balance, risk_free_rate=rf) assert np.isfinite(stats.sortino_ratio) @given(st.lists(positive_return, min_size=10, max_size=100)) @settings(max_examples=50) def test_all_positive_returns_zero_sortino(self, returns): """No downside returns -> Sortino = 0 (downside std = 0).""" balance = _make_balance(returns) stats = BacktestStats.from_balance(balance) assert stats.sortino_ratio == 0.0 # --------------------------------------------------------------------------- # Daily period stats invariants # --------------------------------------------------------------------------- class TestPeriodStatsPBT: @given(st.lists(daily_return, min_size=10, max_size=300), risk_free) @settings(max_examples=100) def test_best_gte_worst(self, returns, rf): balance = _make_balance(returns) stats = BacktestStats.from_balance(balance, risk_free_rate=rf) assert stats.daily.best >= stats.daily.worst - 1e-10 @given(st.lists(daily_return, min_size=10, max_size=300), risk_free) @settings(max_examples=100) def test_vol_non_negative(self, returns, rf): balance = _make_balance(returns) stats = BacktestStats.from_balance(balance, risk_free_rate=rf) assert stats.daily.vol >= -1e-10 @given(st.lists(daily_return, min_size=10, max_size=300), risk_free) @settings(max_examples=100) def test_mean_between_best_and_worst(self, returns, rf): balance = _make_balance(returns) stats = BacktestStats.from_balance(balance, risk_free_rate=rf) assert stats.daily.worst - 1e-10 <= stats.daily.mean <= stats.daily.best + 1e-10 # --------------------------------------------------------------------------- # Lookback returns # --------------------------------------------------------------------------- class TestLookbackPBT: @given(st.lists(daily_return, min_size=30, max_size=500), initial_capital) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_mtd_always_computed(self, returns, cap): balance = _make_balance(returns, cap) stats = BacktestStats.from_balance(balance) assert stats.lookback.mtd is not None @given(st.lists(daily_return, min_size=30, max_size=500), initial_capital) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_ytd_always_computed(self, returns, cap): balance = _make_balance(returns, cap) stats = BacktestStats.from_balance(balance) assert stats.lookback.ytd is not None @given(st.lists(positive_return, min_size=30, max_size=200), initial_capital) @settings(max_examples=30, suppress_health_check=[HealthCheck.too_slow]) def test_all_positive_lookbacks_positive(self, returns, cap): """Strictly increasing capital -> all lookback returns are positive.""" balance = _make_balance(returns, cap) stats = BacktestStats.from_balance(balance) lb = stats.lookback if lb.mtd is not None: assert lb.mtd >= -1e-10 if lb.ytd is not None: assert lb.ytd >= -1e-10 if lb.three_month is not None: assert lb.three_month >= -1e-10 # --------------------------------------------------------------------------- # Turnover / Herfindahl # --------------------------------------------------------------------------- class TestTurnoverHerfindahlPBT: @given(st.lists(daily_return, min_size=20, max_size=100), initial_capital) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_turnover_non_negative(self, returns, cap): balance = _make_balance(returns, cap) stats = BacktestStats.from_balance(balance) assert stats.turnover >= -1e-10 @given(st.lists(daily_return, min_size=20, max_size=100), initial_capital) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_herfindahl_non_negative(self, returns, cap): balance = _make_balance(returns, cap) stats = BacktestStats.from_balance(balance) assert stats.herfindahl >= -1e-10 def test_no_stock_cols_zero_turnover(self): balance = _make_balance([0.01] * 20) stats = BacktestStats.from_balance(balance) assert stats.turnover == 0.0 assert stats.herfindahl == 0.0 # --------------------------------------------------------------------------- # from_balance_range # --------------------------------------------------------------------------- class TestBalanceRangePBT: @given(st.lists(daily_return, min_size=60, max_size=300), initial_capital) @settings(max_examples=30, suppress_health_check=[HealthCheck.too_slow]) def test_range_subset_shorter(self, returns, cap): """Slicing to a sub-range gives different (not necessarily smaller) return.""" balance = _make_balance(returns, cap) full = BacktestStats.from_balance(balance) # Take the middle 50% of dates mid_start = balance.index[len(balance) // 4] mid_end = balance.index[3 * len(balance) // 4] sliced = BacktestStats.from_balance_range(balance, start=mid_start, end=mid_end) # Just verify it computed something -- the stats themselves may differ assert isinstance(sliced, BacktestStats) assert sliced.max_drawdown >= -1e-10 # --------------------------------------------------------------------------- # Output formatting # --------------------------------------------------------------------------- class TestOutputFormattingPBT: @given(st.lists(daily_return, min_size=20, max_size=200), initial_capital) @settings(max_examples=30, suppress_health_check=[HealthCheck.too_slow]) def test_to_dataframe_not_empty(self, returns, cap): balance = _make_balance(returns, cap) stats = BacktestStats.from_balance(balance) df = stats.to_dataframe() assert len(df) > 0 assert "Value" in df.columns @given(st.lists(daily_return, min_size=20, max_size=200), initial_capital) @settings(max_examples=30, suppress_health_check=[HealthCheck.too_slow]) def test_summary_not_empty(self, returns, cap): balance = _make_balance(returns, cap) stats = BacktestStats.from_balance(balance) s = stats.summary() assert len(s) > 0 assert "Total Return" in s ================================================ FILE: tests/analytics/test_charts.py ================================================ """Tests for chart functions (weights_chart + Altair charts).""" from __future__ import annotations import pandas as pd import numpy as np import altair as alt import matplotlib matplotlib.use("Agg") # non-interactive backend for testing from options_portfolio_backtester.analytics.charts import ( weights_chart, returns_chart, returns_histogram, monthly_returns_heatmap, ) def _make_balance() -> pd.DataFrame: dates = pd.bdate_range("2024-01-02", periods=10) return pd.DataFrame({ "total capital": np.linspace(10000, 11000, 10), "cash": np.linspace(2000, 1000, 10), "stocks capital": np.linspace(8000, 10000, 10), "SPY qty": [50.0] * 10, "TLT qty": [100.0] * 10, }, index=dates) def test_weights_chart_returns_fig_ax(): balance = _make_balance() fig, ax = weights_chart(balance) assert fig is not None assert ax is not None assert ax.get_ylabel() == "Weight" def test_weights_chart_no_positions(): dates = pd.bdate_range("2024-01-02", periods=5) balance = pd.DataFrame({ "total capital": [10000.0] * 5, "cash": [10000.0] * 5, }, index=dates) fig, ax = weights_chart(balance) assert fig is not None assert "no positions" in ax.get_title().lower() def test_weights_chart_single_symbol(): dates = pd.bdate_range("2024-01-02", periods=5) balance = pd.DataFrame({ "total capital": [10000.0] * 5, "cash": [2000.0] * 5, "SPY qty": [80.0] * 5, }, index=dates) fig, ax = weights_chart(balance) assert fig is not None # ── Altair chart tests (moved from backtester/test/statistics/test_charts.py) ── def _make_balance_report(days=90): """Create a minimal balance-like DataFrame for Altair chart tests.""" dates = pd.bdate_range('2020-01-01', periods=days, freq='B') rng = np.random.default_rng(42) returns = rng.normal(0.0005, 0.01, size=days) capital = 1_000_000 * np.cumprod(1 + returns) report = pd.DataFrame({ 'total capital': capital, '% change': returns, 'accumulated return': np.cumprod(1 + returns), }, index=dates) return report def test_returns_chart_returns_vconcat(): """returns_chart should return a VConcatChart (layered + brush).""" report = _make_balance_report() chart = returns_chart(report) assert isinstance(chart, alt.VConcatChart) def test_returns_chart_has_two_panels(): """VConcatChart should have exactly 2 panels (main + brush).""" report = _make_balance_report() chart = returns_chart(report) assert len(chart.vconcat) == 2 def test_returns_chart_serializes_to_dict(): """Chart should serialize to a valid Vega-Lite spec dict.""" report = _make_balance_report() chart = returns_chart(report) spec = chart.to_dict() assert 'vconcat' in spec assert isinstance(spec['vconcat'], list) def test_returns_histogram_returns_chart(): """returns_histogram should return a Chart with bar mark.""" report = _make_balance_report() chart = returns_histogram(report) assert isinstance(chart, alt.Chart) def test_returns_histogram_serializes(): """Histogram should serialize without errors.""" report = _make_balance_report() chart = returns_histogram(report) spec = chart.to_dict() assert spec['mark']['type'] == 'bar' def test_monthly_returns_heatmap_returns_chart(): """monthly_returns_heatmap should return a Chart with rect mark.""" report = _make_balance_report(days=250) chart = monthly_returns_heatmap(report) assert isinstance(chart, alt.Chart) def test_monthly_returns_heatmap_serializes(): """Heatmap should serialize without errors.""" report = _make_balance_report(days=250) chart = monthly_returns_heatmap(report) spec = chart.to_dict() assert spec['mark'] == 'rect' or spec['mark']['type'] == 'rect' def test_returns_chart_has_interval_and_point_params(): """Verify the spec has both interval and point selection params (Altair 5 API).""" report = _make_balance_report() chart = returns_chart(report) spec = chart.to_dict() # In Altair 5, params are hoisted to the top-level spec params = spec.get('params', []) param_types = {p.get('select', {}).get('type') for p in params} assert 'interval' in param_types, "Expected an 'interval' selection param" assert 'point' in param_types, "Expected a 'point' selection param" def test_returns_chart_data_included(): """Verify chart spec includes data.""" report = _make_balance_report() chart = returns_chart(report) spec = chart.to_dict() assert 'data' in spec or 'datasets' in spec ================================================ FILE: tests/analytics/test_optimization.py ================================================ """Tests for analytics/optimization.py — grid_sweep and walk_forward.""" import pandas as pd import numpy as np from options_portfolio_backtester.analytics.optimization import ( OptimizationResult, grid_sweep, walk_forward, ) from options_portfolio_backtester.analytics.stats import BacktestStats def _dummy_run_fn(param_a=1, param_b=2): """Dummy backtest function for grid sweep tests.""" dates = pd.date_range("2020-01-01", periods=50, freq="B") capital = [100_000.0] for _ in range(49): capital.append(capital[-1] * (1 + 0.001 * param_a)) bal = pd.DataFrame({"total capital": capital}, index=dates) bal["% change"] = bal["total capital"].pct_change() stats = BacktestStats.from_balance(bal) return stats, bal def _failing_run_fn(param_a=1): """Fails when param_a == 2; picklable because module-level.""" if param_a == 2: raise ValueError("boom") return _dummy_run_fn(param_a=param_a) def _dummy_wf_fn(start_date, end_date): """Dummy walk-forward function.""" dates = pd.bdate_range(start_date, end_date) if len(dates) < 2: dates = pd.bdate_range(start_date, periods=5) capital = np.linspace(100000, 105000, len(dates)) bal = pd.DataFrame({"total capital": capital}, index=dates) bal["% change"] = bal["total capital"].pct_change() stats = BacktestStats.from_balance(bal) return stats, bal class TestOptimizationResult: def test_fields(self): stats = BacktestStats() bal = pd.DataFrame() r = OptimizationResult(params={"x": 1}, stats=stats, balance=bal) assert r.params == {"x": 1} assert r.stats is stats class TestGridSweep: def test_returns_results_for_all_combos(self): results = grid_sweep( _dummy_run_fn, param_grid={"param_a": [1, 2], "param_b": [10, 20]}, max_workers=1, ) assert len(results) == 4 def test_sorted_by_sharpe_descending(self): results = grid_sweep( _dummy_run_fn, param_grid={"param_a": [1, 2, 3]}, max_workers=1, ) sharpes = [r.stats.sharpe_ratio for r in results] assert sharpes == sorted(sharpes, reverse=True) def test_single_combo(self): results = grid_sweep( _dummy_run_fn, param_grid={"param_a": [1]}, max_workers=1, ) assert len(results) == 1 def test_failing_fn_skipped(self): results = grid_sweep( _failing_run_fn, param_grid={"param_a": [1, 2, 3]}, max_workers=1, ) # param_a=2 should be skipped assert len(results) == 2 class TestWalkForward: def test_returns_splits(self): dates = pd.bdate_range("2020-01-01", periods=250) results = walk_forward(_dummy_wf_fn, dates, n_splits=3) assert len(results) == 3 for is_result, oos_result in results: assert is_result.params["type"] == "in_sample" assert oos_result.params["type"] == "out_of_sample" def test_single_split(self): dates = pd.bdate_range("2020-01-01", periods=100) results = walk_forward(_dummy_wf_fn, dates, n_splits=1) assert len(results) == 1 def test_failing_wf_fn_skipped(self): """Walk-forward skips splits where run_fn raises.""" call_count = [0] def _failing_wf(start_date, end_date): call_count[0] += 1 # Fail on call 1 (in-sample for split 0) → entire split 0 skipped if call_count[0] == 1: raise ValueError("boom") return _dummy_wf_fn(start_date, end_date) dates = pd.bdate_range("2020-01-01", periods=200) results = walk_forward(_failing_wf, dates, n_splits=2) # Split 0 fails (in-sample raises), split 1 succeeds assert len(results) == 1 def test_custom_in_sample_pct(self): dates = pd.bdate_range("2020-01-01", periods=200) results = walk_forward(_dummy_wf_fn, dates, n_splits=2, in_sample_pct=0.8) assert len(results) == 2 ================================================ FILE: tests/analytics/test_stats.py ================================================ """Tests for BacktestStats — including the fixed profit_factor.""" import numpy as np import pandas as pd import pytest from options_portfolio_backtester.analytics.stats import ( BacktestStats, PeriodStats, LookbackReturns, ) def _make_balance(returns: list[float], initial: float = 100_000.0) -> pd.DataFrame: """Build a balance DataFrame from a list of daily returns.""" dates = pd.date_range("2020-01-01", periods=len(returns) + 1, freq="B") capital = [initial] for r in returns: capital.append(capital[-1] * (1 + r)) df = pd.DataFrame({"total capital": capital}, index=dates) df["% change"] = df["total capital"].pct_change() return df class TestProfitFactor: """Critical test: profit_factor must be dollar-based, not count-based.""" def test_profit_factor_dollar_ratio(self): """profit_factor = gross_profit / gross_loss in dollars.""" # 2 wins ($100, $200) and 1 loss (-$50) trade_pnls = np.array([100.0, 200.0, -50.0]) balance = _make_balance([0.01] * 10) stats = BacktestStats.from_balance(balance, trade_pnls) # gross_profit = 300, gross_loss = 50, factor = 6.0 assert stats.profit_factor == 6.0 def test_profit_factor_not_count_ratio(self): """The old bug used win_count/loss_count. Verify it's NOT that.""" # 1 big win ($1000) and 3 small losses (-$10 each) trade_pnls = np.array([1000.0, -10.0, -10.0, -10.0]) balance = _make_balance([0.01] * 10) stats = BacktestStats.from_balance(balance, trade_pnls) # Dollar: 1000/30 = 33.33, Count would be: 1/3 = 0.33 assert abs(stats.profit_factor - 33.333333) < 0.01 def test_profit_factor_no_losses(self): trade_pnls = np.array([100.0, 200.0]) balance = _make_balance([0.01] * 10) stats = BacktestStats.from_balance(balance, trade_pnls) assert stats.profit_factor == float("inf") def test_profit_factor_no_wins(self): trade_pnls = np.array([-100.0, -200.0]) balance = _make_balance([0.01] * 10) stats = BacktestStats.from_balance(balance, trade_pnls) assert stats.profit_factor == 0.0 class TestReturnMetrics: def test_total_return(self): balance = _make_balance([0.01] * 252) # 1% daily for a year stats = BacktestStats.from_balance(balance) # (1.01)^252 - 1 ~ 11.28 assert stats.total_return > 10.0 def test_zero_return(self): balance = _make_balance([0.0] * 10) stats = BacktestStats.from_balance(balance) assert abs(stats.total_return) < 1e-10 def test_sharpe_positive(self): # Use varying positive returns so std > 0 rng = np.random.RandomState(42) returns = list(rng.normal(0.001, 0.01, 252)) balance = _make_balance(returns) stats = BacktestStats.from_balance(balance) assert stats.sharpe_ratio > 0 class TestDrawdown: def test_max_drawdown(self): # Go up, then crash, then recover returns = [0.10, 0.10, -0.30, -0.20, 0.10, 0.10] balance = _make_balance(returns) stats = BacktestStats.from_balance(balance) assert stats.max_drawdown > 0 def test_no_drawdown(self): returns = [0.01] * 10 balance = _make_balance(returns) stats = BacktestStats.from_balance(balance) assert stats.max_drawdown == 0.0 def test_drawdown_duration(self): # Drop then flat then recover returns = [0.10, -0.20, -0.01, -0.01, 0.30] balance = _make_balance(returns) stats = BacktestStats.from_balance(balance) assert stats.max_drawdown_duration >= 2 class TestTradeStats: def test_wins_losses_count(self): trade_pnls = np.array([100, -50, 200, -30, 150]) balance = _make_balance([0.01] * 10) stats = BacktestStats.from_balance(balance, trade_pnls) assert stats.wins == 3 assert stats.losses == 2 assert stats.total_trades == 5 def test_win_pct(self): trade_pnls = np.array([100, -50, 200, -30, 150]) balance = _make_balance([0.01] * 10) stats = BacktestStats.from_balance(balance, trade_pnls) assert abs(stats.win_pct - 60.0) < 1e-10 def test_empty_balance(self): balance = pd.DataFrame() stats = BacktestStats.from_balance(balance) assert stats.total_trades == 0 assert stats.total_return == 0.0 def test_no_trade_pnls(self): balance = _make_balance([0.01] * 10) stats = BacktestStats.from_balance(balance) assert stats.total_trades == 0 assert stats.total_return > 0 class TestToDataframe: def test_shape(self): trade_pnls = np.array([100, -50]) balance = _make_balance([0.01] * 10) stats = BacktestStats.from_balance(balance, trade_pnls) df = stats.to_dataframe() assert df.shape[0] >= 30 # expanded stats (period, lookback, portfolio) assert df.shape[1] == 1 def test_summary_string(self): balance = _make_balance([0.01] * 10) stats = BacktestStats.from_balance(balance) s = stats.summary() assert "Sharpe" in s assert "Max Drawdown" in s class TestPeriodStats: def test_daily_stats_computed(self): rng = np.random.RandomState(42) returns = list(rng.normal(0.001, 0.01, 252)) balance = _make_balance(returns) stats = BacktestStats.from_balance(balance) assert stats.daily.mean != 0 assert stats.daily.vol != 0 assert stats.daily.sharpe != 0 assert stats.daily.best > 0 assert stats.daily.worst < 0 def test_monthly_stats_computed(self): rng = np.random.RandomState(42) returns = list(rng.normal(0.001, 0.01, 504)) # 2 years balance = _make_balance(returns) stats = BacktestStats.from_balance(balance) assert stats.monthly.mean != 0 assert stats.monthly.vol != 0 assert stats.monthly.sharpe != 0 def test_yearly_stats_computed(self): rng = np.random.RandomState(42) returns = list(rng.normal(0.001, 0.01, 756)) # 3 years balance = _make_balance(returns) stats = BacktestStats.from_balance(balance) assert stats.yearly.mean != 0 assert stats.yearly.best > 0 assert stats.yearly.worst != 0 def test_skew_kurtosis_with_enough_data(self): rng = np.random.RandomState(42) returns = list(rng.normal(0.001, 0.01, 252)) balance = _make_balance(returns) stats = BacktestStats.from_balance(balance) assert stats.daily.skew != 0 assert stats.daily.kurtosis != 0 def test_skew_kurtosis_not_computed_with_few_points(self): # With only 3 data points, skew/kurtosis should be 0 balance = _make_balance([0.01, 0.02, -0.01]) stats = BacktestStats.from_balance(balance) assert stats.daily.skew == 0 # not enough data (< 8) class TestAvgDrawdown: def test_avg_drawdown_depth(self): returns = [0.10, -0.15, -0.05, 0.30, 0.05, -0.10, 0.20] balance = _make_balance(returns) stats = BacktestStats.from_balance(balance) assert stats.avg_drawdown > 0 assert stats.avg_drawdown <= stats.max_drawdown def test_avg_drawdown_duration(self): returns = [0.10, -0.15, -0.05, 0.30, 0.05, -0.10, 0.20] balance = _make_balance(returns) stats = BacktestStats.from_balance(balance) assert stats.avg_drawdown_duration > 0 assert stats.avg_drawdown_duration <= stats.max_drawdown_duration class TestLookbackReturns: def test_mtd_and_ytd(self): rng = np.random.RandomState(42) returns = list(rng.normal(0.001, 0.01, 504)) balance = _make_balance(returns) stats = BacktestStats.from_balance(balance) assert stats.lookback.mtd is not None assert stats.lookback.ytd is not None def test_one_year_return(self): rng = np.random.RandomState(42) returns = list(rng.normal(0.001, 0.01, 504)) balance = _make_balance(returns) stats = BacktestStats.from_balance(balance) assert stats.lookback.one_year is not None def test_lookback_table(self): rng = np.random.RandomState(42) returns = list(rng.normal(0.001, 0.01, 504)) balance = _make_balance(returns) stats = BacktestStats.from_balance(balance) table = stats.lookback_table() assert not table.empty assert "MTD" in table.columns def test_short_series_lookback_equals_total(self): returns = [0.01] * 10 balance = _make_balance(returns) stats = BacktestStats.from_balance(balance) # For periods longer than the data, lookback == total return assert stats.lookback.ten_year is not None assert abs(stats.lookback.ten_year - stats.total_return) < 1e-6 class TestTurnover: def test_turnover_zero_for_no_stocks(self): balance = _make_balance([0.01] * 10) stats = BacktestStats.from_balance(balance) assert stats.turnover == 0.0 def test_turnover_computed_with_stocks(self): rng = np.random.RandomState(42) dates = pd.date_range("2020-01-01", periods=20, freq="B") total = 100_000 + np.cumsum(rng.normal(100, 500, 20)) spy = total * 0.6 + rng.normal(0, 500, 20) balance = pd.DataFrame({ "total capital": total, "SPY": spy, "SPY qty": spy / 300, }, index=dates) balance["% change"] = balance["total capital"].pct_change() stats = BacktestStats.from_balance(balance) assert stats.turnover >= 0 class TestHerfindahl: def test_single_stock_hhi_is_one(self): dates = pd.date_range("2020-01-01", periods=10, freq="B") balance = pd.DataFrame({ "total capital": [100_000] * 10, "SPY": [100_000] * 10, "SPY qty": [300] * 10, }, index=dates) balance["% change"] = balance["total capital"].pct_change() stats = BacktestStats.from_balance(balance) assert abs(stats.herfindahl - 1.0) < 0.01 def test_two_equal_stocks_hhi(self): dates = pd.date_range("2020-01-01", periods=10, freq="B") balance = pd.DataFrame({ "total capital": [100_000] * 10, "SPY": [50_000] * 10, "SPY qty": [150] * 10, "QQQ": [50_000] * 10, "QQQ qty": [200] * 10, }, index=dates) balance["% change"] = balance["total capital"].pct_change() stats = BacktestStats.from_balance(balance) # 0.5^2 + 0.5^2 = 0.5 assert abs(stats.herfindahl - 0.5) < 0.01 class TestFromBalanceRange: def test_slice_start(self): returns = [0.01] * 20 balance = _make_balance(returns) mid_date = balance.index[10] stats = BacktestStats.from_balance_range(balance, start=str(mid_date)) # Should compute stats on roughly half the data assert stats.total_return > 0 def test_slice_end(self): returns = [0.01] * 20 balance = _make_balance(returns) mid_date = balance.index[10] stats = BacktestStats.from_balance_range(balance, end=str(mid_date)) full_stats = BacktestStats.from_balance(balance) assert stats.total_return < full_stats.total_return def test_slice_both(self): returns = [0.01] * 30 balance = _make_balance(returns) start = str(balance.index[5]) end = str(balance.index[15]) stats = BacktestStats.from_balance_range(balance, start=start, end=end) assert stats.total_return > 0 def test_empty_balance(self): balance = pd.DataFrame() stats = BacktestStats.from_balance_range(balance) assert stats.total_return == 0.0 def test_no_slice(self): returns = [0.01] * 10 balance = _make_balance(returns) stats = BacktestStats.from_balance_range(balance) full_stats = BacktestStats.from_balance(balance) assert abs(stats.total_return - full_stats.total_return) < 1e-6 ================================================ FILE: tests/analytics/test_stats_python_path.py ================================================ """Tests for BacktestStats.from_balance — covers the Rust compute_full_stats path including period stats, lookback, turnover, herfindahl, trade stats, summary text with monthly/turnover branches, and lookback_table.""" import numpy as np import pandas as pd import pytest from options_portfolio_backtester.analytics.stats import ( BacktestStats, PeriodStats, LookbackReturns, ) def _make_balance(returns, initial=100_000.0, start="2020-01-01"): dates = pd.date_range(start, periods=len(returns) + 1, freq="B") capital = [initial] for r in returns: capital.append(capital[-1] * (1 + r)) df = pd.DataFrame({"total capital": capital}, index=dates) df["% change"] = df["total capital"].pct_change() return df def _make_balance_with_stocks(returns, initial=100_000.0, start="2020-01-01"): """Balance with stock columns for turnover/herfindahl tests.""" df = _make_balance(returns, initial, start) n = len(df) df["SPY"] = np.linspace(60000, 70000, n) df["SPY qty"] = 200 df["IWM"] = np.linspace(30000, 25000, n) df["IWM qty"] = 150 return df class TestFromBalanceReturnMetrics: def test_total_return(self): rets = [0.01] * 50 bal = _make_balance(rets) s = BacktestStats.from_balance(bal) expected = (1.01 ** 50) - 1 assert abs(s.total_return - expected) < 1e-6 def test_annualized_return(self): rets = [0.001] * 252 bal = _make_balance(rets) s = BacktestStats.from_balance(bal) assert s.annualized_return > 0 def test_volatility(self): rets = [0.01, -0.01] * 50 bal = _make_balance(rets) s = BacktestStats.from_balance(bal) assert s.volatility > 0 def test_sharpe_and_sortino(self): rng = np.random.default_rng(42) rets = (rng.normal(0.005, 0.01, 100)).tolist() bal = _make_balance(rets) s = BacktestStats.from_balance(bal) assert s.sharpe_ratio != 0 assert s.sortino_ratio != 0 class TestFromBalanceDrawdown: def test_drawdown_with_losses(self): rets = [0.01] * 10 + [-0.05] * 5 + [0.01] * 10 bal = _make_balance(rets) s = BacktestStats.from_balance(bal) assert s.max_drawdown > 0 assert s.max_drawdown_duration > 0 def test_avg_drawdown(self): rets = [0.02] * 5 + [-0.03] * 3 + [0.02] * 5 + [-0.02] * 2 + [0.02] * 5 bal = _make_balance(rets) s = BacktestStats.from_balance(bal) assert s.avg_drawdown > 0 assert s.avg_drawdown_duration > 0 def test_calmar_ratio(self): rets = [0.01] * 10 + [-0.03] + [0.01] * 10 bal = _make_balance(rets) s = BacktestStats.from_balance(bal) assert s.calmar_ratio != 0 def test_no_drawdown(self): rets = [0.01] * 20 bal = _make_balance(rets) s = BacktestStats.from_balance(bal) assert s.max_drawdown == 0.0 assert s.calmar_ratio == 0.0 class TestFromBalanceTailRatio: def test_tail_ratio_enough_data(self): rng = np.random.default_rng(42) rets = rng.normal(0.001, 0.02, 100).tolist() bal = _make_balance(rets) s = BacktestStats.from_balance(bal) assert s.tail_ratio > 0 def test_tail_ratio_insufficient_data(self): rets = [0.01] * 10 bal = _make_balance(rets) s = BacktestStats.from_balance(bal) assert s.tail_ratio == 0.0 class TestFromBalancePeriodStats: def test_daily_stats(self): rng = np.random.default_rng(99) rets = rng.normal(0.005, 0.01, 50).tolist() bal = _make_balance(rets) s = BacktestStats.from_balance(bal) assert s.daily.mean != 0 assert s.daily.vol > 0 assert s.daily.best > 0 assert s.daily.worst < s.daily.best def test_skew_kurtosis_need_8_returns(self): rets = [0.01] * 3 bal = _make_balance(rets) s = BacktestStats.from_balance(bal) assert s.daily.skew == 0.0 assert s.daily.kurtosis == 0.0 def test_skew_kurtosis_with_enough_data(self): rng = np.random.default_rng(42) rets = rng.normal(0.001, 0.02, 30).tolist() bal = _make_balance(rets) s = BacktestStats.from_balance(bal) # skew and kurtosis are non-zero with random data assert s.daily.skew != 0.0 or s.daily.kurtosis != 0.0 class TestFromBalanceLookback: def test_lookback_mtd_ytd(self): rets = [0.002] * 100 bal = _make_balance(rets) s = BacktestStats.from_balance(bal) assert s.lookback.mtd is not None assert s.lookback.ytd is not None def test_lookback_trailing_periods(self): # 2 years of data rets = [0.001] * 504 bal = _make_balance(rets, start="2018-06-01") s = BacktestStats.from_balance(bal) assert s.lookback.three_month is not None assert s.lookback.six_month is not None assert s.lookback.one_year is not None class TestFromBalanceTurnoverHerfindahl: def test_turnover_with_stocks(self): rets = [0.001] * 50 bal = _make_balance_with_stocks(rets) s = BacktestStats.from_balance(bal) assert s.turnover >= 0.0 def test_herfindahl_with_stocks(self): rets = [0.001] * 50 bal = _make_balance_with_stocks(rets) s = BacktestStats.from_balance(bal) assert s.herfindahl > 0.0 def test_turnover_no_stocks(self): rets = [0.001] * 10 bal = _make_balance(rets) s = BacktestStats.from_balance(bal) assert s.turnover == 0.0 def test_herfindahl_no_stocks(self): rets = [0.001] * 10 bal = _make_balance(rets) s = BacktestStats.from_balance(bal) assert s.herfindahl == 0.0 class TestFromBalanceTradeStats: def test_trade_stats_full(self): rets = [0.01] * 10 bal = _make_balance(rets) pnls = np.array([100.0, 200.0, -50.0, -30.0, 150.0]) s = BacktestStats.from_balance(bal, trade_pnls=pnls) assert s.total_trades == 5 assert s.wins == 3 assert s.losses == 2 assert s.win_pct == pytest.approx(60.0) assert s.largest_win == 200.0 assert s.largest_loss == -50.0 assert s.avg_win > 0 assert s.avg_loss < 0 assert s.avg_trade > 0 def test_trade_stats_all_wins(self): rets = [0.01] * 10 bal = _make_balance(rets) pnls = np.array([100.0, 200.0]) s = BacktestStats.from_balance(bal, trade_pnls=pnls) assert s.profit_factor == float("inf") def test_trade_stats_all_losses(self): rets = [0.01] * 10 bal = _make_balance(rets) pnls = np.array([-100.0, -200.0]) s = BacktestStats.from_balance(bal, trade_pnls=pnls) assert s.profit_factor == 0.0 assert s.largest_win == 0 assert s.avg_win == 0 def test_trade_stats_none(self): rets = [0.01] * 10 bal = _make_balance(rets) s = BacktestStats.from_balance(bal, trade_pnls=None) assert s.total_trades == 0 class TestSummaryText: def test_summary_minimal(self): rets = [0.01] * 5 bal = _make_balance(rets) s = BacktestStats.from_balance(bal) text = s.summary() assert "Total Return" in text def test_summary_with_turnover(self): rets = [0.001] * 50 bal = _make_balance_with_stocks(rets) s = BacktestStats.from_balance(bal) text = s.summary() assert "Turnover" in text class TestLookbackTable: def test_lookback_table_nonempty(self): rets = [0.002] * 100 bal = _make_balance(rets) s = BacktestStats.from_balance(bal) tbl = s.lookback_table() assert not tbl.empty assert "MTD" in tbl.columns def test_lookback_table_empty_when_no_data(self): s = BacktestStats() tbl = s.lookback_table() assert tbl.empty class TestToDataframe: def test_has_expected_rows(self): rets = [0.002] * 60 bal = _make_balance(rets) s = BacktestStats.from_balance(bal) df = s.to_dataframe() assert "Total return" in df.index assert "Sharpe ratio" in df.index assert "Herfindahl index" in df.index class TestFromBalanceSharpe: def test_positive_returns_with_variance(self): rng = np.random.default_rng(42) rets = rng.normal(0.01, 0.005, 50).tolist() bal = _make_balance(rets) s = BacktestStats.from_balance(bal) assert s.sharpe_ratio > 0 def test_all_positive_sortino_zero(self): rets = [0.01] * 50 bal = _make_balance(rets) s = BacktestStats.from_balance(bal) # No downside returns -> sortino should be 0 assert s.sortino_ratio == 0.0 def test_mixed_returns_sortino_nonzero(self): rets = ([0.02, -0.01, 0.015, -0.005] * 10) bal = _make_balance(rets) s = BacktestStats.from_balance(bal) assert s.sortino_ratio != 0.0 class TestFromBalanceDispatch: """Test the from_balance classmethod.""" def test_from_balance_empty(self): bal = pd.DataFrame(columns=["total capital", "% change"]) s = BacktestStats.from_balance(bal) assert s.total_return == 0.0 def test_from_balance_basic(self): rets = [0.01] * 20 bal = _make_balance(rets) s = BacktestStats.from_balance(bal) assert s.total_return > 0 assert s.annualized_return > 0 def test_from_balance_with_trade_pnls(self): rets = [0.01] * 20 bal = _make_balance(rets) pnls = np.array([100.0, -50.0, 200.0]) s = BacktestStats.from_balance(bal, trade_pnls=pnls) assert s.total_trades == 3 class TestFromBalanceRange: """Test the from_balance_range classmethod.""" def test_empty_balance(self): bal = pd.DataFrame(columns=["total capital"]) s = BacktestStats.from_balance_range(bal) assert s.total_return == 0.0 def test_full_range(self): rets = [0.01] * 30 bal = _make_balance(rets) s = BacktestStats.from_balance_range(bal) assert s.total_return > 0 def test_with_start(self): rets = [0.01] * 30 bal = _make_balance(rets, start="2020-01-01") # Slice from 10 business days in s = BacktestStats.from_balance_range(bal, start="2020-01-15") assert s.total_return > 0 def test_with_end(self): rets = [0.01] * 30 bal = _make_balance(rets, start="2020-01-01") s = BacktestStats.from_balance_range(bal, end="2020-01-15") assert s.total_return > 0 def test_with_start_and_end(self): rets = [0.01] * 30 bal = _make_balance(rets, start="2020-01-01") s = BacktestStats.from_balance_range(bal, start="2020-01-10", end="2020-01-20") assert s.total_return > 0 def test_out_of_range_returns_empty(self): rets = [0.01] * 10 bal = _make_balance(rets, start="2020-01-01") s = BacktestStats.from_balance_range(bal, start="2025-01-01") assert s.total_return == 0.0 ================================================ FILE: tests/analytics/test_summary.py ================================================ """Tests for analytics/summary.py — the legacy summary statistics function.""" import numpy as np import pandas as pd from options_portfolio_backtester.core.types import Order from options_portfolio_backtester.analytics.summary import summary def _make_trade_log_and_balance(): """Build a minimal MultiIndex trade log and balance for testing summary().""" leg = "leg_1" entries = pd.DataFrame({ (leg, "contract"): ["SPY_C_001", "SPY_C_002"], (leg, "underlying"): ["SPY", "SPY"], (leg, "expiration"): pd.to_datetime(["2020-03-20", "2020-03-20"]), (leg, "type"): ["call", "call"], (leg, "strike"): [320.0, 325.0], (leg, "cost"): [-500.0, -400.0], (leg, "order"): [Order.BTO, Order.BTO], ("totals", "cost"): [-500.0, -400.0], ("totals", "qty"): [2, 3], ("totals", "date"): pd.to_datetime(["2020-01-15", "2020-01-20"]), }) exits = pd.DataFrame({ (leg, "contract"): ["SPY_C_001", "SPY_C_002"], (leg, "underlying"): ["SPY", "SPY"], (leg, "expiration"): pd.to_datetime(["2020-03-20", "2020-03-20"]), (leg, "type"): ["call", "call"], (leg, "strike"): [320.0, 325.0], (leg, "cost"): [600.0, 350.0], (leg, "order"): [Order.STC, Order.STC], ("totals", "cost"): [600.0, 350.0], ("totals", "qty"): [2, 3], ("totals", "date"): pd.to_datetime(["2020-02-15", "2020-02-20"]), }) entries.columns = pd.MultiIndex.from_tuples(entries.columns) exits.columns = pd.MultiIndex.from_tuples(exits.columns) trade_log = pd.concat([entries, exits], ignore_index=True) dates = pd.date_range("2020-01-10", periods=30, freq="B") capital = np.linspace(1_000_000, 1_050_000, 30) balance = pd.DataFrame({"total capital": capital}, index=dates) balance["% change"] = balance["total capital"].pct_change() return trade_log, balance class TestSummary: def test_returns_styler(self): trade_log, balance = _make_trade_log_and_balance() result = summary(trade_log, balance) assert isinstance(result, pd.io.formats.style.Styler) def test_summary_has_expected_rows(self): trade_log, balance = _make_trade_log_and_balance() result = summary(trade_log, balance) df = result.data assert "Total trades" in df.index assert "Win %" in df.index assert "Average P&L %" in df.index assert "Total P&L %" in df.index def test_total_trades_count(self): trade_log, balance = _make_trade_log_and_balance() result = summary(trade_log, balance) df = result.data total_trades = df.loc["Total trades", "Strategy"] assert total_trades == 2 def test_win_metrics(self): trade_log, balance = _make_trade_log_and_balance() result = summary(trade_log, balance) df = result.data wins = df.loc["Number of wins", "Strategy"] losses = df.loc["Number of losses", "Strategy"] assert wins + losses == df.loc["Total trades", "Strategy"] def test_summary_with_missing_exit(self): """When an exit is missing for a contract, IndexError branch is hit.""" leg = "leg_1" # Entry for contract A, but no matching exit entries = pd.DataFrame({ (leg, "contract"): ["SPY_C_ORPHAN"], (leg, "underlying"): ["SPY"], (leg, "expiration"): pd.to_datetime(["2020-03-20"]), (leg, "type"): ["call"], (leg, "strike"): [320.0], (leg, "cost"): [-500.0], (leg, "order"): [Order.BTO], ("totals", "cost"): [-500.0], ("totals", "qty"): [2], ("totals", "date"): pd.to_datetime(["2020-01-15"]), }) # Exit for a different contract exits = pd.DataFrame({ (leg, "contract"): ["SPY_C_OTHER"], (leg, "underlying"): ["SPY"], (leg, "expiration"): pd.to_datetime(["2020-03-20"]), (leg, "type"): ["call"], (leg, "strike"): [325.0], (leg, "cost"): [600.0], (leg, "order"): [Order.STC], ("totals", "cost"): [600.0], ("totals", "qty"): [2], ("totals", "date"): pd.to_datetime(["2020-02-15"]), }) entries.columns = pd.MultiIndex.from_tuples(entries.columns) exits.columns = pd.MultiIndex.from_tuples(exits.columns) trade_log = pd.concat([entries, exits], ignore_index=True) dates = pd.date_range("2020-01-10", periods=10, freq="B") balance = pd.DataFrame({ "total capital": np.linspace(1_000_000, 1_010_000, 10), }, index=dates) balance["% change"] = balance["total capital"].pct_change() result = summary(trade_log, balance) df = result.data # Should still produce output — the orphan contract is skipped assert "Total trades" in df.index ================================================ FILE: tests/analytics/test_tearsheet.py ================================================ from __future__ import annotations from unittest.mock import patch from pathlib import Path import numpy as np import pandas as pd from options_portfolio_backtester.analytics.tearsheet import ( build_tearsheet, drawdown_series, monthly_return_table, ) def _balance(periods: int = 40) -> pd.DataFrame: idx = pd.date_range("2024-01-01", periods=periods, freq="B") total = [100_000.0] for i in range(1, len(idx)): total.append(total[-1] * (1.0 + (0.001 if i % 3 else -0.0005))) bal = pd.DataFrame({"total capital": total}, index=idx) bal["% change"] = bal["total capital"].pct_change() return bal # --------------------------------------------------------------------------- # build_tearsheet # --------------------------------------------------------------------------- def test_build_tearsheet_has_expected_artifacts(): report = build_tearsheet(_balance(), trade_pnls=[100.0, -50.0, 70.0]) assert report.stats.total_trades == 3 assert not report.stats_table.empty assert "Value" in report.stats_table.columns assert isinstance(report.monthly_returns, pd.DataFrame) assert isinstance(report.drawdown_series, pd.Series) def test_build_tearsheet_no_trades(): report = build_tearsheet(_balance()) assert report.stats.total_trades == 0 def test_build_tearsheet_with_risk_free_rate(): report = build_tearsheet(_balance(), risk_free_rate=0.04) assert report.stats is not None # --------------------------------------------------------------------------- # to_dict # --------------------------------------------------------------------------- def test_tearsheet_to_dict_shape(): report = build_tearsheet(_balance()) d = report.to_dict() assert "stats" in d assert "stats_table" in d assert "monthly_returns" in d assert "drawdown_series" in d # --------------------------------------------------------------------------- # Exports: CSV, HTML, Markdown # --------------------------------------------------------------------------- def test_tearsheet_exports(tmp_path: Path): report = build_tearsheet(_balance()) files = report.to_csv(tmp_path) assert files["stats_table"].exists() assert files["monthly_returns"].exists() assert files["drawdown_series"].exists() assert "" in report.to_html() assert "# Tearsheet" in report.to_markdown() def test_csv_creates_directories(tmp_path: Path): nested = tmp_path / "a" / "b" / "c" report = build_tearsheet(_balance()) files = report.to_csv(nested) assert nested.exists() assert files["stats_table"].exists() def test_html_contains_tables(): report = build_tearsheet(_balance()) html = report.to_html() assert "stats-table" in html assert "monthly-returns" in html or "No monthly returns" in html # --------------------------------------------------------------------------- # to_markdown fallback (item 15) # --------------------------------------------------------------------------- def test_tearsheet_markdown_fallback_without_tabulate(): report = build_tearsheet(_balance()) # Patch to_markdown to raise so the except fallback fires with patch.object(pd.DataFrame, "to_markdown", side_effect=ImportError("no tabulate")): md = report.to_markdown() assert "# Tearsheet" in md assert "Summary" in md # --------------------------------------------------------------------------- # monthly_return_table # --------------------------------------------------------------------------- def test_monthly_return_table_has_year_month_structure(): bal = _balance(periods=120) # ~6 months of business days tbl = monthly_return_table(bal) if not tbl.empty: assert tbl.index.name == "year" assert all(isinstance(c, int) for c in tbl.columns) def test_monthly_return_table_empty_balance(): empty = pd.DataFrame(columns=["total capital", "% change"]) assert monthly_return_table(empty).empty def test_monthly_return_table_no_pct_change(): bal = pd.DataFrame({"total capital": [100, 101]}, index=pd.date_range("2024-01-01", periods=2)) assert monthly_return_table(bal).empty # --------------------------------------------------------------------------- # drawdown_series # --------------------------------------------------------------------------- def test_drawdown_series_shape(): bal = _balance() dd = drawdown_series(bal) assert len(dd) == len(bal) assert (dd <= 0).all() # drawdowns are always <= 0 def test_drawdown_series_peak_at_start(): idx = pd.date_range("2024-01-01", periods=5, freq="B") bal = pd.DataFrame({"total capital": [100, 90, 80, 85, 95]}, index=idx) dd = drawdown_series(bal) assert dd.iloc[0] == 0.0 # first point is always 0 assert dd.iloc[2] == -0.2 # 80/100 - 1 = -0.2 def test_drawdown_series_empty(): empty = pd.DataFrame(columns=["total capital"]) dd = drawdown_series(empty) assert dd.empty def test_drawdown_series_no_total_capital(): bad = pd.DataFrame({"other": [1, 2]}, index=pd.date_range("2024-01-01", periods=2)) dd = drawdown_series(bad) assert dd.empty # --------------------------------------------------------------------------- # Edge cases # --------------------------------------------------------------------------- def test_build_tearsheet_single_day(): bal = pd.DataFrame( {"total capital": [100_000.0], "% change": [np.nan]}, index=pd.date_range("2024-01-01", periods=1), ) report = build_tearsheet(bal) assert report.monthly_returns.empty or not report.monthly_returns.empty assert isinstance(report.drawdown_series, pd.Series) def test_build_tearsheet_flat_returns(): idx = pd.date_range("2024-01-01", periods=20, freq="B") bal = pd.DataFrame({"total capital": [100_000.0] * 20}, index=idx) bal["% change"] = bal["total capital"].pct_change() report = build_tearsheet(bal) dd = report.drawdown_series # No drawdown for flat returns assert (dd.dropna() == 0).all() ================================================ FILE: tests/analytics/test_trade_log.py ================================================ """Tests for structured TradeLog.""" import numpy as np import pandas as pd from options_portfolio_backtester.analytics.trade_log import Trade, TradeLog from options_portfolio_backtester.core.types import Order def _make_trade(pnl_sign: float = 1.0) -> Trade: return Trade( contract="SPY_C_500", underlying="SPY", option_type="call", strike=500.0, entry_date=pd.Timestamp("2024-01-01"), exit_date=pd.Timestamp("2024-02-01"), entry_price=5.0, exit_price=5.0 + pnl_sign * 2.0, quantity=10, shares_per_contract=100, entry_order=Order.BTO, exit_order=Order.STC, ) class TestTrade: def test_gross_pnl(self): t = _make_trade(pnl_sign=1.0) # (7-5) * 10 * 100 = 2000 assert t.gross_pnl == 2000.0 def test_gross_pnl_loss(self): t = _make_trade(pnl_sign=-1.0) # (3-5) * 10 * 100 = -2000 assert t.gross_pnl == -2000.0 def test_net_pnl_with_commission(self): t = Trade( contract="X", underlying="SPY", option_type="call", strike=500.0, entry_date="2024-01-01", exit_date="2024-02-01", entry_price=5.0, exit_price=7.0, quantity=10, shares_per_contract=100, entry_order=Order.BTO, exit_order=Order.STC, entry_commission=6.50, exit_commission=6.50, ) assert t.net_pnl == 2000.0 - 13.0 def test_return_pct(self): t = _make_trade(pnl_sign=1.0) # gross=2000, entry_cost=5*10*100=5000, return=40% assert abs(t.return_pct - 0.40) < 1e-10 class TestTradeLog: def test_add_and_len(self): tl = TradeLog() tl.add_trade(_make_trade(1.0)) tl.add_trade(_make_trade(-1.0)) assert len(tl) == 2 def test_winners_losers(self): tl = TradeLog() tl.add_trade(_make_trade(1.0)) tl.add_trade(_make_trade(-1.0)) tl.add_trade(_make_trade(1.0)) assert len(tl.winners) == 2 assert len(tl.losers) == 1 def test_net_pnls(self): tl = TradeLog() tl.add_trade(_make_trade(1.0)) tl.add_trade(_make_trade(-1.0)) pnls = tl.net_pnls assert len(pnls) == 2 assert pnls[0] == 2000.0 assert pnls[1] == -2000.0 def test_to_dataframe(self): tl = TradeLog() tl.add_trade(_make_trade(1.0)) df = tl.to_dataframe() assert "net_pnl" in df.columns assert "return_pct" in df.columns assert len(df) == 1 def test_empty_to_dataframe(self): tl = TradeLog() df = tl.to_dataframe() assert len(df) == 0 def test_from_legacy_empty(self): tl = TradeLog.from_legacy_trade_log(pd.DataFrame()) assert len(tl) == 0 def test_from_legacy_trade_log(self): """Build a MultiIndex trade log and verify round-trip parsing.""" leg = "leg_1" entries = pd.DataFrame({ (leg, "contract"): ["SPY_C_001"], (leg, "underlying"): ["SPY"], (leg, "expiration"): pd.to_datetime(["2024-03-15"]), (leg, "type"): ["call"], (leg, "strike"): [450.0], (leg, "cost"): [-500.0], (leg, "order"): [Order.BTO], ("totals", "cost"): [-500.0], ("totals", "qty"): [5], ("totals", "date"): pd.to_datetime(["2024-01-15"]), }) exits = pd.DataFrame({ (leg, "contract"): ["SPY_C_001"], (leg, "underlying"): ["SPY"], (leg, "expiration"): pd.to_datetime(["2024-03-15"]), (leg, "type"): ["call"], (leg, "strike"): [450.0], (leg, "cost"): [600.0], (leg, "order"): [Order.STC], ("totals", "cost"): [600.0], ("totals", "qty"): [5], ("totals", "date"): pd.to_datetime(["2024-02-15"]), }) entries.columns = pd.MultiIndex.from_tuples(entries.columns) exits.columns = pd.MultiIndex.from_tuples(exits.columns) trade_log_df = pd.concat([entries, exits], ignore_index=True) tl = TradeLog.from_legacy_trade_log(trade_log_df) assert len(tl) == 1 assert tl.trades[0].contract == "SPY_C_001" assert tl.trades[0].quantity == 5 def test_return_pct_zero_entry(self): t = Trade( contract="X", underlying="SPY", option_type="call", strike=500.0, entry_date="2024-01-01", exit_date="2024-02-01", entry_price=0.0, exit_price=1.0, quantity=10, shares_per_contract=100, entry_order=Order.BTO, exit_order=Order.STC, ) assert t.return_pct == 0.0 ================================================ FILE: tests/bench/__init__.py ================================================ ================================================ FILE: tests/bench/_test_helpers.py ================================================ """Shared helpers for bench regression tests.""" from __future__ import annotations import os import numpy as np import pandas as pd import pytest try: from options_portfolio_backtester._ob_rust import run_backtest_py # noqa: F401 RUST_AVAILABLE = True except ImportError: RUST_AVAILABLE = False _TEST_DIR = os.path.join(os.path.dirname(__file__), "..", "test_data") _DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data") # ── Constants ────────────────────────────────────────────────────────── DEFAULT_ALLOC = {"stocks": 0.6, "options": 0.3, "cash": 0.1} DEFAULT_CAPITAL = 1_000_000 IVY_STOCKS_TUPLES = [ ("VTI", 0.2), ("VEU", 0.2), ("BND", 0.2), ("VNQ", 0.2), ("DBC", 0.2), ] GENERATED_STOCKS_TUPLES = [ ("VOO", 0.20), ("TLT", 0.20), ("EWY", 0.15), ("PDBC", 0.15), ("IAU", 0.10), ("VNQI", 0.10), ("VTIP", 0.10), ] PROD_SPY_STOCKS_TUPLES = [("SPY", 1.0)] PROD_SLICES = { "spy_crisis": {"stocks_tuples": [("SPY", 1.0)], "underlying": "SPY"}, "spy_lowvol": {"stocks_tuples": [("SPY", 1.0)], "underlying": "SPY"}, "spy_covid": {"stocks_tuples": [("SPY", 1.0)], "underlying": "SPY"}, "spy_bear": {"stocks_tuples": [("SPY", 1.0)], "underlying": "SPY"}, "iwm_2020": {"stocks_tuples": [("IWM", 1.0)], "underlying": "IWM"}, "qqq_2020": {"stocks_tuples": [("QQQ", 1.0)], "underlying": "QQQ"}, } STRATEGY_MAP: dict = {} # populated after strategy builder definitions # ── Stock lists ──────────────────────────────────────────────────────── def _stocks_from_tuples(tuples): from options_portfolio_backtester.core.types import Stock return [Stock(sym, pct) for sym, pct in tuples] def ivy_stocks(): return _stocks_from_tuples(IVY_STOCKS_TUPLES) def generated_stocks(): return _stocks_from_tuples(GENERATED_STOCKS_TUPLES) def prod_spy_stocks(): return _stocks_from_tuples(PROD_SPY_STOCKS_TUPLES) def slice_stocks(slice_id): return _stocks_from_tuples(PROD_SLICES[slice_id]["stocks_tuples"]) # ── Data loaders ─────────────────────────────────────────────────────── def load_small_stocks(): from options_portfolio_backtester.data.providers import TiingoData s = TiingoData(os.path.join(_TEST_DIR, "ivy_5assets_data.csv")) s._data["adjClose"] = 10 return s def load_small_options(): from options_portfolio_backtester.data.providers import HistoricalOptionsData o = HistoricalOptionsData(os.path.join(_TEST_DIR, "options_data.csv")) o._data.at[2, "ask"] = 1 o._data.at[2, "bid"] = 0.5 o._data.at[51, "ask"] = 1.5 o._data.at[50, "bid"] = 0.5 o._data.at[130, "bid"] = 0.5 o._data.at[131, "bid"] = 1.5 o._data.at[206, "bid"] = 0.5 o._data.at[207, "bid"] = 1.5 return o def load_large_stocks(): from options_portfolio_backtester.data.providers import TiingoData return TiingoData(os.path.join(_TEST_DIR, "test_data_stocks.csv")) def load_large_options(): from options_portfolio_backtester.data.providers import HistoricalOptionsData return HistoricalOptionsData(os.path.join(_TEST_DIR, "test_data_options.csv")) def load_generated_stocks(): from options_portfolio_backtester.data.providers import TiingoData return TiingoData(os.path.join(_DATA_DIR, "large_stocks.csv")) def load_generated_options(): from options_portfolio_backtester.data.providers import HistoricalOptionsData return HistoricalOptionsData(os.path.join(_DATA_DIR, "large_options.csv")) def load_prod_stocks(): from options_portfolio_backtester.data.providers import TiingoData return TiingoData(os.path.join(_DATA_DIR, "prod_stocks_1y.csv")) def load_prod_options(): from options_portfolio_backtester.data.providers import HistoricalOptionsData return HistoricalOptionsData(os.path.join(_DATA_DIR, "prod_options_1y.csv")) def slice_data_exists(slice_id): return ( os.path.isfile(os.path.join(_DATA_DIR, f"{slice_id}_stocks.csv")) and os.path.isfile(os.path.join(_DATA_DIR, f"{slice_id}_options.csv")) ) def load_slice_stocks(slice_id): from options_portfolio_backtester.data.providers import TiingoData return TiingoData(os.path.join(_DATA_DIR, f"{slice_id}_stocks.csv")) def load_slice_options(slice_id): from options_portfolio_backtester.data.providers import HistoricalOptionsData return HistoricalOptionsData( os.path.join(_DATA_DIR, f"{slice_id}_options.csv") ) # ── Strategy builders ───────────────────────────────────────────────── def buy_put_strategy(schema, underlying="SPX", dte_min=60, dte_max=None, dte_exit=30): from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.core.types import OptionType as Type, Direction strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) filt = (schema.underlying == underlying) & (schema.dte >= dte_min) if dte_max is not None: filt = filt & (schema.dte <= dte_max) leg.entry_filter = filt leg.exit_filter = schema.dte <= dte_exit strat.add_legs([leg]) return strat def buy_call_strategy(schema, underlying="SPX", dte_min=60, dte_exit=30): from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.core.types import OptionType as Type, Direction strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.CALL, direction=Direction.BUY) leg.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min) leg.exit_filter = schema.dte <= dte_exit strat.add_legs([leg]) return strat def sell_put_strategy(schema, underlying="SPX", dte_min=60, dte_exit=30): from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.core.types import OptionType as Type, Direction strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.SELL) leg.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min) leg.exit_filter = schema.dte <= dte_exit strat.add_legs([leg]) return strat def sell_call_strategy(schema, underlying="SPX", dte_min=60, dte_exit=30): from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.core.types import OptionType as Type, Direction strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.CALL, direction=Direction.SELL) leg.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min) leg.exit_filter = schema.dte <= dte_exit strat.add_legs([leg]) return strat def strangle_strategy(schema, underlying="SPX", dte_min=60, dte_exit=30): from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.core.types import OptionType as Type, Direction strat = Strategy(schema) leg1 = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.SELL) leg1.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min) leg1.exit_filter = schema.dte <= dte_exit leg2 = StrategyLeg("leg_2", schema, option_type=Type.CALL, direction=Direction.SELL) leg2.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min) leg2.exit_filter = schema.dte <= dte_exit strat.add_legs([leg1, leg2]) return strat def straddle_strategy(schema, underlying="SPX", dte_min=60, dte_exit=30): from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.core.types import OptionType as Type, Direction strat = Strategy(schema) leg1 = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) leg1.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min) leg1.exit_filter = schema.dte <= dte_exit leg2 = StrategyLeg("leg_2", schema, option_type=Type.CALL, direction=Direction.BUY) leg2.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min) leg2.exit_filter = schema.dte <= dte_exit strat.add_legs([leg1, leg2]) return strat def buy_put_spread_strategy(schema, underlying="SPX", dte_min=60, dte_exit=30): from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.core.types import OptionType as Type, Direction strat = Strategy(schema) leg1 = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) leg1.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min) leg1.exit_filter = schema.dte <= dte_exit leg2 = StrategyLeg("leg_2", schema, option_type=Type.PUT, direction=Direction.SELL) leg2.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min) leg2.exit_filter = schema.dte <= dte_exit strat.add_legs([leg1, leg2]) return strat def sell_call_spread_strategy(schema, underlying="SPX", dte_min=60, dte_exit=30): from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.core.types import OptionType as Type, Direction strat = Strategy(schema) leg1 = StrategyLeg("leg_1", schema, option_type=Type.CALL, direction=Direction.SELL) leg1.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min) leg1.exit_filter = schema.dte <= dte_exit leg2 = StrategyLeg("leg_2", schema, option_type=Type.CALL, direction=Direction.BUY) leg2.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min) leg2.exit_filter = schema.dte <= dte_exit strat.add_legs([leg1, leg2]) return strat def two_leg_strategy(schema, dir1, type1, dir2, type2, underlying="SPX", dte_min=60, dte_exit=30): from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.core.types import OptionType as Type, Direction type_map = {"put": Type.PUT, "call": Type.CALL} dir_map = {"buy": Direction.BUY, "sell": Direction.SELL} strat = Strategy(schema) leg1 = StrategyLeg("leg_1", schema, option_type=type_map[type1], direction=dir_map[dir1]) leg1.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min) leg1.exit_filter = schema.dte <= dte_exit leg2 = StrategyLeg("leg_2", schema, option_type=type_map[type2], direction=dir_map[dir2]) leg2.entry_filter = (schema.underlying == underlying) & (schema.dte >= dte_min) leg2.exit_filter = schema.dte <= dte_exit strat.add_legs([leg1, leg2]) return strat # Populate STRATEGY_MAP after definitions STRATEGY_MAP.update({ "buy_put": buy_put_strategy, "buy_call": buy_call_strategy, "sell_put": sell_put_strategy, "sell_call": sell_call_strategy, "buy_put_spread": buy_put_spread_strategy, "sell_call_spread": sell_call_spread_strategy, "strangle": strangle_strategy, "straddle": straddle_strategy, }) # ── Execution model factories ───────────────────────────────────────── def make_cost_model(name): from options_portfolio_backtester.execution.cost_model import ( NoCosts, PerContractCommission, TieredCommission, ) if name == "NoCosts": return NoCosts() elif name == "PerContract": return PerContractCommission(rate=0.65) elif name == "Tiered": return TieredCommission(tiers=[(10_000, 0.65), (100_000, 0.50)]) raise ValueError(name) def make_fill_model(name): from options_portfolio_backtester.execution.fill_model import ( MarketAtBidAsk, MidPrice, VolumeAwareFill, ) if name == "MarketAtBidAsk": return MarketAtBidAsk() elif name == "MidPrice": return MidPrice() elif name == "VolumeAware": return VolumeAwareFill(full_volume_threshold=100) raise ValueError(name) def make_signal_selector(name): from options_portfolio_backtester.execution.signal_selector import ( FirstMatch, NearestDelta, MaxOpenInterest, ) if name == "FirstMatch": return FirstMatch() elif name == "NearestDelta": return NearestDelta(target_delta=-0.30) elif name == "MaxOpenInterest": return MaxOpenInterest() raise ValueError(name) # ── Engine runner ────────────────────────────────────────────────────── def _make_engine(alloc, capital, stocks, stocks_data, options_data, strategy_fn, **engine_kwargs): from options_portfolio_backtester.engine.engine import BacktestEngine from options_portfolio_backtester.execution.cost_model import NoCosts from options_portfolio_backtester.execution.fill_model import MarketAtBidAsk from options_portfolio_backtester.execution.signal_selector import FirstMatch engine_kwargs.setdefault("cost_model", NoCosts()) engine_kwargs.setdefault("fill_model", MarketAtBidAsk()) engine_kwargs.setdefault("signal_selector", FirstMatch()) eng = BacktestEngine(alloc, initial_capital=capital, **engine_kwargs) eng.stocks = stocks eng.stocks_data = stocks_data eng.options_data = options_data eng.options_strategy = strategy_fn(options_data.schema) return eng def run_backtest(alloc=None, capital=None, strategy_fn=None, rebalance_freq=1, rebalance_unit='BMS', stocks=None, stocks_data=None, options_data=None, sma_days=None, **engine_kwargs): """Run a single backtest and return the engine.""" s = stocks_data or load_small_stocks() o = options_data or load_small_options() stks = stocks or ivy_stocks() a = alloc or DEFAULT_ALLOC c = capital or DEFAULT_CAPITAL sf = strategy_fn or buy_put_strategy eng = _make_engine(a, c, stks, s, o, sf, **engine_kwargs) eng.run(rebalance_freq=rebalance_freq, sma_days=sma_days, rebalance_unit=rebalance_unit) return eng # ── Invariant assertions ────────────────────────────────────────────── def assert_invariants(eng, min_trades=0, label="", allow_negative_capital=False): """Assert standard invariants on a single backtest engine result.""" prefix = f"[{label}] " if label else "" bal = eng.balance assert len(bal) > 0, f"{prefix}empty balance" tc = bal["total capital"] # Total capital never negative (sell strategies can go negative) if not allow_negative_capital: assert (tc >= -1.0).all(), f"{prefix}negative total capital: min={tc.min()}" # Balance dates monotonic assert bal.index.is_monotonic_increasing, f"{prefix}balance index not monotonic" # Cash column exists assert "cash" in bal.columns, f"{prefix}'cash' column missing" # Capital = sum of parts (skip first row — initial allocation) if "options capital" in bal.columns and "stocks capital" in bal.columns: reconstructed = bal["cash"] + bal["stocks capital"] + bal["options capital"] assert np.allclose( tc.values[1:], reconstructed.values[1:], rtol=1e-4, atol=1.0, ), f"{prefix}total capital != cash + stocks + options" # Trade log if min_trades > 0: assert len(eng.trade_log) >= min_trades, ( f"{prefix}expected >= {min_trades} trades, got {len(eng.trade_log)}" ) # Entry quantities positive if not eng.trade_log.empty: qtys = eng.trade_log["totals"]["qty"].values assert all(q > 0 for q in qtys), f"{prefix}found non-positive qty" # No negative stock quantities (sell strategies can cause negative via margin) if not allow_negative_capital: for col in bal.columns: if col.endswith(" qty"): vals = pd.to_numeric(bal[col], errors="coerce").dropna() assert (vals >= -0.01).all(), ( f"{prefix}negative qty in '{col}': min={vals.min()}" ) ================================================ FILE: tests/bench/extract_prod_slices.py ================================================ #!/usr/bin/env python3 """Extract diverse time-period slices from raw parquet data for parity testing. Reads raw options + underlying parquets and outputs backtester-format CSV slices into tests/data/. Uses the same column mapping as data/fetch_data.py. Usage: python tests/bench/extract_prod_slices.py """ from __future__ import annotations import sys from pathlib import Path import pandas as pd import pyarrow.parquet as pq PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent DATA_DIR = PROJECT_ROOT / "tests" / "data" # ── Slice definitions ───────────────────────────────────────────────── SLICES = { "spy_crisis": { "options_parquet": "data/raw/release/SPY_options.parquet", "underlying_parquet": "data/raw/release/SPY_underlying.parquet", "symbol": "SPY", "start": "2008-06-01", "end": "2009-06-30", }, "spy_lowvol": { "options_parquet": "data/raw/release/SPY_options.parquet", "underlying_parquet": "data/raw/release/SPY_underlying.parquet", "symbol": "SPY", "start": "2017-01-01", "end": "2018-01-31", }, "spy_covid": { "options_parquet": "data/raw/release/SPY_options.parquet", "underlying_parquet": "data/raw/release/SPY_underlying.parquet", "symbol": "SPY", "start": "2020-01-01", "end": "2021-03-31", }, "spy_bear": { "options_parquet": "data/raw/release/SPY_options.parquet", "underlying_parquet": "data/raw/release/SPY_underlying.parquet", "symbol": "SPY", "start": "2022-01-01", "end": "2023-01-31", }, "iwm_2020": { "options_parquet": "data/raw/options-data/IWM/options.parquet", "underlying_parquet": "data/raw/options-dataset-hist/IWM/underlying_prices.parquet", "symbol": "IWM", "start": "2020-01-01", "end": "2021-01-31", }, "qqq_2020": { "options_parquet": "data/raw/options-data/QQQ/options.parquet", "underlying_parquet": "data/raw/options-dataset-hist/QQQ/underlying_prices.parquet", "symbol": "QQQ", "start": "2020-01-01", "end": "2021-01-01", }, } # ── Column mapping (matches data/fetch_data.py lines 259-278) ──────── def _convert_options(opts: pd.DataFrame, symbol: str, und_prices: pd.DataFrame | None) -> pd.DataFrame: """Convert raw parquet options to backtester CSV format.""" if und_prices is not None: opts = opts.merge(und_prices, on="date", how="left") else: opts["underlying_last"] = float("nan") if "last" in opts.columns: _last = opts["last"].fillna((opts["bid"] + opts["ask"]) / 2) else: _last = (opts["bid"] + opts["ask"]) / 2 return pd.DataFrame({ "underlying": symbol, "underlying_last": opts["underlying_last"].values, "optionroot": opts["contract_id"].values, "type": opts["type"].values, "expiration": pd.to_datetime(opts["expiration"]).values, "quotedate": opts["date"].values, "strike": opts["strike"].values, "last": _last.values, "bid": opts["bid"].values, "ask": opts["ask"].values, "volume": opts["volume"].values, "openinterest": opts["open_interest"].values, "impliedvol": opts["implied_volatility"].values, "delta": opts["delta"].values, "gamma": opts["gamma"].values, "theta": opts["theta"].values, "vega": opts["vega"].values, "optionalias": opts["contract_id"].values, }) def _convert_underlying(und: pd.DataFrame, symbol: str) -> pd.DataFrame: """Convert underlying parquet to Tiingo-format stocks CSV.""" # Some datasets have None/NaN for adjusted_close, dividend_amount, # split_coefficient. Fall back to close (no adjustment) and safe defaults. adj_close = und["adjusted_close"].fillna(und["close"]) div_cash = und["dividend_amount"].fillna(0.0) split_factor = und["split_coefficient"].fillna(1.0) ratio = adj_close / und["close"] return pd.DataFrame({ "symbol": symbol, "date": und["date"].values, "close": und["close"].values, "high": und["high"].values, "low": und["low"].values, "open": und["open"].values, "volume": und["volume"].values, "adjClose": adj_close.values, "adjHigh": (und["high"] * ratio).values, "adjLow": (und["low"] * ratio).values, "adjOpen": (und["open"] * ratio).values, "adjVolume": und["volume"].values, "divCash": div_cash.values, "splitFactor": split_factor.values, }) def extract_slice(slice_id: str, spec: dict) -> None: """Extract a single slice to CSV files.""" options_path = PROJECT_ROOT / spec["options_parquet"] underlying_path = PROJECT_ROOT / spec["underlying_parquet"] symbol = spec["symbol"] start = pd.Timestamp(spec["start"]) end = pd.Timestamp(spec["end"]) if not options_path.exists(): print(f" SKIP {slice_id}: {options_path} not found") return if not underlying_path.exists(): print(f" SKIP {slice_id}: {underlying_path} not found") return # Read underlying print(f" Reading underlying from {underlying_path.name}...") und = pd.read_parquet(underlying_path) und["date"] = pd.to_datetime(und["date"]) und = und[(und["date"] >= start) & (und["date"] <= end)] if und.empty: print(f" SKIP {slice_id}: no underlying data in [{start.date()}, {end.date()}]") return und = und.sort_values("date") stocks_df = _convert_underlying(und, symbol) und_prices = und[["date", "close"]].rename(columns={"close": "underlying_last"}) # Read options (use filters for efficiency on large parquets) print(f" Reading options from {options_path.name} " f"[{start.date()}, {end.date()}]...") opts = pd.read_parquet(options_path) opts["date"] = pd.to_datetime(opts["date"]) opts = opts[(opts["date"] >= start) & (opts["date"] <= end)] if opts.empty: print(f" SKIP {slice_id}: no options data in [{start.date()}, {end.date()}]") return options_df = _convert_options(opts, symbol, und_prices) options_df = options_df.sort_values( ["quotedate", "underlying", "expiration", "strike", "type"] ) # Align dates: keep only days present in both stocks and options stock_dates = set(pd.to_datetime(stocks_df["date"]).dt.normalize()) option_dates = set(pd.to_datetime(options_df["quotedate"]).dt.normalize()) shared = stock_dates & option_dates stocks_df = stocks_df[ pd.to_datetime(stocks_df["date"]).dt.normalize().isin(shared) ] options_df = options_df[ pd.to_datetime(options_df["quotedate"]).dt.normalize().isin(shared) ] # Write CSVs DATA_DIR.mkdir(parents=True, exist_ok=True) stocks_out = DATA_DIR / f"{slice_id}_stocks.csv" options_out = DATA_DIR / f"{slice_id}_options.csv" stocks_df.to_csv(stocks_out, index=False) options_df.to_csv(options_out, index=False) print(f" {slice_id}: {len(stocks_df)} stock rows, " f"{len(options_df)} option rows, " f"{len(shared)} trading days → {stocks_out.name}, {options_out.name}") def main(): print("Extracting production slices for parity testing...") print(f"Output directory: {DATA_DIR}\n") for slice_id, spec in SLICES.items(): print(f"[{slice_id}]") extract_slice(slice_id, spec) print() print("Done.") if __name__ == "__main__": main() ================================================ FILE: tests/bench/generate_test_data.py ================================================ """Generate large deterministic synthetic datasets for 3-way parity tests. Produces stock and options CSVs with the same schema as the real test data in backtester/test/test_data/, but covering 500 trading days with fixed strikes per expiration cycle (like real listed options). Usage: python -m tests.bench.generate_test_data """ from __future__ import annotations import os from math import erf, sqrt, log, exp from pathlib import Path import numpy as np import pandas as pd SEED = 42 OUTPUT_DIR = Path(__file__).resolve().parent.parent / "data" # 7 stocks matching test_data_stocks.csv symbols STOCK_SYMBOLS = ["VOO", "TLT", "EWY", "PDBC", "IAU", "VNQI", "VTIP"] STOCK_INITIAL_PRICES = [210.0, 120.0, 55.0, 18.0, 23.0, 52.0, 80.0] UNDERLYING = "SPX" N_TRADING_DAYS = 500 def _generate_trading_dates(n: int, start: str = "2017-01-03") -> pd.DatetimeIndex: return pd.bdate_range(start=start, periods=n) def _random_walk(rng: np.random.Generator, initial: float, n: int, drift: float = 0.0002, vol: float = 0.015) -> np.ndarray: log_returns = rng.normal(drift, vol, size=n) prices = initial * np.exp(np.cumsum(log_returns)) return np.round(prices, 6) def generate_stocks(dates: pd.DatetimeIndex, rng: np.random.Generator) -> pd.DataFrame: rows = [] for sym, p0 in zip(STOCK_SYMBOLS, STOCK_INITIAL_PRICES): prices = _random_walk(rng, p0, len(dates)) for date, price in zip(dates, prices): high = round(price * (1 + rng.uniform(0.001, 0.015)), 6) low = round(price * (1 - rng.uniform(0.001, 0.015)), 6) opn = round(price * (1 + rng.uniform(-0.005, 0.005)), 6) vol = int(rng.integers(500_000, 10_000_000)) rows.append({ "symbol": sym, "date": date.strftime("%Y-%m-%d"), "close": round(price, 2), "high": round(high, 2), "low": round(low, 2), "open": round(opn, 2), "volume": vol, "adjClose": price, "adjHigh": high, "adjLow": low, "adjOpen": opn, "adjVolume": vol, "divCash": 0.0, "splitFactor": 1.0, }) df = pd.DataFrame(rows) df = df.sort_values(["symbol", "date"]).reset_index(drop=True) return df def _bs_approx(S: float, K: float, T: float, vol: float, is_call: bool) -> dict: """Quick Black-Scholes approximation for option pricing.""" T = max(T, 1 / 365) sqrt_T = sqrt(T) d1 = (log(S / K) + (0.02 + 0.5 * vol**2) * T) / (vol * sqrt_T) nd1 = 0.5 * (1 + erf(d1 / sqrt(2))) if is_call: delta = round(nd1, 4) intrinsic = max(S - K, 0) else: delta = round(nd1 - 1, 4) intrinsic = max(K - S, 0) time_value = vol * S * sqrt_T * 0.4 mid_price = max(intrinsic + time_value * abs(delta), 0.05) spread = max(mid_price * 0.03, 0.05) bid = round(max(mid_price - spread / 2, 0.0), 2) ask = round(mid_price + spread / 2, 2) if bid <= 0: bid = 0.0 gamma = round(max(0.0001, 0.01 * exp(-0.5 * d1**2) / (S * vol * sqrt_T)), 4) theta = round(-S * vol * gamma / (2 * sqrt_T), 4) vega = round(S * sqrt_T * gamma * 0.01, 4) return { "bid": bid, "ask": ask, "last": round((bid + ask) / 2, 2), "delta": delta, "gamma": gamma, "theta": theta, "vega": vega, "impliedvol": round(vol, 4), } def generate_options(dates: pd.DatetimeIndex, rng: np.random.Generator) -> pd.DataFrame: """Generate options data with FIXED strikes per expiration cycle. Like real listed options: once an expiration is listed, its strikes remain constant across all quote dates until expiration. """ underlying_prices = _random_walk(rng, 2260.0, len(dates), drift=0.0003, vol=0.01) # Monthly expirations (3rd Friday) all_expirations = pd.bdate_range( start=dates[0] + pd.Timedelta(days=30), end=dates[-1] + pd.Timedelta(days=150), freq="WOM-3FRI", ) # Pre-compute FIXED strikes for each expiration based on the underlying # price at the time the expiration first becomes active (~90 DTE). # Use 5 strike levels: 90%, 95%, 100%, 105%, 110% of the reference price. exp_strikes: dict[pd.Timestamp, list[int]] = {} date_to_price = dict(zip(dates, underlying_prices)) for exp_date in all_expirations: # Reference date: ~90 days before expiration ref_date_target = exp_date - pd.Timedelta(days=90) # Find the closest actual trading date ref_date = min(dates, key=lambda d: abs((d - ref_date_target).days)) ref_price = date_to_price.get(ref_date, 2260.0) # Round strikes to nearest 50 (like real SPX options) exp_strikes[exp_date] = [ int(round(ref_price * pct / 50) * 50) for pct in [0.90, 0.95, 1.00, 1.05, 1.10] ] rows = [] for i, (qdate, spx_price) in enumerate(zip(dates, underlying_prices)): active_exps = [ exp_date for exp_date in all_expirations if 5 <= (exp_date - qdate).days <= 120 ] if not active_exps: continue exps_to_use = active_exps[:4] # up to 4 active cycles vol_base = 0.15 + 0.05 * np.sin(i / 50) for exp_date in exps_to_use: dte = (exp_date - qdate).days exp_str = exp_date.strftime("%Y-%m-%d") T = dte / 365.0 strikes = exp_strikes[exp_date] for strike in strikes: for opt_type in ["call", "put"]: is_call = opt_type == "call" vol = vol_base + rng.uniform(-0.02, 0.02) greeks = _bs_approx(spx_price, strike, T, vol, is_call) exp_code = exp_date.strftime("%y%m%d") type_code = "C" if is_call else "P" strike_code = f"{int(strike):08d}00" contract = f"SPX{exp_code}{type_code}{strike_code}" rows.append({ "underlying": UNDERLYING, "underlying_last": round(spx_price, 2), " exchange": "*", "optionroot": contract, "optionext": "", "type": opt_type, "expiration": exp_str, "quotedate": qdate.strftime("%Y-%m-%d"), "strike": strike, "last": greeks["last"], "bid": greeks["bid"], "ask": greeks["ask"], "volume": int(rng.integers(0, 5000)), "openinterest": int(rng.integers(0, 50000)), "impliedvol": greeks["impliedvol"], "delta": greeks["delta"], "gamma": greeks["gamma"], "theta": greeks["theta"], "vega": greeks["vega"], "optionalias": contract, "dte": dte, }) df = pd.DataFrame(rows) df = df.sort_values(["quotedate", "optionroot"]).reset_index(drop=True) return df def main(): rng = np.random.default_rng(SEED) dates = _generate_trading_dates(N_TRADING_DAYS) print(f"Generating {N_TRADING_DAYS} trading days: {dates[0].date()} to {dates[-1].date()}") stocks_df = generate_stocks(dates, rng) print(f"Stocks: {len(stocks_df)} rows, {stocks_df['symbol'].nunique()} symbols") options_df = generate_options(dates, rng) print(f"Options: {len(options_df)} rows, {options_df['quotedate'].nunique()} dates, " f"~{len(options_df) / options_df['quotedate'].nunique():.0f} contracts/date") # Verify contracts persist across dates sample_contract = options_df['optionroot'].iloc[0] dates_for_contract = options_df[options_df['optionroot'] == sample_contract]['quotedate'].nunique() print(f"Sample contract {sample_contract} appears on {dates_for_contract} dates") os.makedirs(OUTPUT_DIR, exist_ok=True) stocks_path = OUTPUT_DIR / "large_stocks.csv" options_path = OUTPUT_DIR / "large_options.csv" stocks_df.to_csv(stocks_path, index=False) options_df.to_csv(options_path, index=False) print(f"\nWritten:\n {stocks_path}\n {options_path}") if __name__ == "__main__": main() ================================================ FILE: tests/bench/test_edge_cases.py ================================================ """Edge-case regression tests. Each test runs the backtest ONCE and checks invariants. """ from __future__ import annotations import pytest from tests.bench._test_helpers import ( RUST_AVAILABLE, DEFAULT_ALLOC, DEFAULT_CAPITAL, _make_engine, ivy_stocks, load_small_stocks, load_small_options, buy_put_strategy, buy_call_strategy, sell_put_strategy, run_backtest, assert_invariants, ) pytestmark = pytest.mark.skipif( not RUST_AVAILABLE, reason="Rust extension not installed" ) # ── Allocation edge cases ───────────────────────────────────────────── class TestAllocationEdgeCases: def test_zero_options(self): alloc = {"stocks": 0.9, "options": 0.0, "cash": 0.1} eng = run_backtest(alloc=alloc) assert len(eng.balance) > 0 assert eng.trade_log.empty def test_high_options(self): alloc = {"stocks": 0.09, "options": 0.90, "cash": 0.01} eng = run_backtest(alloc=alloc) assert_invariants(eng, label="high_options") def test_tiny_stocks(self): alloc = {"stocks": 0.01, "options": 0.89, "cash": 0.10} eng = run_backtest(alloc=alloc) assert_invariants(eng, label="tiny_stocks") # ── Capital edge cases ──────────────────────────────────────────────── class TestCapitalEdgeCases: def test_tiny_capital(self): eng = run_backtest(capital=1_000) assert_invariants(eng, label="tiny_capital") def test_huge_capital(self): eng = run_backtest(capital=100_000_000) assert_invariants(eng, label="huge_capital") # ── Rebalance edge cases ───────────────────────────────────────────── class TestRebalanceEdgeCases: def test_weekly_rebalance(self): eng = run_backtest(rebalance_unit="W-MON") assert_invariants(eng, min_trades=1, label="weekly_rebalance") # ── Direction and type ──────────────────────────────────────────────── class TestDirectionAndType: def test_sell_put(self): eng = run_backtest(strategy_fn=sell_put_strategy) # Sell strategies can go deeply negative (unlimited downside risk) assert len(eng.balance) > 0 assert not eng.trade_log.empty def test_buy_call(self): eng = run_backtest(strategy_fn=buy_call_strategy) assert_invariants(eng, label="buy_call") # ── SMA gating ─────────────────────────────────────────────────────── class TestSMAGating: def test_sma_50(self): eng = run_backtest(sma_days=50) assert_invariants(eng, label="sma_50") # ── Options budget pct ─────────────────────────────────────────────── class TestOptionsBudgetPct: def test_budget_limits_spending(self): eng = _make_engine( DEFAULT_ALLOC, DEFAULT_CAPITAL, ivy_stocks(), load_small_stocks(), load_small_options(), buy_put_strategy, ) eng.options_budget_pct = 0.005 eng.run(rebalance_freq=1, rebalance_unit="BMS") assert_invariants(eng, label="budget_pct") # ── No matching entries ────────────────────────────────────────────── class TestNoMatchingEntries: def test_filter_matches_nothing(self): eng = run_backtest( strategy_fn=lambda schema: buy_put_strategy( schema, underlying="NONEXISTENT" ), ) assert len(eng.balance) > 0 assert eng.trade_log.empty ================================================ FILE: tests/bench/test_execution_models.py ================================================ """Regression tests for execution models (cost, fill, signal, risk, exits).""" from __future__ import annotations import pytest from tests.bench._test_helpers import ( RUST_AVAILABLE, DEFAULT_ALLOC, DEFAULT_CAPITAL, buy_put_strategy, strangle_strategy, run_backtest, assert_invariants, make_cost_model, make_fill_model, make_signal_selector, ) pytestmark = [ pytest.mark.skipif(not RUST_AVAILABLE, reason="Rust extension not installed"), pytest.mark.bench, ] # ── Cost models ─────────────────────────────────────────────────────── class TestCostModels: @pytest.mark.parametrize("cost_name", ["NoCosts", "PerContract", "Tiered"]) def test_cost_model(self, cost_name): eng = run_backtest(cost_model=make_cost_model(cost_name)) assert_invariants(eng, min_trades=1, label=cost_name) # ── Fill models ─────────────────────────────────────────────────────── class TestFillModels: @pytest.mark.parametrize("fill_name", ["MarketAtBidAsk", "MidPrice"]) def test_fill_model(self, fill_name): eng = run_backtest(fill_model=make_fill_model(fill_name)) assert_invariants(eng, min_trades=1, label=fill_name) # ── Signal selectors ───────────────────────────────────────────────── class TestSignalSelectors: @pytest.mark.parametrize("signal_name", ["FirstMatch", "NearestDelta", "MaxOpenInterest"]) def test_signal_selector(self, signal_name): eng = run_backtest(signal_selector=make_signal_selector(signal_name)) assert_invariants(eng, label=signal_name) # ── Risk constraints ───────────────────────────────────────────────── class TestRiskConstraints: def test_max_delta(self): from options_portfolio_backtester.portfolio.risk import RiskManager, MaxDelta rm = RiskManager() rm.add_constraint(MaxDelta(limit=100)) eng = run_backtest(risk_manager=rm) assert_invariants(eng) def test_max_drawdown(self): from options_portfolio_backtester.portfolio.risk import RiskManager, MaxDrawdown rm = RiskManager() rm.add_constraint(MaxDrawdown(max_dd_pct=0.20)) eng = run_backtest(risk_manager=rm) assert_invariants(eng) # ── Exit thresholds ────────────────────────────────────────────────── class TestExitThresholds: def test_profit_exit(self): from tests.bench._test_helpers import ( _make_engine, load_small_stocks, load_small_options, ivy_stocks, ) eng = _make_engine( DEFAULT_ALLOC, DEFAULT_CAPITAL, ivy_stocks(), load_small_stocks(), load_small_options(), buy_put_strategy, ) eng.profit_target = 0.5 eng.run(rebalance_freq=1, rebalance_unit="BMS") assert_invariants(eng) def test_loss_exit(self): from tests.bench._test_helpers import ( _make_engine, load_small_stocks, load_small_options, ivy_stocks, ) eng = _make_engine( DEFAULT_ALLOC, DEFAULT_CAPITAL, ivy_stocks(), load_small_stocks(), load_small_options(), buy_put_strategy, ) eng.stop_loss = 0.3 eng.run(rebalance_freq=1, rebalance_unit="BMS") assert_invariants(eng) # ── Full model grid (3 x 2 x 3 = 18 combos) ──────────────────────── class TestModelGrid: @pytest.mark.parametrize("cost_name", ["NoCosts", "PerContract", "Tiered"]) @pytest.mark.parametrize("fill_name", ["MarketAtBidAsk", "MidPrice"]) @pytest.mark.parametrize("signal_name", ["FirstMatch", "NearestDelta", "MaxOpenInterest"]) def test_model_combo(self, cost_name, fill_name, signal_name): eng = run_backtest( cost_model=make_cost_model(cost_name), fill_model=make_fill_model(fill_name), signal_selector=make_signal_selector(signal_name), ) assert_invariants(eng, label=f"{cost_name}_{fill_name}_{signal_name}") ================================================ FILE: tests/bench/test_invariants.py ================================================ """Balance sheet and trade log invariants. Tests run each backtest ONCE and verify structural invariants. Covers small, generated, and production datasets. """ from __future__ import annotations import numpy as np import pandas as pd import pytest from tests.bench._test_helpers import ( RUST_AVAILABLE, DEFAULT_ALLOC, DEFAULT_CAPITAL, IVY_STOCKS_TUPLES, ivy_stocks, generated_stocks, prod_spy_stocks, load_generated_stocks, load_generated_options, load_prod_stocks, load_prod_options, buy_put_strategy, sell_put_strategy, strangle_strategy, run_backtest, assert_invariants, ) pytestmark = pytest.mark.skipif( not RUST_AVAILABLE, reason="Rust extension not installed" ) # ── Small dataset invariants ─────────────────────────────────────────── class TestBalanceSheetInvariants: @pytest.fixture(autouse=True) def _engine(self): self.eng = run_backtest() def test_total_capital_equals_parts(self): assert_invariants(self.eng) def test_capital_never_negative(self): tc = self.eng.balance["total capital"] assert (tc >= -1.0).all() def test_initial_capital_correct(self): first_tc = self.eng.balance["total capital"].iloc[0] assert abs(first_tc - DEFAULT_CAPITAL) < 1.0 def test_balance_dates_monotonic(self): assert self.eng.balance.index.is_monotonic_increasing def test_balance_not_empty(self): assert len(self.eng.balance) > 1 def test_cash_column_exists(self): assert "cash" in self.eng.balance.columns class TestTradeLogInvariants: @pytest.fixture(autouse=True) def _engine(self): self.eng = run_backtest() def test_trade_log_not_empty(self): assert not self.eng.trade_log.empty def test_entry_costs_nonzero(self): if self.eng.trade_log.empty: pytest.skip("No trades") costs = self.eng.trade_log["totals"]["cost"].values assert all(c != 0 for c in costs) def test_qty_positive_on_entry(self): if self.eng.trade_log.empty: pytest.skip("No trades") qtys = self.eng.trade_log["totals"]["qty"].values assert all(q > 0 for q in qtys) def test_trade_dates_within_data_range(self): if self.eng.trade_log.empty: pytest.skip("No trades") trade_dates = pd.to_datetime(self.eng.trade_log["totals"]["date"]).unique() data_start = pd.Timestamp(self.eng.options_data._data["quotedate"].min()) data_end = pd.Timestamp(self.eng.options_data._data["quotedate"].max()) for td in trade_dates: assert data_start <= td <= data_end class TestBalanceColumns: @pytest.fixture(autouse=True) def _engine(self): self.eng = run_backtest() def test_required_columns(self): required = { "cash", "stocks capital", "options capital", "total capital", "calls capital", "puts capital", "% change", "accumulated return", } actual = set(self.eng.balance.columns) missing = required - actual assert not missing, f"Missing columns: {missing}" def test_per_stock_columns(self): for sym, _ in IVY_STOCKS_TUPLES: assert sym in self.eng.balance.columns assert f"{sym} qty" in self.eng.balance.columns # ── Generated dataset invariants ─────────────────────────────────────── class TestGeneratedDataInvariants: @pytest.fixture(autouse=True) def _engine(self): self.eng = run_backtest( stocks=generated_stocks(), stocks_data=load_generated_stocks(), options_data=load_generated_options(), ) def test_invariants(self): assert_invariants(self.eng, min_trades=5, label="generated") def test_many_balance_rows(self): assert len(self.eng.balance) >= 10 def test_initial_capital(self): first_tc = self.eng.balance["total capital"].iloc[0] assert abs(first_tc - DEFAULT_CAPITAL) < 1.0 # ── Production SPY invariants ────────────────────────────────────────── class TestProductionDataInvariants: @pytest.fixture(autouse=True) def _engine(self): self.eng = run_backtest( strategy_fn=lambda schema: buy_put_strategy(schema, underlying="SPY"), stocks=prod_spy_stocks(), stocks_data=load_prod_stocks(), options_data=load_prod_options(), ) def test_invariants(self): assert_invariants(self.eng, min_trades=3, label="production") def test_capital_never_negative(self): tc = self.eng.balance["total capital"] assert (tc >= -1.0).all() ================================================ FILE: tests/bench/test_multi_leg.py ================================================ """Multi-leg strategy regression tests. Each test runs the backtest ONCE and checks invariants. """ from __future__ import annotations import pytest from tests.bench._test_helpers import ( RUST_AVAILABLE, DEFAULT_ALLOC, DEFAULT_CAPITAL, strangle_strategy, straddle_strategy, buy_put_spread_strategy, sell_call_spread_strategy, two_leg_strategy, run_backtest, assert_invariants, ) pytestmark = pytest.mark.skipif( not RUST_AVAILABLE, reason="Rust extension not installed" ) class TestMultiLegStrategies: def test_strangle(self): eng = run_backtest(strategy_fn=strangle_strategy) assert_invariants(eng, allow_negative_capital=True) def test_straddle(self): eng = run_backtest(strategy_fn=straddle_strategy) assert_invariants(eng) def test_put_spread(self): eng = run_backtest(strategy_fn=buy_put_spread_strategy) assert_invariants(eng, allow_negative_capital=True) def test_call_spread(self): eng = run_backtest(strategy_fn=sell_call_spread_strategy) assert_invariants(eng, allow_negative_capital=True) class TestMixedDirections: _COMBOS = [ ("buy", "put", "sell", "call"), ("sell", "put", "buy", "call"), ("buy", "put", "buy", "call"), ("sell", "put", "sell", "call"), ] @pytest.mark.parametrize("d1,t1,d2,t2", _COMBOS) def test_direction_combo(self, d1, t1, d2, t2): eng = run_backtest( strategy_fn=lambda schema: two_leg_strategy(schema, d1, t1, d2, t2) ) has_sell = "sell" in (d1, d2) assert_invariants(eng, label=f"{d1}_{t1}_{d2}_{t2}", allow_negative_capital=has_sell) class TestPerLegOverrides: def test_per_leg_signal_selector(self): from options_portfolio_backtester.execution.signal_selector import NearestDelta eng = run_backtest( strategy_fn=strangle_strategy, signal_selector=NearestDelta(target_delta=-0.30), ) assert_invariants(eng, allow_negative_capital=True) def test_per_leg_fill_model(self): from options_portfolio_backtester.execution.fill_model import MidPrice eng = run_backtest( strategy_fn=strangle_strategy, fill_model=MidPrice(), ) assert_invariants(eng, allow_negative_capital=True) ================================================ FILE: tests/bench/test_partial_exits.py ================================================ """Regression tests for partial exit (sell_some_options) scenarios. High options allocation (85%) forces sell_some_options to trigger when mark-to-market changes shift the allocation balance. """ from __future__ import annotations import numpy as np import pytest from tests.bench._test_helpers import ( RUST_AVAILABLE, DEFAULT_CAPITAL, generated_stocks, prod_spy_stocks, load_generated_stocks, load_generated_options, load_prod_stocks, load_prod_options, buy_put_strategy, sell_put_strategy, strangle_strategy, run_backtest, assert_invariants, ) pytestmark = pytest.mark.skipif( not RUST_AVAILABLE, reason="Rust extension not installed" ) HIGH_OPTIONS_ALLOC = {"stocks": 0.05, "options": 0.85, "cash": 0.10} class TestPartialExitGenerated: def _run(self, strategy_fn): return run_backtest( alloc=HIGH_OPTIONS_ALLOC, capital=DEFAULT_CAPITAL, strategy_fn=strategy_fn, stocks=generated_stocks(), stocks_data=load_generated_stocks(), options_data=load_generated_options(), ) def test_buy_put(self): eng = self._run(buy_put_strategy) assert_invariants(eng, min_trades=3, label="partial_buy_put") def test_sell_put(self): eng = self._run(sell_put_strategy) assert_invariants(eng, min_trades=3, label="partial_sell_put", allow_negative_capital=True) def test_strangle(self): eng = self._run(strangle_strategy) assert_invariants(eng, label="partial_strangle", allow_negative_capital=True) class TestPartialExitProduction: def _run(self, strategy_fn): return run_backtest( alloc=HIGH_OPTIONS_ALLOC, capital=DEFAULT_CAPITAL, strategy_fn=lambda schema: strategy_fn(schema, underlying="SPY"), stocks=prod_spy_stocks(), stocks_data=load_prod_stocks(), options_data=load_prod_options(), ) def test_buy_put_spy(self): eng = self._run(buy_put_strategy) assert_invariants(eng, label="partial_prod_buy_put") def test_sell_put_spy(self): eng = self._run(sell_put_strategy) assert_invariants(eng, label="partial_prod_sell_put", allow_negative_capital=True) class TestPartialExitCashAccounting: def test_cash_never_deeply_negative(self): eng = run_backtest( alloc=HIGH_OPTIONS_ALLOC, capital=DEFAULT_CAPITAL, strategy_fn=buy_put_strategy, stocks=generated_stocks(), stocks_data=load_generated_stocks(), options_data=load_generated_options(), ) # High options allocation can cause cash to go moderately negative # due to timing of mark-to-market and rebalancing cash = eng.balance["cash"] assert (cash >= -50_000.0).all(), f"Cash deeply negative: min={cash.min()}" ================================================ FILE: tests/bench/test_sweep.py ================================================ """Regression tests for Rust parallel_sweep API.""" import pandas as pd import pytest try: import polars as pl from options_portfolio_backtester._ob_rust import ( parallel_sweep as rust_parallel_sweep, run_backtest_py as rust_run_backtest, ) from options_portfolio_backtester.analytics.optimization import rust_grid_sweep RUST_AVAILABLE = True except ImportError: RUST_AVAILABLE = False pytestmark = pytest.mark.skipif(not RUST_AVAILABLE, reason="Rust extension not installed") def _pd_to_pl(df: pd.DataFrame) -> "pl.DataFrame": return pl.from_pandas(df) def _dates_to_ns(dates: list[str]) -> list[int]: return [int(pd.Timestamp(d).value) for d in dates] def _ensure_datetime_cols(df: pd.DataFrame, cols: list[str]) -> pd.DataFrame: df = df.copy() for col in cols: if col in df.columns: df[col] = pd.to_datetime(df[col]) return df def _make_test_data(): dates = ["2024-01-01"] * 4 + ["2024-01-15"] * 4 + ["2024-02-01"] * 4 opts = pd.DataFrame({ "optionroot": ["A", "B", "C", "D"] * 3, "underlying": ["SPX"] * 12, "underlying_last": [4500.0] * 12, "quotedate": dates, "type": ["put", "put", "call", "put"] * 3, "expiration": ["2024-03-01"] * 12, "strike": [4400.0, 4300.0, 4500.0, 4200.0] * 3, "bid": [5.0, 8.0, 3.0, 12.0, 4.0, 7.0, 2.0, 11.0, 3.5, 6.5, 1.5, 10.5], "ask": [6.0, 9.0, 4.0, 13.0, 5.0, 8.0, 3.0, 12.0, 4.5, 7.5, 2.5, 11.5], "volume": [100] * 12, "open_interest": [1000] * 12, "dte": [60] * 12, }) stocks = pd.DataFrame({ "date": ["2024-01-01", "2024-01-15", "2024-02-01"] * 2, "symbol": ["SPY"] * 3 + ["TLT"] * 3, "adjClose": [450.0, 455.0, 460.0, 100.0, 101.0, 102.0], }) opts = _ensure_datetime_cols(opts, ["quotedate", "expiration"]) stocks = _ensure_datetime_cols(stocks, ["date"]) return _pd_to_pl(opts), _pd_to_pl(stocks) def _base_config(): return { "allocation": {"stocks": 0.5, "options": 0.3, "cash": 0.2}, "initial_capital": 100000.0, "shares_per_contract": 100, "legs": [{ "name": "leg_1", "entry_filter": "(type == 'put') & (ask > 0)", "exit_filter": "type == 'put'", "direction": "ask", "type": "put", "entry_sort_col": None, "entry_sort_asc": True, }], "profit_pct": None, "loss_pct": None, "stocks": [("SPY", 0.6), ("TLT", 0.4)], "rebalance_dates": _dates_to_ns(["2024-01-01", "2024-01-15", "2024-02-01"]), } def _schema(): return { "contract": "optionroot", "date": "quotedate", "stocks_date": "date", "stocks_symbol": "symbol", "stocks_price": "adjClose", } class TestSweep: def test_single_config_matches_direct(self): opts, stocks = _make_test_data() config = _base_config() schema = _schema() _balance, _trade_log, direct_stats = rust_run_backtest( opts, stocks, config, schema, ) sweep_results = rust_parallel_sweep( opts, stocks, config, schema, [{"label": "base"}], ) assert len(sweep_results) == 1 r = sweep_results[0] assert r["label"] == "base" assert r["error"] is None assert abs(r["total_return"] - direct_stats["total_return"]) < 1e-10 assert abs(r["final_cash"] - direct_stats["final_cash"]) < 1e-6 def test_multiple_configs(self): opts, stocks = _make_test_data() config = _base_config() schema = _schema() overrides = [ {"label": "tight", "profit_pct": 0.01, "loss_pct": 0.01}, {"label": "wide", "profit_pct": 0.99, "loss_pct": 0.99}, ] results = rust_parallel_sweep(opts, stocks, config, schema, overrides) assert len(results) == 2 for r in results: assert r["error"] is None def test_per_leg_filter_overrides(self): opts, stocks = _make_test_data() config = _base_config() schema = _schema() overrides = [ {"label": "broad", "leg_entry_filters": ["(type == 'put') & (ask > 0)"]}, {"label": "narrow", "leg_entry_filters": ["(type == 'put') & (ask > 0) & (strike < 4300)"]}, ] results = rust_parallel_sweep(opts, stocks, config, schema, overrides) by_label = {r["label"]: r for r in results} assert by_label["narrow"]["total_trades"] <= by_label["broad"]["total_trades"] def test_bad_filter_returns_error(self): opts, stocks = _make_test_data() config = _base_config() schema = _schema() overrides = [{"label": "bad", "leg_entry_filters": ["(((invalid syntax!!!"]}] results = rust_parallel_sweep(opts, stocks, config, schema, overrides) assert len(results) == 1 r = results[0] assert r["error"] is not None or r["total_trades"] == 0 def test_empty_param_grid(self): opts, stocks = _make_test_data() results = rust_parallel_sweep( opts, stocks, _base_config(), _schema(), [], ) assert results == [] def test_deterministic(self): opts, stocks = _make_test_data() config = _base_config() schema = _schema() overrides = [ {"label": "a", "profit_pct": 0.5}, {"label": "b", "loss_pct": 0.5}, ] r1 = rust_parallel_sweep(opts, stocks, config, schema, overrides, n_workers=1) r2 = rust_parallel_sweep(opts, stocks, config, schema, overrides) r1.sort(key=lambda x: x["label"]) r2.sort(key=lambda x: x["label"]) for a, b in zip(r1, r2): assert abs(a["total_return"] - b["total_return"]) < 1e-10 class TestGridSweepWrapper: def test_sorted_by_sharpe(self): opts, stocks = _make_test_data() overrides = [{"label": "a"}, {"label": "b", "profit_pct": 0.01}] results = rust_grid_sweep(opts, stocks, _base_config(), _schema(), overrides) sharpes = [r["sharpe_ratio"] for r in results] assert sharpes == sorted(sharpes, reverse=True) ================================================ FILE: tests/compat/__init__.py ================================================ ================================================ FILE: tests/compat/test_bt_overlap_gate.py ================================================ from __future__ import annotations from pathlib import Path import pytest from scripts.compare_with_bt import normalize_weights, run_bt, run_options_portfolio_backtester @pytest.mark.bench def test_bt_overlap_gate_stock_only(): stocks_file = Path("data/processed/stocks.csv") if not stocks_file.exists(): pytest.skip("stocks.csv is not available") bt_available = True try: import bt # noqa: F401 except Exception: bt_available = False if not bt_available: pytest.skip("bt is not installed") symbols = ["SPY"] weights = normalize_weights(symbols, None) ob = run_options_portfolio_backtester( stocks_file=str(stocks_file), symbols=symbols, weights=weights, initial_capital=1_000_000.0, rebalance_months=1, runs=1, ) bt_res = run_bt( stocks_file=str(stocks_file), symbols=symbols, weights=weights, initial_capital=1_000_000.0, runs=1, ) assert bt_res is not None common = ob.equity.index.intersection(bt_res.equity.index) assert len(common) > 200 ob_n = ob.equity.loc[common] / ob.equity.loc[common].iloc[0] bt_n = bt_res.equity.loc[common] / bt_res.equity.loc[common].iloc[0] delta = (ob_n - bt_n).abs().max() assert float(delta) < 0.10 ================================================ FILE: tests/conftest.py ================================================ from __future__ import annotations from pathlib import Path import pytest def pytest_collection_modifyitems(config, items): del config for item in items: path = Path(str(item.fspath)).as_posix() if "/tests/bench/" in path: item.add_marker(pytest.mark.bench) ================================================ FILE: tests/convexity/__init__.py ================================================ ================================================ FILE: tests/convexity/conftest.py ================================================ """Shared fixtures for convexity tests.""" import numpy as np import pandas as pd import pytest from options_portfolio_backtester.convexity.config import BacktestConfig, InstrumentConfig class MockOptionsData: """Mock HistoricalOptionsData with ._data attribute.""" def __init__(self, df: pd.DataFrame): self._data = df class MockStocksData: """Mock TiingoData with ._data attribute.""" def __init__(self, df: pd.DataFrame): self._data = df def _make_put_row(date, strike, bid, ask, delta, underlying, dte, iv, expiration): return { "quotedate": pd.Timestamp(date), "expiration": pd.Timestamp(expiration), "type": "put", "strike": strike, "bid": bid, "ask": ask, "delta": delta, "underlying_last": underlying, "dte": dte, "impliedvol": iv, } @pytest.fixture def instrument_config(): return InstrumentConfig( symbol="TEST", options_file="test_options.csv", stocks_file="test_stocks.csv", target_delta=-0.10, dte_min=14, dte_max=60, tail_drop=0.20, ) @pytest.fixture def backtest_config(): return BacktestConfig( initial_capital=100_000.0, budget_pct=0.005, ) @pytest.fixture def synthetic_options(): """Three months of synthetic put options, 3 strikes per day.""" rows = [] dates = pd.bdate_range("2020-01-02", "2020-03-31") for date in dates: expiration = date + pd.Timedelta(days=30) underlying = 400.0 for strike, bid, ask, delta, iv in [ (360.0, 2.5, 3.0, -0.08, 0.20), (370.0, 3.5, 4.0, -0.12, 0.22), (380.0, 5.0, 5.5, -0.18, 0.25), ]: rows.append(_make_put_row(date, strike, bid, ask, delta, underlying, 30, iv, expiration)) df = pd.DataFrame(rows) return MockOptionsData(df) @pytest.fixture def synthetic_stocks(): """Three months of synthetic stock prices.""" dates = pd.bdate_range("2020-01-02", "2020-03-31") np.random.seed(42) prices = 400.0 * np.cumprod(1 + np.random.normal(0.0003, 0.01, len(dates))) df = pd.DataFrame({"date": dates, "adjClose": prices}) return MockStocksData(df) @pytest.fixture def empty_options(): """Empty options DataFrame.""" df = pd.DataFrame(columns=[ "quotedate", "expiration", "type", "strike", "bid", "ask", "delta", "underlying_last", "dte", "impliedvol", ]) return MockOptionsData(df) @pytest.fixture def empty_stocks(): """Empty stocks DataFrame.""" df = pd.DataFrame(columns=["date", "adjClose"]) return MockStocksData(df) ================================================ FILE: tests/convexity/test_allocator.py ================================================ """Tests for allocation strategies.""" import pytest from options_portfolio_backtester.convexity.allocator import ( allocate_equal_weight, allocate_inverse_vol, pick_cheapest, ) class TestPickCheapest: def test_picks_highest_ratio(self): scores = {"SPY": 1.5, "HYG": 3.2, "EEM": 2.0} assert pick_cheapest(scores) == "HYG" def test_single_instrument(self): assert pick_cheapest({"SPY": 1.0}) == "SPY" def test_empty_raises(self): with pytest.raises(ValueError): pick_cheapest({}) class TestEqualWeight: def test_splits_evenly(self): alloc = allocate_equal_weight(["SPY", "HYG", "EEM"], 6000.0) assert alloc == {"SPY": 2000.0, "HYG": 2000.0, "EEM": 2000.0} def test_single(self): alloc = allocate_equal_weight(["SPY"], 5000.0) assert alloc == {"SPY": 5000.0} def test_empty(self): assert allocate_equal_weight([], 5000.0) == {} class TestInverseVol: def test_lower_vol_gets_more(self): alloc = allocate_inverse_vol({"SPY": 0.20, "HYG": 0.40}, 6000.0) assert alloc["SPY"] > alloc["HYG"] assert abs(alloc["SPY"] + alloc["HYG"] - 6000.0) < 0.01 def test_equal_vol(self): alloc = allocate_inverse_vol({"SPY": 0.20, "HYG": 0.20}, 4000.0) assert abs(alloc["SPY"] - 2000.0) < 0.01 assert abs(alloc["HYG"] - 2000.0) < 0.01 def test_zero_vol_falls_back(self): alloc = allocate_inverse_vol({"SPY": 0.0, "HYG": 0.0}, 4000.0) assert abs(alloc["SPY"] - 2000.0) < 0.01 ================================================ FILE: tests/convexity/test_backtest.py ================================================ """Tests for convexity backtest module.""" import pandas as pd import pytest from options_portfolio_backtester.convexity.backtest import ( BacktestResult, run_backtest, run_unhedged, ) class TestRunBacktest: def test_returns_monthly_records( self, synthetic_options, synthetic_stocks, backtest_config, ): result = run_backtest(synthetic_options, synthetic_stocks, backtest_config) assert isinstance(result, BacktestResult) assert len(result.records) == 3 def test_daily_balance_populated( self, synthetic_options, synthetic_stocks, backtest_config, ): result = run_backtest(synthetic_options, synthetic_stocks, backtest_config) assert not result.daily_balance.empty assert (result.daily_balance["balance"] > 0).all() def test_budget_deducted( self, synthetic_options, synthetic_stocks, backtest_config, ): result = run_backtest(synthetic_options, synthetic_stocks, backtest_config) assert result.records["put_cost"].iloc[0] > 0 assert result.records["contracts"].iloc[0] > 0 def test_empty_options( self, empty_options, synthetic_stocks, backtest_config, ): result = run_backtest(empty_options, synthetic_stocks, backtest_config) assert result.records.empty assert result.daily_balance.empty class TestRunUnhedged: def test_returns_correct_shape(self, synthetic_stocks, backtest_config): daily = run_unhedged(synthetic_stocks, backtest_config) assert not daily.empty assert "balance" in daily.columns assert "pct_change" in daily.columns dates = pd.bdate_range("2020-01-02", "2020-03-31") assert len(daily) == len(dates) def test_initial_value_matches_capital(self, synthetic_stocks, backtest_config): daily = run_unhedged(synthetic_stocks, backtest_config) assert abs(daily["balance"].iloc[0] - backtest_config.initial_capital) < 0.01 ================================================ FILE: tests/convexity/test_config.py ================================================ """Tests for convexity config.""" from options_portfolio_backtester.convexity.config import ( BacktestConfig, InstrumentConfig, default_config, ) class TestConfig: def test_instrument_defaults(self): inst = InstrumentConfig(symbol="SPY", options_file="o.csv", stocks_file="s.csv") assert inst.target_delta == -0.10 assert inst.dte_min == 14 assert inst.dte_max == 60 assert inst.tail_drop == 0.20 def test_backtest_defaults(self): cfg = BacktestConfig() assert cfg.initial_capital == 1_000_000.0 assert cfg.budget_pct == 0.005 def test_default_config(self): cfg = default_config() assert len(cfg.instruments) == 1 assert cfg.instruments[0].symbol == "SPY" ================================================ FILE: tests/core/__init__.py ================================================ ================================================ FILE: tests/core/test_types.py ================================================ """Tests for core domain types.""" from options_portfolio_backtester.core.types import ( Direction, OptionType, Order, Signal, Greeks, Fill, OptionContract, StockAllocation, Stock, get_order, ) # --------------------------------------------------------------------------- # Direction # --------------------------------------------------------------------------- class TestDirection: def test_buy_price_column_is_ask(self): assert Direction.BUY.price_column == "ask" def test_sell_price_column_is_bid(self): assert Direction.SELL.price_column == "bid" def test_invert_buy(self): assert ~Direction.BUY == Direction.SELL def test_invert_sell(self): assert ~Direction.SELL == Direction.BUY def test_decoupled_from_column_name(self): """Direction.value is 'buy'/'sell', NOT 'ask'/'bid'.""" assert Direction.BUY.value == "buy" assert Direction.SELL.value == "sell" # --------------------------------------------------------------------------- # OptionType # --------------------------------------------------------------------------- class TestOptionType: def test_invert_call(self): assert ~OptionType.CALL == OptionType.PUT def test_invert_put(self): assert ~OptionType.PUT == OptionType.CALL # --------------------------------------------------------------------------- # Order # --------------------------------------------------------------------------- class TestOrder: def test_invert_bto(self): assert ~Order.BTO == Order.STC def test_invert_stc(self): assert ~Order.STC == Order.BTO def test_invert_sto(self): assert ~Order.STO == Order.BTC def test_invert_btc(self): assert ~Order.BTC == Order.STO # --------------------------------------------------------------------------- # get_order # --------------------------------------------------------------------------- class TestGetOrder: def test_buy_entry(self): assert get_order(Direction.BUY, Signal.ENTRY) == Order.BTO def test_buy_exit(self): assert get_order(Direction.BUY, Signal.EXIT) == Order.STC def test_sell_entry(self): assert get_order(Direction.SELL, Signal.ENTRY) == Order.STO def test_sell_exit(self): assert get_order(Direction.SELL, Signal.EXIT) == Order.BTC # --------------------------------------------------------------------------- # Greeks # --------------------------------------------------------------------------- class TestGreeks: def test_default_zeros(self): g = Greeks() assert g.delta == 0.0 assert g.gamma == 0.0 assert g.theta == 0.0 assert g.vega == 0.0 def test_addition(self): a = Greeks(delta=0.5, gamma=0.01, theta=-0.02, vega=0.1) b = Greeks(delta=-0.3, gamma=0.02, theta=-0.01, vega=0.05) result = a + b assert abs(result.delta - 0.2) < 1e-10 assert abs(result.gamma - 0.03) < 1e-10 assert abs(result.theta - (-0.03)) < 1e-10 assert abs(result.vega - 0.15) < 1e-10 def test_scalar_multiply(self): g = Greeks(delta=0.5, gamma=0.01, theta=-0.02, vega=0.1) result = g * 10 assert abs(result.delta - 5.0) < 1e-10 assert abs(result.gamma - 0.1) < 1e-10 def test_rmul(self): g = Greeks(delta=0.5, gamma=0.01, theta=-0.02, vega=0.1) result = 10 * g assert abs(result.delta - 5.0) < 1e-10 def test_negation(self): g = Greeks(delta=0.5, gamma=0.01, theta=-0.02, vega=0.1) neg = -g assert abs(neg.delta - (-0.5)) < 1e-10 assert abs(neg.vega - (-0.1)) < 1e-10 def test_as_dict(self): g = Greeks(delta=0.5, gamma=0.01, theta=-0.02, vega=0.1) d = g.as_dict assert d == {"delta": 0.5, "gamma": 0.01, "theta": -0.02, "vega": 0.1} def test_frozen(self): g = Greeks(delta=0.5) try: g.delta = 1.0 # type: ignore assert False, "Should have raised" except AttributeError: pass # --------------------------------------------------------------------------- # Fill # --------------------------------------------------------------------------- class TestFill: def test_buy_fill_notional(self): f = Fill(price=2.50, quantity=10, direction=Direction.BUY, shares_per_contract=100) # -1 * 2.50 * 10 * 100 = -2500 assert f.notional == -2500.0 def test_sell_fill_notional(self): f = Fill(price=2.50, quantity=10, direction=Direction.SELL, shares_per_contract=100) # 1 * 2.50 * 10 * 100 = 2500 assert f.notional == 2500.0 def test_fill_with_commission(self): f = Fill(price=2.50, quantity=10, direction=Direction.BUY, shares_per_contract=100, commission=6.50) # -2500 - 6.50 = -2506.50 assert f.notional == -2506.50 def test_fill_with_slippage(self): f = Fill(price=2.50, quantity=10, direction=Direction.BUY, shares_per_contract=100, slippage=5.0) assert f.notional == -2505.0 def test_fill_with_commission_and_slippage(self): f = Fill(price=2.50, quantity=10, direction=Direction.BUY, shares_per_contract=100, commission=6.50, slippage=5.0) assert f.notional == -2511.50 # --------------------------------------------------------------------------- # OptionContract # --------------------------------------------------------------------------- class TestOptionContract: def test_creation(self): c = OptionContract( contract_id="SPY240119C00500000", underlying="SPY", expiration="2024-01-19", option_type=OptionType.CALL, strike=500.0, ) assert c.contract_id == "SPY240119C00500000" assert c.option_type == OptionType.CALL assert c.strike == 500.0 # --------------------------------------------------------------------------- # StockAllocation / Stock # --------------------------------------------------------------------------- class TestStockAllocation: def test_creation(self): s = StockAllocation("SPY", 0.60) assert s.symbol == "SPY" assert s.percentage == 0.60 def test_stock_alias(self): s = Stock("VOO", 1.0) assert s.symbol == "VOO" assert isinstance(s, StockAllocation) ================================================ FILE: tests/core/test_types_pbt.py ================================================ """Property-based tests for core domain types. Fuzzes Greeks algebra, Fill notional, Direction/Order/OptionType enum inversions, and get_order mapping with Hypothesis. """ import numpy as np import pytest from hypothesis import given, settings, assume from hypothesis import strategies as st from options_portfolio_backtester.core.types import ( Direction, OptionType, Order, Signal, Greeks, Fill, get_order, StockAllocation, ) # --------------------------------------------------------------------------- # Hypothesis strategies # --------------------------------------------------------------------------- greek_float = st.floats(min_value=-1000, max_value=1000, allow_nan=False, allow_infinity=False) scalar = st.floats(min_value=-100, max_value=100, allow_nan=False, allow_infinity=False) price = st.floats(min_value=0.01, max_value=10_000, allow_nan=False, allow_infinity=False) quantity = st.integers(min_value=1, max_value=10_000) spc = st.sampled_from([1, 10, 100, 1000]) commission = st.floats(min_value=0.0, max_value=1000, allow_nan=False, allow_infinity=False) slippage = st.floats(min_value=0.0, max_value=1000, allow_nan=False, allow_infinity=False) direction = st.sampled_from([Direction.BUY, Direction.SELL]) option_type = st.sampled_from([OptionType.CALL, OptionType.PUT]) signal = st.sampled_from([Signal.ENTRY, Signal.EXIT]) order = st.sampled_from([Order.BTO, Order.BTC, Order.STO, Order.STC]) pct = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False) greeks = st.builds( Greeks, delta=greek_float, gamma=greek_float, theta=greek_float, vega=greek_float, ) # --------------------------------------------------------------------------- # Direction # --------------------------------------------------------------------------- class TestDirectionPBT: @given(direction) def test_price_column_is_bid_or_ask(self, d): assert d.price_column in {"bid", "ask"} @given(direction) def test_buy_maps_to_ask(self, d): if d == Direction.BUY: assert d.price_column == "ask" else: assert d.price_column == "bid" @given(direction) def test_invert_changes_price_column(self, d): assert d.price_column != (~d).price_column @given(direction) def test_double_invert_identity(self, d): assert ~~d == d @given(direction) def test_invert_is_different(self, d): assert ~d != d # --------------------------------------------------------------------------- # OptionType # --------------------------------------------------------------------------- class TestOptionTypePBT: @given(option_type) def test_double_invert_identity(self, ot): assert ~~ot == ot @given(option_type) def test_invert_is_different(self, ot): assert ~ot != ot @given(option_type) def test_call_inverts_to_put(self, ot): if ot == OptionType.CALL: assert ~ot == OptionType.PUT else: assert ~ot == OptionType.CALL # --------------------------------------------------------------------------- # Order # --------------------------------------------------------------------------- class TestOrderPBT: @given(order) def test_double_invert_identity(self, o): assert ~~o == o @given(order) def test_invert_changes_buy_sell(self, o): """BTO↔STC, STO↔BTC: invert swaps buy/sell side.""" inv = ~o assert inv != o @given(direction, signal) def test_get_order_exhaustive(self, d, s): """get_order always returns a valid Order.""" o = get_order(d, s) assert isinstance(o, Order) @given(direction, signal) def test_get_order_entry_exit_paired(self, d, s): """Entry and exit orders for same direction are inverses.""" entry = get_order(d, Signal.ENTRY) exit_ = get_order(d, Signal.EXIT) assert ~entry == exit_ @given(direction) def test_buy_entry_is_bto(self, d): if d == Direction.BUY: assert get_order(d, Signal.ENTRY) == Order.BTO else: assert get_order(d, Signal.ENTRY) == Order.STO # --------------------------------------------------------------------------- # Greeks — extensive algebra properties # --------------------------------------------------------------------------- class TestGreeksFieldsPBT: @given(greeks) def test_has_four_fields(self, g): d = g.as_dict assert set(d.keys()) == {"delta", "gamma", "theta", "vega"} @given(greeks) def test_frozen(self, g): with pytest.raises(AttributeError): g.delta = 999.0 @given(greek_float, greek_float, greek_float, greek_float) def test_construction(self, d, ga, th, v): g = Greeks(delta=d, gamma=ga, theta=th, vega=v) assert g.delta == d assert g.gamma == ga assert g.theta == th assert g.vega == v class TestGreeksAdditionPBT: @given(greeks, greeks) @settings(max_examples=200) def test_commutative(self, a, b): assert _greeks_close(a + b, b + a) @given(greeks, greeks, greeks) @settings(max_examples=200) def test_associative(self, a, b, c): assert _greeks_close((a + b) + c, a + (b + c), tol=1e-8) @given(greeks) @settings(max_examples=100) def test_zero_identity(self, g): assert _greeks_close(g + Greeks(), g) @given(greeks) @settings(max_examples=100) def test_inverse(self, g): assert _greeks_close(g + (-g), Greeks(), tol=1e-10) class TestGreeksScalarMulPBT: @given(greeks, scalar) @settings(max_examples=200) def test_componentwise(self, g, s): r = g * s assert abs(r.delta - g.delta * s) < 1e-6 assert abs(r.gamma - g.gamma * s) < 1e-6 assert abs(r.theta - g.theta * s) < 1e-6 assert abs(r.vega - g.vega * s) < 1e-6 @given(greeks) @settings(max_examples=50) def test_identity(self, g): assert _greeks_close(g * 1.0, g) @given(greeks) @settings(max_examples=50) def test_zero(self, g): assert _greeks_close(g * 0.0, Greeks(), tol=1e-10) @given(greeks, scalar) @settings(max_examples=200) def test_rmul(self, g, s): assert _greeks_close(s * g, g * s) @given(greeks, greeks, scalar) @settings(max_examples=200) def test_distributes_over_addition(self, a, b, s): r1 = (a + b) * s r2 = (a * s) + (b * s) tol = max(abs(r1.delta), abs(r2.delta), 1) * 1e-6 + 1e-10 assert abs(r1.delta - r2.delta) < tol assert abs(r1.vega - r2.vega) < tol # --------------------------------------------------------------------------- # Fill # --------------------------------------------------------------------------- class TestFillPBT: @given(price, quantity, direction, spc) @settings(max_examples=200) def test_direction_sign_matches(self, p, q, d, s): f = Fill(price=p, quantity=q, direction=d, shares_per_contract=s) expected = -1 if d == Direction.BUY else 1 assert f.direction_sign == expected @given(price, quantity, spc, commission, slippage) @settings(max_examples=200) def test_sell_notional_exceeds_buy(self, p, q, s, comm, slip): """SELL notional > BUY notional for same price/qty (costs reduce both).""" buy = Fill(price=p, quantity=q, direction=Direction.BUY, shares_per_contract=s, commission=comm, slippage=slip) sell = Fill(price=p, quantity=q, direction=Direction.SELL, shares_per_contract=s, commission=comm, slippage=slip) assert sell.notional > buy.notional @given(price, quantity, direction, spc) @settings(max_examples=100) def test_zero_costs_notional(self, p, q, d, s): f = Fill(price=p, quantity=q, direction=d, shares_per_contract=s) expected = f.direction_sign * p * q * s assert abs(f.notional - expected) < 1e-6 @given(price, quantity, direction, spc, commission, slippage) @settings(max_examples=200) def test_costs_reduce_notional(self, p, q, d, s, comm, slip): f_clean = Fill(price=p, quantity=q, direction=d, shares_per_contract=s) f_dirty = Fill(price=p, quantity=q, direction=d, shares_per_contract=s, commission=comm, slippage=slip) assert f_dirty.notional <= f_clean.notional + 1e-10 @given(price, quantity, direction, spc, st.floats(min_value=0, max_value=500, allow_nan=False, allow_infinity=False), st.floats(min_value=0, max_value=500, allow_nan=False, allow_infinity=False)) @settings(max_examples=100) def test_higher_commission_lower_notional(self, p, q, d, s, c1, c2): assume(c2 > c1) f1 = Fill(price=p, quantity=q, direction=d, shares_per_contract=s, commission=c1) f2 = Fill(price=p, quantity=q, direction=d, shares_per_contract=s, commission=c2) assert f2.notional <= f1.notional + 1e-10 # --------------------------------------------------------------------------- # StockAllocation # --------------------------------------------------------------------------- class TestStockAllocationPBT: @given(st.text(min_size=1, max_size=10), pct) @settings(max_examples=50) def test_named_tuple_fields(self, sym, p): sa = StockAllocation(symbol=sym, percentage=p) assert sa.symbol == sym assert sa.percentage == p assert sa[0] == sym assert sa[1] == p # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _greeks_close(a: Greeks, b: Greeks, tol: float = 1e-10) -> bool: return ( abs(a.delta - b.delta) < tol and abs(a.gamma - b.gamma) < tol and abs(a.theta - b.theta) < tol and abs(a.vega - b.vega) < tol ) ================================================ FILE: tests/data/__init__.py ================================================ ================================================ FILE: tests/data/test_filter.py ================================================ """Tests for Schema DSL: Field filter operations.""" from options_portfolio_backtester.data.schema import Field def test_strike_eq_100(): """Test filter for 'strike' == 100""" strike_field = Field("strike", "strike") ft = strike_field == 100 assert ft.query == "strike == 100" def test_strike_lt_100(): """Test filter for 'strike' < 100""" strike_field = Field("strike", "strike") ft = strike_field < 100 assert ft.query == "strike < 100" def test_strike_ge_100(): """Test filter for 'strike' >= 100""" strike_field = Field("strike", "strike") ft = strike_field >= 100 assert ft.query == "strike >= 100" def test_negate_filter(): """Test negations of a filter""" symbol_field = Field("underlying", "underlying") ft = symbol_field == "SPX" negated = ~ft assert negated.query == "!(underlying == 'SPX')" def test_compose_filters_with_and(): """Test composition of two filters with and""" symbol_field = Field("underlying", "underlying") strike_field = Field("strike", "strike") ft1 = symbol_field == "SPX" ft2 = strike_field < 200 composed = ft1 & ft2 assert composed.query == "(underlying == 'SPX') & (strike < 200)" def test_compose_filters_with_or(): """Test composition of two filters with or""" strike_field = Field("strike", "strike") ft1 = strike_field >= 200 ft2 = strike_field < 100 composed = ft1 | ft2 assert composed.query == "((strike >= 200) | (strike < 100))" def test_compose_many_filters(): """Test composition of three filters mixing and + or""" symbol_field = Field("underlying", "underlying") strike_field = Field("strike", "strike") ft1 = symbol_field == "SPX" ft2 = strike_field >= 200 ft3 = strike_field < 100 composed = ft1 & (ft2 | ft3) assert composed.query == "(underlying == 'SPX') & (((strike >= 200) | (strike < 100)))" def test_add_number_to_field(): """Test addition of a number to a field""" strike_field = Field("strike", "strike") field = strike_field + 10 assert field.name == "strike + 10" assert field.mapping == "strike + 10" def test_subtract_number_from_field(): """Test subtraction of a number from a field""" strike_field = Field("strike", "strike") field = strike_field - 10 assert field.name == "strike - 10" assert field.mapping == "strike - 10" def test_multiply_field_by_number(): """Test multiplication of a field by a number""" underlying_last = Field("last", "underlying_last") field = underlying_last * 1.5 assert field.name == "last * 1.5" assert field.mapping == "underlying_last * 1.5" def test_multiply_on_left(): """Test multiplication of a field by a number on the *left*""" underlying_last = Field("last", "underlying_last") field = 1.5 * underlying_last assert field.name == "1.5 * last" assert field.mapping == "1.5 * underlying_last" def test_filter_from_combined_field(): """Test filter from a linear combination of fields""" underlying_last = Field("last", "underlying_last") strike_field = Field("strike", "strike") combined_filter = underlying_last == strike_field * 1.2 assert combined_filter.query == "underlying_last == strike * 1.2" ================================================ FILE: tests/data/test_property_based.py ================================================ """Property-based tests for Schema, Field, and Filter DSL.""" import numpy as np import pandas as pd from hypothesis import given, settings, assume, HealthCheck from hypothesis import strategies as st from options_portfolio_backtester.data.schema import Schema, Field, Filter # --------------------------------------------------------------------------- # Strategies # --------------------------------------------------------------------------- numeric_value = st.floats(min_value=-1000.0, max_value=1000.0, allow_nan=False, allow_infinity=False) positive_numeric = st.floats(min_value=0.01, max_value=1000.0, allow_nan=False, allow_infinity=False) def _make_df(n_rows, col_name="strike", values=None): """Build a simple numeric DataFrame for filter testing.""" if values is None: rng = np.random.default_rng(42) values = rng.uniform(50, 500, size=n_rows) return pd.DataFrame({col_name: values}) # --------------------------------------------------------------------------- # Filter properties # --------------------------------------------------------------------------- class TestFilterProperties: @given( st.floats(min_value=100.0, max_value=400.0, allow_nan=False), st.integers(min_value=10, max_value=200), ) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_filter_returns_subset(self, threshold, n_rows): """Compiled filter result is a subset of input rows.""" assume(n_rows > 0) df = _make_df(n_rows) f = Field("strike", "strike") filt = f >= threshold mask = filt(df) filtered = df[mask] assert len(filtered) <= len(df) @given(st.integers(min_value=5, max_value=200)) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_impossible_range_empty(self, n_rows): """min > max range → empty result.""" df = _make_df(n_rows) f = Field("strike", "strike") filt = (f >= 9999) & (f <= 0) mask = filt(df) assert mask.sum() == 0 @given( st.floats(min_value=100.0, max_value=400.0, allow_nan=False), st.integers(min_value=10, max_value=200), ) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_numeric_filter_bounds(self, threshold, n_rows): """All matched values satisfy the filter condition.""" assume(n_rows > 0) df = _make_df(n_rows) f = Field("strike", "strike") filt = f >= threshold mask = filt(df) matched = df.loc[mask, "strike"] assert (matched >= threshold - 1e-10).all() @given( st.floats(min_value=100.0, max_value=300.0, allow_nan=False), st.floats(min_value=300.0, max_value=500.0, allow_nan=False), st.integers(min_value=10, max_value=200), ) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_and_is_intersection(self, lo, hi, n_rows): """AND of two filters = intersection of their individual results.""" assume(lo < hi and n_rows > 0) df = _make_df(n_rows) f = Field("strike", "strike") f1 = f >= lo f2 = f <= hi combined = f1 & f2 mask_1 = f1(df) mask_2 = f2(df) mask_and = combined(df) expected = mask_1 & mask_2 assert (mask_and == expected).all() @given( st.floats(min_value=100.0, max_value=300.0, allow_nan=False), st.floats(min_value=300.0, max_value=500.0, allow_nan=False), st.integers(min_value=10, max_value=200), ) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_or_is_union(self, lo, hi, n_rows): """OR of two filters = union of their individual results.""" assume(lo < hi and n_rows > 0) df = _make_df(n_rows) f = Field("strike", "strike") f1 = f <= lo f2 = f >= hi combined = f1 | f2 mask_1 = f1(df) mask_2 = f2(df) mask_or = combined(df) expected = mask_1 | mask_2 assert (mask_or == expected).all() ================================================ FILE: tests/data/test_providers.py ================================================ """Tests for data providers.""" import os import pytest import pandas as pd from options_portfolio_backtester.data.providers import ( CsvOptionsProvider, CsvStocksProvider, DataProvider, OptionsDataProvider, StocksDataProvider, ) from options_portfolio_backtester.data.schema import Schema TEST_DIR = os.path.join(os.path.dirname(__file__), "..", "test_data") STOCKS_FILE = os.path.join(TEST_DIR, "test_data_stocks.csv") OPTIONS_FILE = os.path.join(TEST_DIR, "test_data_options.csv") @pytest.fixture def options_provider(): return CsvOptionsProvider(OPTIONS_FILE) @pytest.fixture def stocks_provider(): return CsvStocksProvider(STOCKS_FILE) class TestCsvOptionsProvider: def test_is_data_provider(self, options_provider): assert isinstance(options_provider, DataProvider) assert isinstance(options_provider, OptionsDataProvider) def test_has_schema(self, options_provider): assert options_provider.schema is not None def test_data_is_dataframe(self, options_provider): assert isinstance(options_provider.data, pd.DataFrame) def test_start_end_dates(self, options_provider): assert isinstance(options_provider.start_date, pd.Timestamp) assert isinstance(options_provider.end_date, pd.Timestamp) assert options_provider.start_date <= options_provider.end_date def test_len(self, options_provider): assert len(options_provider) > 0 def test_iter_dates(self, options_provider): groups = list(options_provider.iter_dates()) assert len(groups) > 0 class TestCsvStocksProvider: def test_is_data_provider(self, stocks_provider): assert isinstance(stocks_provider, DataProvider) assert isinstance(stocks_provider, StocksDataProvider) def test_has_schema(self, stocks_provider): assert stocks_provider.schema is not None def test_data_is_dataframe(self, stocks_provider): assert isinstance(stocks_provider.data, pd.DataFrame) def test_start_end_dates(self, stocks_provider): assert isinstance(stocks_provider.start_date, pd.Timestamp) assert isinstance(stocks_provider.end_date, pd.Timestamp) def test_len(self, stocks_provider): assert len(stocks_provider) > 0 class TestSchemaReExport: def test_schema_import(self): from options_portfolio_backtester.data.schema import Schema, Field, Filter assert Schema is not None assert Field is not None assert Filter is not None def test_options_schema(self): s = Schema.options() assert "bid" in s assert "ask" in s ================================================ FILE: tests/data/test_providers_extended.py ================================================ """Extended tests for data providers — accessors, iteration, edge cases.""" import os import pandas as pd import pytest from options_portfolio_backtester.data.providers import ( TiingoData, HistoricalOptionsData, CsvOptionsProvider, CsvStocksProvider, ) from options_portfolio_backtester.data.schema import Schema, Filter @pytest.fixture def stocks_csv(tmp_path): """Create a minimal stocks CSV for testing.""" csv = tmp_path / "stocks.csv" csv.write_text( "symbol,date,open,close,high,low,volume,adjClose,adjHigh,adjLow,adjOpen,adjVolume,divCash,splitFactor\n" "SPY,2020-01-02,320,322,323,319,1000000,322,323,319,320,1000000,0,1\n" "SPY,2020-01-03,322,321,324,320,1100000,321,324,320,322,1100000,0,1\n" "SPY,2020-01-06,321,323,325,320,1200000,323,325,320,321,1200000,0,1\n" ) return str(csv) @pytest.fixture def options_csv(tmp_path): """Create a minimal options CSV for testing.""" csv = tmp_path / "options.csv" csv.write_text( "underlying,underlying_last,quotedate,optionroot,type,expiration,strike,bid,ask,volume,openinterest,last,impliedvol,delta,gamma,theta,vega\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" "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" "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" "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" "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" "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" ) return str(csv) class TestTiingoData: def test_len(self, stocks_csv): td = TiingoData(stocks_csv) assert len(td) == 3 def test_getitem_schema_key(self, stocks_csv): td = TiingoData(stocks_csv) result = td["symbol"] assert isinstance(result, pd.Series) assert (result == "SPY").all() def test_setitem(self, stocks_csv): td = TiingoData(stocks_csv) td["custom"] = [1, 2, 3] assert "custom" in td.schema assert td._data["custom"].tolist() == [1, 2, 3] def test_repr(self, stocks_csv): td = TiingoData(stocks_csv) r = repr(td) assert "SPY" in r def test_start_end_dates(self, stocks_csv): td = TiingoData(stocks_csv) assert td.start_date == pd.Timestamp("2020-01-02") assert td.end_date == pd.Timestamp("2020-01-06") def test_iter_dates(self, stocks_csv): td = TiingoData(stocks_csv) dates = list(td.iter_dates()) assert len(dates) == 3 def test_apply_filter(self, stocks_csv): td = TiingoData(stocks_csv) f = Filter("adjClose > 321") result = td.apply_filter(f) assert len(result) == 2 # 322 and 323 def test_getattr_passthrough_method(self, stocks_csv): """__getattr__ delegates to _data; head() is a DataFrame method.""" td = TiingoData(stocks_csv) result = td.head(2) assert isinstance(result, pd.DataFrame) assert len(result) == 2 def test_getattr_passthrough_property(self, stocks_csv): """__getattr__ delegates to _data; shape is a property.""" td = TiingoData(stocks_csv) assert td.shape == (3, 14) # 3 rows, 14 columns def test_iter_months(self, stocks_csv): td = TiingoData(stocks_csv) months = list(td.iter_months()) # All 3 dates are in January 2020, so iter_months groups to 1 month assert len(months) >= 1 def test_sma(self, stocks_csv): td = TiingoData(stocks_csv) td.sma(2) assert "sma" in td._data.columns assert "sma" in td.schema class TestHistoricalOptionsData: def test_len(self, options_csv): hod = HistoricalOptionsData(options_csv) assert len(hod) == 6 def test_dte_column_added(self, options_csv): hod = HistoricalOptionsData(options_csv) assert "dte" in hod._data.columns assert (hod._data["dte"] > 0).all() def test_getitem_schema_key(self, options_csv): hod = HistoricalOptionsData(options_csv) result = hod["underlying"] assert (result == "SPY").all() def test_getitem_series_indexing(self, options_csv): hod = HistoricalOptionsData(options_csv) mask = hod._data["type"] == "call" result = hod[mask] assert len(result) == 3 def test_setitem(self, options_csv): hod = HistoricalOptionsData(options_csv) hod["flag"] = True assert "flag" in hod.schema def test_repr(self, options_csv): hod = HistoricalOptionsData(options_csv) r = repr(hod) assert "SPY" in r def test_iter_dates(self, options_csv): hod = HistoricalOptionsData(options_csv) dates = list(hod.iter_dates()) assert len(dates) == 3 def test_iter_months(self, options_csv): hod = HistoricalOptionsData(options_csv) months = list(hod.iter_months()) assert len(months) >= 1 def test_getattr_passthrough(self, options_csv): hod = HistoricalOptionsData(options_csv) result = hod.head(3) assert isinstance(result, pd.DataFrame) assert len(result) == 3 def test_apply_filter(self, options_csv): hod = HistoricalOptionsData(options_csv) f = Filter("strike > 400") result = hod.apply_filter(f) assert len(result) == 3 # only the call rows at strike 450 def test_start_end_dates(self, options_csv): hod = HistoricalOptionsData(options_csv) assert hod.start_date == pd.Timestamp("2020-01-02") assert hod.end_date == pd.Timestamp("2020-01-06") class TestCsvStocksProvider: def test_data_property(self, stocks_csv): p = CsvStocksProvider(stocks_csv) assert isinstance(p.data, pd.DataFrame) assert len(p.data) == 3 def test_underscore_data(self, stocks_csv): p = CsvStocksProvider(stocks_csv) assert p._data is p.data def test_schema(self, stocks_csv): p = CsvStocksProvider(stocks_csv) assert isinstance(p.schema, Schema) def test_setitem_getitem(self, stocks_csv): p = CsvStocksProvider(stocks_csv) p["flag"] = [1, 2, 3] assert p._data["flag"].tolist() == [1, 2, 3] def test_len(self, stocks_csv): p = CsvStocksProvider(stocks_csv) assert len(p) == 3 def test_iter_dates(self, stocks_csv): p = CsvStocksProvider(stocks_csv) dates = list(p.iter_dates()) assert len(dates) == 3 def test_iter_months(self, stocks_csv): p = CsvStocksProvider(stocks_csv) months = list(p.iter_months()) assert len(months) >= 1 def test_apply_filter(self, stocks_csv): p = CsvStocksProvider(stocks_csv) f = Filter("adjClose > 321") result = p.apply_filter(f) assert len(result) == 2 def test_start_end_date(self, stocks_csv): p = CsvStocksProvider(stocks_csv) assert p.start_date == pd.Timestamp("2020-01-02") assert p.end_date == pd.Timestamp("2020-01-06") def test_sma(self, stocks_csv): p = CsvStocksProvider(stocks_csv) p.sma(2) assert "sma" in p._data.columns class TestCsvOptionsProvider: def test_data_property(self, options_csv): p = CsvOptionsProvider(options_csv) assert isinstance(p.data, pd.DataFrame) assert len(p.data) == 6 def test_underscore_data(self, options_csv): p = CsvOptionsProvider(options_csv) assert p._data is p.data def test_setitem_getitem(self, options_csv): p = CsvOptionsProvider(options_csv) p["flag"] = range(6) result = p["flag"] assert len(result) == 6 def test_len(self, options_csv): p = CsvOptionsProvider(options_csv) assert len(p) == 6 def test_iter_dates(self, options_csv): p = CsvOptionsProvider(options_csv) dates = list(p.iter_dates()) assert len(dates) == 3 def test_iter_months(self, options_csv): p = CsvOptionsProvider(options_csv) months = list(p.iter_months()) assert len(months) >= 1 def test_apply_filter(self, options_csv): p = CsvOptionsProvider(options_csv) f = Filter("strike > 400") result = p.apply_filter(f) assert len(result) == 3 def test_start_end_date(self, options_csv): p = CsvOptionsProvider(options_csv) assert p.start_date == pd.Timestamp("2020-01-02") assert p.end_date == pd.Timestamp("2020-01-06") def test_schema(self, options_csv): p = CsvOptionsProvider(options_csv) assert isinstance(p.schema, Schema) ================================================ FILE: tests/data/test_schema.py ================================================ """Tests for Schema, Field, and Filter DSL.""" import pandas as pd import pytest from options_portfolio_backtester.data.schema import Schema, Field, Filter class TestSchema: def test_stocks_factory(self): s = Schema.stocks() assert "symbol" in s assert "date" in s assert "adjClose" in s def test_options_factory(self): s = Schema.options() assert "underlying" in s assert "strike" in s assert "bid" in s assert "ask" in s def test_getitem(self): s = Schema.stocks() assert s["symbol"] == "symbol" def test_getattr_returns_field(self): s = Schema.stocks() f = s.symbol assert isinstance(f, Field) assert f.mapping == "symbol" def test_update(self): s = Schema.stocks() s.update({"custom": "custom_col"}) assert s["custom"] == "custom_col" def test_contains(self): s = Schema.stocks() assert "symbol" in s assert "nonexistent" not in s def test_setitem(self): s = Schema.stocks() s["new_field"] = "new_col" assert s["new_field"] == "new_col" def test_iter(self): s = Schema.stocks() pairs = list(s) assert any(k == "symbol" for k, _ in pairs) def test_repr(self): s = Schema.stocks() r = repr(s) assert "Schema" in r def test_equality(self): s1 = Schema.stocks() s2 = Schema.stocks() assert s1 == s2 def test_inequality_different_schema(self): s1 = Schema.stocks() s2 = Schema.options() assert s1 != s2 def test_equality_with_non_schema(self): s = Schema.stocks() assert s != "not a schema" class TestField: def test_repr(self): f = Field("strike", "strike") assert "Field" in repr(f) assert "strike" in repr(f) def test_comparison_operators(self): s = Schema.options() f = s.strike > 100 assert isinstance(f, Filter) assert "100" in f.query def test_equality_operator_string(self): s = Schema.options() f = s.underlying == "SPY" assert isinstance(f, Filter) assert "'SPY'" in f.query def test_arithmetic_field_field(self): s = Schema.options() combined = s.strike + s.bid assert isinstance(combined, Field) assert "+" in combined.mapping def test_arithmetic_field_scalar(self): s = Schema.options() combined = s.strike * 1.05 assert isinstance(combined, Field) assert "*" in combined.mapping def test_radd(self): s = Schema.options() combined = 100 + s.strike assert isinstance(combined, Field) def test_rsub(self): s = Schema.options() combined = 100 - s.strike assert isinstance(combined, Field) def test_rtruediv(self): s = Schema.options() combined = 1 / s.strike assert isinstance(combined, Field) def test_rmul(self): s = Schema.options() combined = 2 * s.strike assert isinstance(combined, Field) def test_ne_operator(self): s = Schema.options() f = s.underlying != "SPY" assert isinstance(f, Filter) assert "!=" in f.query class TestFilter: def test_and(self): s = Schema.options() f = (s.strike > 100) & (s.strike < 200) assert isinstance(f, Filter) assert "&" in f.query def test_or(self): s = Schema.options() f = (s.strike > 100) | (s.strike < 50) assert isinstance(f, Filter) assert "|" in f.query def test_invert(self): s = Schema.options() f = ~(s.strike > 100) assert isinstance(f, Filter) assert "!" in f.query def test_call_on_dataframe(self): df = pd.DataFrame({"strike": [100, 200, 300]}) s = Schema.options() f = s.strike > 150 result = f(df) assert isinstance(result, pd.Series) assert result.sum() == 2 def test_repr(self): f = Filter("strike > 100") assert "Filter" in repr(f) assert "strike > 100" in repr(f) ================================================ FILE: tests/engine/__init__.py ================================================ ================================================ FILE: tests/engine/test_algo_adapters.py ================================================ from __future__ import annotations import warnings import pandas as pd import pytest from options_portfolio_backtester.engine.algo_adapters import ( BudgetPercent, EngineRunMonthly, EnginePipelineContext, EngineStepDecision, ExitOnThreshold, MaxGreekExposure, RangeFilter, SelectByDTE, SelectByDelta, IVRankFilter, ) from options_portfolio_backtester.engine.engine import BacktestEngine from options_portfolio_backtester.core.types import Greeks from tests.engine.test_engine import _buy_strategy, _ivy_stocks, _options_data, _stocks_data def _run_with_algos(algos): stocks = _ivy_stocks() stocks_data = _stocks_data() options_data = _options_data() schema = options_data.schema engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0.0}, algos=algos, ) engine.stocks = stocks engine.stocks_data = stocks_data engine.options_data = options_data engine.options_strategy = _buy_strategy(schema) engine.run(rebalance_freq=1) return engine def _dummy_ctx(**overrides) -> EnginePipelineContext: defaults = dict( date=pd.Timestamp("2024-01-02"), stocks=pd.DataFrame(), options=pd.DataFrame(), total_capital=100_000.0, current_cash=50_000.0, current_greeks=Greeks(delta=0.5, gamma=0.01, theta=-0.02, vega=0.1), options_allocation=3000.0, ) defaults.update(overrides) return EnginePipelineContext(**defaults) # --------------------------------------------------------------------------- # EngineRunMonthly # --------------------------------------------------------------------------- def test_engine_algo_monthly_gate_translates(): """EngineRunMonthly is consumed by _translate_algos_to_config (no-op for Rust).""" stocks = _ivy_stocks() stocks_data = _stocks_data() options_data = _options_data() schema = options_data.schema engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0.0}, algos=[EngineRunMonthly()], ) engine.stocks = stocks engine.stocks_data = stocks_data engine.options_data = options_data engine.options_strategy = _buy_strategy(schema) # EngineRunMonthly should be consumed without error; Rust handles rebalancing. result = engine.run(rebalance_freq=1, rebalance_unit="B") assert not result.empty # run() returns a balance DataFrame assert len(engine.algos) == 0 # algos consumed by translation def test_engine_run_monthly_reset(): algo = EngineRunMonthly() ctx_jan = _dummy_ctx(date=pd.Timestamp("2024-01-02")) ctx_jan2 = _dummy_ctx(date=pd.Timestamp("2024-01-15")) assert algo(ctx_jan).status == "continue" assert algo(ctx_jan2).status == "skip_day" algo.reset() assert algo(ctx_jan).status == "continue" # --------------------------------------------------------------------------- # BudgetPercent # --------------------------------------------------------------------------- def test_budget_percent_zero_blocks_option_entries(): engine = _run_with_algos([BudgetPercent(0.0)]) # With 0% budget, options allocation is zero — no options should be bought assert engine.trade_log.empty or (engine.trade_log["totals"]["qty"] <= 0).all() def test_budget_percent_sets_allocation(): algo = BudgetPercent(0.05) ctx = _dummy_ctx(total_capital=200_000.0) algo(ctx) assert ctx.options_allocation == 10_000.0 def test_budget_percent_clamps_negative_capital(): algo = BudgetPercent(0.05) ctx = _dummy_ctx(total_capital=-100.0) algo(ctx) assert ctx.options_allocation == 0.0 # --------------------------------------------------------------------------- # RangeFilter (item 8 dedup) # --------------------------------------------------------------------------- def test_range_filter_appends_entry_filter(): flt = RangeFilter(column="delta", min_val=-0.3, max_val=-0.1) ctx = _dummy_ctx() result = flt(ctx) assert result.status == "continue" assert len(ctx.entry_filters) == 1 df = pd.DataFrame({"delta": [-0.5, -0.2, -0.1, 0.0, 0.3]}) mask = ctx.entry_filters[0](df) assert mask.tolist() == [False, True, True, False, False] def test_range_filter_missing_column_passes_all(): flt = RangeFilter(column="nonexistent", min_val=0, max_val=1) ctx = _dummy_ctx() flt(ctx) df = pd.DataFrame({"other": [1, 2, 3]}) mask = ctx.entry_filters[0](df) assert mask.all() # --------------------------------------------------------------------------- # SelectByDelta / SelectByDTE / IVRankFilter (backward-compat aliases) # --------------------------------------------------------------------------- def test_select_by_delta_returns_range_filter(): flt = SelectByDelta(min_delta=-0.5, max_delta=-0.1) assert isinstance(flt, RangeFilter) assert flt.column == "delta" def test_select_by_dte_returns_range_filter(): flt = SelectByDTE(min_dte=30, max_dte=60) assert isinstance(flt, RangeFilter) assert flt.column == "dte" assert flt.min_val == 30.0 assert flt.max_val == 60.0 def test_iv_rank_filter_returns_range_filter(): flt = IVRankFilter(min_rank=0.3, max_rank=0.8, column="iv_rank") assert isinstance(flt, RangeFilter) assert flt.column == "iv_rank" def test_select_by_dte_strict_filter_skips_candidates(): """SelectByDTE(0,1) translates to a tight filter that blocks most entries.""" engine = _run_with_algos([SelectByDTE(min_dte=0, max_dte=1)]) # With DTE 0-1, almost no options qualify → few or no trades tl = engine.trade_log assert tl.empty or len(tl) <= 2 # at most a couple if data happens to match # --------------------------------------------------------------------------- # MaxGreekExposure # --------------------------------------------------------------------------- def test_max_greek_exposure_delta_blocks(): algo = MaxGreekExposure(max_abs_delta=0.3) ctx = _dummy_ctx(current_greeks=Greeks(delta=0.5, gamma=0, theta=0, vega=0)) result = algo(ctx) assert result.status == "skip_day" assert "delta" in result.message def test_max_greek_exposure_vega_blocks(): algo = MaxGreekExposure(max_abs_vega=0.05) ctx = _dummy_ctx(current_greeks=Greeks(delta=0, gamma=0, theta=0, vega=0.1)) result = algo(ctx) assert result.status == "skip_day" assert "vega" in result.message def test_max_greek_exposure_within_limits_continues(): algo = MaxGreekExposure(max_abs_delta=1.0, max_abs_vega=1.0) ctx = _dummy_ctx(current_greeks=Greeks(delta=0.1, gamma=0, theta=0, vega=0.05)) result = algo(ctx) assert result.status == "continue" def test_max_greek_exposure_none_limits_pass(): algo = MaxGreekExposure() ctx = _dummy_ctx(current_greeks=Greeks(delta=999, gamma=0, theta=0, vega=999)) result = algo(ctx) assert result.status == "continue" # --------------------------------------------------------------------------- # ExitOnThreshold (item 17) # --------------------------------------------------------------------------- def test_exit_on_threshold_sets_override(): algo = ExitOnThreshold(profit_pct=0.5, loss_pct=0.3) ctx = _dummy_ctx() algo(ctx) assert ctx.exit_threshold_override == (0.5, 0.3) def test_exit_on_threshold_warns_on_all_inf(): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") ExitOnThreshold() assert len(w) == 1 assert "no effect" in str(w[0].message).lower() def test_exit_on_threshold_no_warn_when_finite(): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") ExitOnThreshold(profit_pct=0.5) assert len(w) == 0 # --------------------------------------------------------------------------- # Events dataframe structure (item 14 + item 18 flattened data) # --------------------------------------------------------------------------- def test_events_dataframe_has_flattened_columns(): engine = _run_with_algos([]) events = engine.events_dataframe() assert "date" in events.columns assert "event" in events.columns assert "status" in events.columns # "data" column should NOT exist (flattened into top-level) assert "data" not in events.columns def test_events_dataframe_contains_cash_from_rebalance_start(): engine = _run_with_algos([]) events = engine.events_dataframe() rebal_starts = events[events["event"] == "rebalance_start"] if not rebal_starts.empty: assert "cash" in rebal_starts.columns assert pd.notna(rebal_starts["cash"].iloc[0]) def test_events_dataframe_empty_when_no_events(): from options_portfolio_backtester.engine.engine import BacktestEngine engine = BacktestEngine({"stocks": 0.97, "options": 0.03, "cash": 0.0}) events = engine.events_dataframe() assert events.empty assert "date" in events.columns assert "event" in events.columns assert "status" in events.columns ================================================ FILE: tests/engine/test_capital_conservation.py ================================================ """Capital conservation invariant: no money should be created or destroyed. At every row in the balance sheet: cash + stocks_capital + options_capital ≈ total_capital This catches bugs like the one where _execute_option_entries unconditionally added options_allocation to current_cash, creating money from thin air in AQR framing. """ import math import os import numpy as np import pytest from options_portfolio_backtester.core.types import ( Direction, OptionType as Type, Stock, ) from options_portfolio_backtester.data.providers import ( HistoricalOptionsData, TiingoData, ) from options_portfolio_backtester.engine.engine import BacktestEngine from options_portfolio_backtester.execution.cost_model import NoCosts from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg TEST_DIR = os.path.join(os.path.dirname(__file__), "..", "test_data") STOCKS_FILE = os.path.join(TEST_DIR, "ivy_5assets_data.csv") OPTIONS_FILE = os.path.join(TEST_DIR, "options_data.csv") def _ivy_stocks(): return [ Stock("VTI", 0.2), Stock("VEU", 0.2), Stock("BND", 0.2), Stock("VNQ", 0.2), Stock("DBC", 0.2), ] def _stocks_data(): data = TiingoData(STOCKS_FILE) data._data["adjClose"] = 10 return data def _options_data(): data = HistoricalOptionsData(OPTIONS_FILE) data._data.at[2, "ask"] = 1 data._data.at[2, "bid"] = 0.5 data._data.at[51, "ask"] = 1.5 data._data.at[50, "bid"] = 0.5 data._data.at[130, "bid"] = 0.5 data._data.at[131, "bid"] = 1.5 data._data.at[206, "bid"] = 0.5 data._data.at[207, "bid"] = 1.5 return data def _buy_strategy(schema): strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) leg.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat.add_legs([leg]) strat.add_exit_thresholds(profit_pct=math.inf, loss_pct=math.inf) return strat def _assert_balance_components_sum(balance, rtol=1e-6): """Assert cash + stocks + options = total at every row.""" component_sum = ( balance["cash"] + balance["stocks capital"] + balance["options capital"] ) total = balance["total capital"] mismatches = ~np.isclose(component_sum, total, rtol=rtol, atol=0.01) if mismatches.any(): bad = balance[mismatches][["cash", "stocks capital", "options capital", "total capital"]].head(5) sums = component_sum[mismatches].head(5) raise AssertionError( f"Components don't sum to total at {mismatches.sum()} rows.\n" f"First mismatches:\n{bad}\nComponent sums:\n{sums}" ) def _assert_no_capital_spike(balance, initial_capital, max_first_day_ratio=1.01): """Assert total capital never jumps above initial on the first day. The first rebalance should not create money — total capital on day 1 should be ≤ initial_capital (plus a small tolerance for rounding). """ first_total = balance["total capital"].iloc[1] if len(balance) > 1 else balance["total capital"].iloc[0] assert first_total <= initial_capital * max_first_day_ratio, ( f"Capital spiked on first day: {first_total:.2f} > {initial_capital * max_first_day_ratio:.2f}. " f"Possible money creation." ) class TestCapitalConservationAQR: """AQR framing: sell stocks to fund puts. No external money.""" @pytest.fixture(autouse=True) def setup(self): stocks_data = _stocks_data() options_data = _options_data() schema = options_data.schema self.engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), initial_capital=100_000, ) self.engine.stocks = _ivy_stocks() self.engine.stocks_data = stocks_data self.engine.options_data = options_data self.engine.options_strategy = _buy_strategy(schema) self.engine.run(rebalance_freq=1) def test_components_sum_to_total(self): _assert_balance_components_sum(self.engine.balance) def test_no_first_day_spike(self): _assert_no_capital_spike(self.engine.balance, 100_000) def test_final_capital_plausible(self): """With NoCosts and OTM puts, total capital should stay near initial.""" final = self.engine.balance["total capital"].iloc[-1] # Should not grow by more than 50% from options alone on small test data assert final < 100_000 * 1.5, f"Suspiciously high final capital: {final}" # Should not go negative assert final > 0 class TestCapitalConservationSpitznagel: """Spitznagel framing: 100% stocks + external put budget.""" @pytest.fixture(autouse=True) def setup(self): stocks_data = _stocks_data() options_data = _options_data() schema = options_data.schema self.engine = BacktestEngine( {"stocks": 1.0, "options": 0.0, "cash": 0}, cost_model=NoCosts(), initial_capital=100_000, ) self.engine.options_budget_pct = 0.03 self.engine.stocks = _ivy_stocks() self.engine.stocks_data = stocks_data self.engine.options_data = options_data self.engine.options_strategy = _buy_strategy(schema) self.engine.run(rebalance_freq=1) def test_components_sum_to_total(self): _assert_balance_components_sum(self.engine.balance) class TestCapitalConservationNoTrades: """With impossible entry filter, no trades should happen and capital should be stable.""" @pytest.fixture(autouse=True) def setup(self): stocks_data = _stocks_data() options_data = _options_data() schema = options_data.schema strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) # Impossible filter: delta > 0 for puts (never true) leg.entry_filter = (schema.underlying == "SPX") & (schema.delta > 0) leg.exit_filter = schema.dte <= 30 strat.add_legs([leg]) self.engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), initial_capital=100_000, ) self.engine.stocks = _ivy_stocks() self.engine.stocks_data = stocks_data self.engine.options_data = options_data self.engine.options_strategy = strat self.engine.run(rebalance_freq=1) def test_components_sum_to_total(self): _assert_balance_components_sum(self.engine.balance) def test_options_capital_always_zero(self): assert (self.engine.balance["options capital"] == 0).all() def test_no_trades(self): assert self.engine.trade_log.empty class TestCapitalConservationHighBudget: """Stress test: 50% AQR allocation. Should NOT create money.""" @pytest.fixture(autouse=True) def setup(self): stocks_data = _stocks_data() options_data = _options_data() schema = options_data.schema self.engine = BacktestEngine( {"stocks": 0.50, "options": 0.50, "cash": 0}, cost_model=NoCosts(), initial_capital=100_000, ) self.engine.stocks = _ivy_stocks() self.engine.stocks_data = stocks_data self.engine.options_data = options_data self.engine.options_strategy = _buy_strategy(schema) self.engine.run(rebalance_freq=1) def test_components_sum_to_total(self): _assert_balance_components_sum(self.engine.balance) def test_no_first_day_spike(self): _assert_no_capital_spike(self.engine.balance, 100_000) # --------------------------------------------------------------------------- # New tests for the skip-day cash conservation fix # --------------------------------------------------------------------------- class TestSkipDayCashConservation: """When _execute_option_entries returns early (no candidates), the options allocation money must stay as cash -- it must not be destroyed. Uses an impossible entry filter (delta > 0 for puts) so puts are never found. Verifies: - cash + stocks = total at every step (options capital is always 0) - cash is never lower than the stocks-only floor (i.e. options money is always preserved in cash on skip days) """ @pytest.fixture(autouse=True) def setup(self): stocks_data = _stocks_data() options_data = _options_data() schema = options_data.schema # Impossible filter: delta > 0 for puts (never satisfied) strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) leg.entry_filter = (schema.underlying == "SPX") & (schema.delta > 0) leg.exit_filter = schema.dte <= 30 strat.add_legs([leg]) strat.add_exit_thresholds(profit_pct=math.inf, loss_pct=math.inf) self.engine = BacktestEngine( {"stocks": 0.90, "options": 0.10, "cash": 0}, cost_model=NoCosts(), initial_capital=100_000, ) self.engine.stocks = _ivy_stocks() self.engine.stocks_data = stocks_data self.engine.options_data = options_data self.engine.options_strategy = strat self.engine.run(rebalance_freq=1) def test_components_sum_to_total(self): _assert_balance_components_sum(self.engine.balance) def test_options_capital_always_zero(self): assert (self.engine.balance["options capital"] == 0).all(), ( "Options capital should be zero when no puts are ever entered." ) def test_cash_never_below_options_floor(self): """Cash should always hold at least the options portion of total capital (since puts were never bought, the money stays as cash).""" bal = self.engine.balance total = bal["total capital"] # On skip days, cash = total - stocks. Since options_allocation # was 10% of total, cash should be >= 10% of total (minus rounding). expected_min_cash = total * 0.10 - 0.01 # Skip the first row (initial balance row, before any rebalance) actual_cash = bal["cash"].iloc[1:] expected = expected_min_cash.iloc[1:] violations = actual_cash < expected if violations.any(): bad = bal.iloc[1:][violations][["cash", "stocks capital", "total capital"]].head(5) raise AssertionError( f"Cash fell below expected options floor at {violations.sum()} rows.\n" f"First violations:\n{bad}" ) def test_no_trades(self): assert self.engine.trade_log.empty def test_total_stable_with_flat_prices(self): """With constant stock prices and no options, total capital should be approximately constant (NoCosts means no transaction fees).""" bal = self.engine.balance total = bal["total capital"].iloc[1:] # skip pre-rebalance row assert total.max() <= 100_000 * 1.001, ( f"Total grew unexpectedly: {total.max()}" ) assert total.min() >= 100_000 * 0.999, ( f"Total shrunk unexpectedly: {total.min()}" ) class TestAQRDeploymentNeverExceedsTotal: """At every point: stocks_capital + options_capital + cash == total_capital. Total capital must never exceed what is explained by stock returns and option P&L. This catches the original 'money from thin air' bug where options_allocation was double-counted. Tests across several AQR allocation ratios. """ @pytest.fixture( params=[ {"stocks": 0.97, "options": 0.03, "cash": 0}, {"stocks": 0.80, "options": 0.20, "cash": 0}, {"stocks": 0.50, "options": 0.50, "cash": 0}, {"stocks": 0.50, "options": 0.30, "cash": 0.20}, ], ids=["97/3/0", "80/20/0", "50/50/0", "50/30/20"], ) def engine(self, request): stocks_data = _stocks_data() options_data = _options_data() schema = options_data.schema eng = BacktestEngine( request.param, cost_model=NoCosts(), initial_capital=100_000, ) eng.stocks = _ivy_stocks() eng.stocks_data = stocks_data eng.options_data = options_data eng.options_strategy = _buy_strategy(schema) eng.run(rebalance_freq=1) return eng def test_components_sum_to_total(self, engine): _assert_balance_components_sum(engine.balance) def test_total_never_above_initial_on_flat_prices(self, engine): """Stock prices are flat at 10 (set by _stocks_data), so stocks generate no return. Total capital should never materially exceed initial capital by an unreasonable amount. With large options allocations, option MTM can legitimately swing, so we use a generous bound (3x) that catches runaway money creation but not legitimate option P&L.""" bal = engine.balance total = bal["total capital"] assert total.max() < 100_000 * 3.0, ( f"Total capital suspiciously high: {total.max():.2f}. " f"Possible money creation." ) def test_deployment_never_exceeds_total(self, engine): """stocks_capital + options_capital should never exceed total_capital. If it does, cash would be negative, meaning we spent money we didn't have.""" bal = engine.balance deployed = bal["stocks capital"] + bal["options capital"] total = bal["total capital"] overdeployed = deployed > total + 0.01 if overdeployed.any(): bad = bal[overdeployed][ ["cash", "stocks capital", "options capital", "total capital"] ].head(5) raise AssertionError( f"Deployed capital exceeds total at {overdeployed.sum()} rows.\n" f"First overdeployments:\n{bad}" ) def test_cash_never_negative(self, engine): """Cash should never go meaningfully negative (small float noise OK).""" bal = engine.balance cash = bal["cash"] bad_cash = cash < -0.01 if bad_cash.any(): bad = bal[bad_cash][ ["cash", "stocks capital", "options capital", "total capital"] ].head(5) raise AssertionError( f"Cash went negative at {bad_cash.sum()} rows.\n" f"First violations:\n{bad}" ) class TestAQRRebalanceCycleAccounting: """After a full cycle (buy puts -> puts expire/exit -> rebalance), verify that total_capital change is explained only by stock price moves and option P&L -- not by cash appearing or disappearing. Since _stocks_data() sets all prices to 10 (flat), and we use NoCosts, the total capital change should come purely from option value changes. We verify this by checking that cash + stocks + options = total at every row, and that the net change in total capital matches the net change in the component sum. """ @pytest.fixture(autouse=True) def setup(self): stocks_data = _stocks_data() options_data = _options_data() schema = options_data.schema self.engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), initial_capital=100_000, ) self.engine.stocks = _ivy_stocks() self.engine.stocks_data = stocks_data self.engine.options_data = options_data self.engine.options_strategy = _buy_strategy(schema) self.engine.run(rebalance_freq=1) def test_components_sum_to_total(self): _assert_balance_components_sum(self.engine.balance) def test_total_change_equals_component_change(self): """Delta(total) == Delta(cash) + Delta(stocks) + Delta(options). If not, money leaked in or out.""" bal = self.engine.balance d_total = bal["total capital"].diff().iloc[1:] d_cash = bal["cash"].diff().iloc[1:] d_stocks = bal["stocks capital"].diff().iloc[1:] d_options = bal["options capital"].diff().iloc[1:] d_components = d_cash + d_stocks + d_options mismatches = ~np.isclose(d_total, d_components, rtol=1e-6, atol=0.01) if mismatches.any(): bad_idx = d_total[mismatches].index[:5] raise AssertionError( f"Total capital change does not match component changes at " f"{mismatches.sum()} rows.\n" f"Dates: {bad_idx.tolist()}\n" f"d_total: {d_total[mismatches].head(5).tolist()}\n" f"d_components: {d_components[mismatches].head(5).tolist()}" ) def test_no_cash_leak_over_full_run(self): """Over the entire run, the total change in capital should equal the sum of: stock returns + option P&L. We verify this by checking that [total_final - total_initial] == [sum of period-by-period component changes].""" bal = self.engine.balance total_change = bal["total capital"].iloc[-1] - bal["total capital"].iloc[0] component_changes = ( bal["cash"].diff().sum() + bal["stocks capital"].diff().sum() + bal["options capital"].diff().sum() ) assert np.isclose(total_change, component_changes, rtol=1e-6, atol=0.01), ( f"Cumulative total change {total_change:.4f} != " f"cumulative component changes {component_changes:.4f}. " f"Cash leaked: {total_change - component_changes:.4f}" ) class TestAQRvsSpitznagelZeroBudget: """With an impossible filter (no puts ever bought), AQR has less equity exposure than Spitznagel. AQR allocates (1 - options_pct) to stocks, while Spitznagel allocates 100% to stocks. When puts are never bought: - AQR 97/3: only 97% in stocks, 3% idle in cash - Spitznagel 100/0 + 3% budget: 100% in stocks, budget never spent With flat prices both should preserve capital, but Spitznagel should have higher (or equal) equity exposure and thus higher (or equal) returns in a rising market. With flat prices (all = 10), they should be approximately equal, but AQR should never beat Spitznagel since AQR holds less stock. """ @pytest.fixture(autouse=True) def setup(self): stocks_data = _stocks_data() options_data = _options_data() schema = options_data.schema # Impossible filter so no puts are ever entered strat_impossible = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) leg.entry_filter = (schema.underlying == "SPX") & (schema.delta > 0) leg.exit_filter = schema.dte <= 30 strat_impossible.add_legs([leg]) strat_impossible.add_exit_thresholds(profit_pct=math.inf, loss_pct=math.inf) # AQR framing: 97% stocks, 3% options (from stock allocation) self.aqr_engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), initial_capital=100_000, ) self.aqr_engine.stocks = _ivy_stocks() self.aqr_engine.stocks_data = stocks_data self.aqr_engine.options_data = options_data self.aqr_engine.options_strategy = strat_impossible self.aqr_engine.run(rebalance_freq=1) # Spitznagel framing: 100% stocks + external 3% budget self.spitz_engine = BacktestEngine( {"stocks": 1.0, "options": 0.0, "cash": 0}, cost_model=NoCosts(), initial_capital=100_000, ) self.spitz_engine.options_budget_pct = 0.03 self.spitz_engine.stocks = _ivy_stocks() self.spitz_engine.stocks_data = stocks_data self.spitz_engine.options_data = options_data self.spitz_engine.options_strategy = strat_impossible self.spitz_engine.run(rebalance_freq=1) def test_both_conserve_capital(self): _assert_balance_components_sum(self.aqr_engine.balance) _assert_balance_components_sum(self.spitz_engine.balance) def test_aqr_less_equity_than_spitznagel(self): """AQR should have strictly less stock capital than Spitznagel, since AQR reserves 3% for options (held as cash when puts not found) while Spitznagel puts 100% in stocks.""" aqr_stocks = self.aqr_engine.balance["stocks capital"].iloc[1:] spitz_stocks = self.spitz_engine.balance["stocks capital"].iloc[1:] # Align on common dates common = aqr_stocks.index.intersection(spitz_stocks.index) assert len(common) > 0, "No overlapping dates between AQR and Spitznagel" assert (aqr_stocks.loc[common] <= spitz_stocks.loc[common] + 0.01).all(), ( "AQR has more stock capital than Spitznagel -- allocation is wrong." ) def test_aqr_return_leq_spitznagel(self): """With flat prices and no puts bought, AQR total return should be less than or equal to Spitznagel (AQR holds less stock).""" aqr_final = self.aqr_engine.balance["total capital"].iloc[-1] spitz_final = self.spitz_engine.balance["total capital"].iloc[-1] assert aqr_final <= spitz_final + 0.01, ( f"AQR final ({aqr_final:.2f}) > Spitznagel final ({spitz_final:.2f}). " f"AQR should not outperform with less equity and no options." ) def test_no_trades_in_either(self): assert self.aqr_engine.trade_log.empty, "AQR should have no trades" assert self.spitz_engine.trade_log.empty, "Spitznagel should have no trades" def test_aqr_has_cash_from_unspent_options(self): """In AQR framing with impossible filter, the 3% options allocation should remain as cash since it was never spent on puts.""" bal = self.aqr_engine.balance # After first rebalance, cash should be roughly 3% of total cash = bal["cash"].iloc[1:] total = bal["total capital"].iloc[1:] ratio = cash / total # Should be close to 3% (the unspent options allocation) assert (ratio > 0.02).all(), ( f"AQR cash ratio too low -- options money was destroyed.\n" f"Min ratio: {ratio.min():.4f}, expected ~0.03" ) class TestExternallyFundedNoLeakage: """Verify that the externally-funded (budget_pct) path does not leak cash. When budget_pct is set, the engine injects `remaining_budget` into cash before buying puts, then must claw back the full unspent amount. If the put trade costs less than remaining_budget (due to floor(qty) rounding), the difference must be removed from cash — not left as phantom money. Uses flat stock prices (all 10) so any growth in total capital beyond option MTM is evidence of a cash leak. """ @pytest.fixture( params=[0.005, 0.01, 0.03, 0.10], ids=["0.5%", "1%", "3%", "10%"], ) def engine(self, request): stocks_data = _stocks_data() options_data = _options_data() schema = options_data.schema eng = BacktestEngine( {"stocks": 1.0, "options": 0.0, "cash": 0}, cost_model=NoCosts(), initial_capital=100_000, ) eng.options_budget_pct = request.param eng.stocks = _ivy_stocks() eng.stocks_data = stocks_data eng.options_data = options_data eng.options_strategy = _buy_strategy(schema) eng.run(rebalance_freq=1) return eng def test_components_sum_to_total(self, engine): _assert_balance_components_sum(engine.balance) def test_no_phantom_cash_growth(self, engine): """With flat stock prices, total capital changes should come only from option MTM, not from cash leaking in. Cash should never exceed the non-options portion of total capital.""" bal = engine.balance # In externally-funded mode, stocks get liquid_capital (= cash + stock_cap). # Cash after buying stocks should be ~0 (all deployed to stocks), plus # any unspent options budget should be clawed back. # On days with no option positions, total ~= stock_cap ~= initial. # Any row where cash > budget_amount suggests a leak. total = bal["total capital"].iloc[1:] cash = bal["cash"].iloc[1:] # Cash should never be a significant fraction of total # (it should be near 0 after buying stocks, with only rounding leftovers) max_cash_ratio = (cash / total).max() assert max_cash_ratio < 0.05, ( f"Cash/total ratio reached {max_cash_ratio:.4f} — possible cash leak " f"from externally-funded budget injection." ) def test_cash_never_negative(self, engine): bal = engine.balance bad = bal["cash"] < -0.01 assert not bad.any(), ( f"Cash went negative at {bad.sum()} rows." ) def test_total_capital_bounded(self, engine): """With flat prices and OTM puts that mostly expire worthless, total capital should not grow significantly above initial.""" final = engine.balance["total capital"].iloc[-1] assert final < 100_000 * 1.5, ( f"Final capital {final:.0f} suspiciously high for flat stock prices." ) ================================================ FILE: tests/engine/test_chaos.py ================================================ """Chaos / fault-injection tests — corrupted and adversarial data. Feed corrupted data through the engine. Assert: either raises a clear error OR completes with math.isfinite(final_capital). Never silently produces NaN/Inf. Reuses the test_engine.py data pattern, then corrupts ._data in-memory. """ import math import os import numpy as np import pandas as pd import pytest from options_portfolio_backtester.engine.engine import BacktestEngine from options_portfolio_backtester.execution.cost_model import NoCosts, PerContractCommission from options_portfolio_backtester.execution.fill_model import ( MarketAtBidAsk, MidPrice, VolumeAwareFill, ) from options_portfolio_backtester.execution.signal_selector import NearestDelta, FirstMatch from options_portfolio_backtester.portfolio.risk import RiskManager, MaxDelta, MaxVega from options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.core.types import Stock, OptionType as Type, Direction TEST_DIR = os.path.join(os.path.dirname(__file__), "..", "test_data") STOCKS_FILE = os.path.join(TEST_DIR, "ivy_5assets_data.csv") OPTIONS_FILE = os.path.join(TEST_DIR, "options_data.csv") pytestmark = pytest.mark.chaos # --------------------------------------------------------------------------- # Shared helpers # --------------------------------------------------------------------------- def _ivy_stocks(): return [Stock("VTI", 0.2), Stock("VEU", 0.2), Stock("BND", 0.2), Stock("VNQ", 0.2), Stock("DBC", 0.2)] def _stocks_data(): data = TiingoData(STOCKS_FILE) data._data["adjClose"] = 10 return data def _options_data(): data = HistoricalOptionsData(OPTIONS_FILE) data._data.at[2, "ask"] = 1 data._data.at[2, "bid"] = 0.5 data._data.at[51, "ask"] = 1.5 data._data.at[50, "bid"] = 0.5 data._data.at[130, "bid"] = 0.5 data._data.at[131, "bid"] = 1.5 data._data.at[206, "bid"] = 0.5 data._data.at[207, "bid"] = 1.5 return data def _build_strategy(schema, direction=Direction.BUY): strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=direction) leg.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat.add_legs([leg]) return strat def _run_chaos(options_data, stocks_data=None, cost_model=None, fill_model=None, signal_selector=None, risk_manager=None, direction=Direction.BUY, initial_capital=1_000_000): """Run engine with possibly-corrupted data. Returns engine or raises.""" stocks = _ivy_stocks() sd = stocks_data or _stocks_data() schema = options_data.schema engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=cost_model or NoCosts(), fill_model=fill_model or MarketAtBidAsk(), signal_selector=signal_selector or NearestDelta(target_delta=-0.30), risk_manager=risk_manager or RiskManager(), initial_capital=initial_capital, ) engine.stocks = stocks engine.stocks_data = sd engine.options_data = options_data engine.options_strategy = _build_strategy(schema, direction=direction) engine.run(rebalance_freq=1) return engine def _assert_finite_or_error(fn): """Call fn(). If it succeeds, assert final capital is finite. If it raises, that's OK too.""" try: engine = fn() final = engine.balance["total capital"].iloc[-1] assert math.isfinite(final), f"Non-finite final capital: {final}" return engine except (ValueError, KeyError, IndexError, ZeroDivisionError, AssertionError, RuntimeError): pass # Clear error is acceptable # --------------------------------------------------------------------------- # Chaos test classes # --------------------------------------------------------------------------- class TestNaNInjection: """NaN injected into bid, ask, delta, volume columns.""" def test_nan_all_bids(self): od = _options_data() od._data["bid"] = np.nan _assert_finite_or_error(lambda: _run_chaos(od)) def test_nan_all_asks(self): od = _options_data() od._data["ask"] = np.nan _assert_finite_or_error(lambda: _run_chaos(od)) def test_nan_scattered_bid(self): od = _options_data() mask = od._data.index % 2 == 0 od._data.loc[mask, "bid"] = np.nan _assert_finite_or_error(lambda: _run_chaos(od)) def test_nan_scattered_ask(self): od = _options_data() mask = od._data.index % 2 == 0 od._data.loc[mask, "ask"] = np.nan _assert_finite_or_error(lambda: _run_chaos(od)) def test_nan_delta(self): od = _options_data() if "delta" in od._data.columns: od._data["delta"] = np.nan _assert_finite_or_error(lambda: _run_chaos(od)) def test_nan_volume(self): od = _options_data() od._data["volume"] = np.nan _assert_finite_or_error(lambda: _run_chaos(od)) class TestNegativePrices: """Negative bid/ask prices — should not crash or produce NaN.""" def test_negative_bid(self): od = _options_data() od._data["bid"] = -1.0 _assert_finite_or_error(lambda: _run_chaos(od)) def test_negative_ask(self): od = _options_data() od._data["ask"] = -5.0 _assert_finite_or_error(lambda: _run_chaos(od)) def test_both_negative(self): od = _options_data() od._data["bid"] = -2.0 od._data["ask"] = -1.0 _assert_finite_or_error(lambda: _run_chaos(od)) class TestInvertedBidAsk: """Bid > ask (crossed market) — should still produce finite fills.""" def test_inverted_spread(self): od = _options_data() original_bid = od._data["bid"].copy() original_ask = od._data["ask"].copy() od._data["bid"] = original_ask od._data["ask"] = original_bid _assert_finite_or_error(lambda: _run_chaos(od)) def test_bid_equals_ask(self): od = _options_data() od._data["ask"] = od._data["bid"] _assert_finite_or_error(lambda: _run_chaos(od)) class TestMissingColumns: """Drop delta column with NearestDelta selector — should fall back to first match.""" def test_missing_delta_column(self): od = _options_data() if "delta" in od._data.columns: od._data = od._data.drop(columns=["delta"]) engine = _assert_finite_or_error( lambda: _run_chaos(od, signal_selector=NearestDelta(target_delta=-0.30)) ) # NearestDelta falls back to iloc[0] when delta column missing if engine is not None: assert math.isfinite(engine.balance["total capital"].iloc[-1]) class TestNoMatchingContracts: """All DTE=0 — entry_filter (dte >= 60) never matches.""" def test_all_dte_zero(self): od = _options_data() od._data["dte"] = 0 engine = _assert_finite_or_error(lambda: _run_chaos(od)) if engine is not None: # No trades should have occurred assert len(engine.trade_log) == 0 or engine.trade_log.empty class TestZeroVolume: """Volume=0 with VolumeAwareFill — should fill at mid.""" def test_zero_volume_fill(self): od = _options_data() od._data["volume"] = 0 engine = _assert_finite_or_error( lambda: _run_chaos(od, fill_model=VolumeAwareFill(full_volume_threshold=100)) ) if engine is not None: assert math.isfinite(engine.balance["total capital"].iloc[-1]) class TestExtremeGreeks: """Extreme delta/vega values — risk constraints should block/allow correctly.""" def test_extreme_delta_blocked(self): od = _options_data() if "delta" in od._data.columns: od._data["delta"] = 100.0 rm = RiskManager(constraints=[MaxDelta(limit=0.01)]) engine = _assert_finite_or_error( lambda: _run_chaos(od, risk_manager=rm) ) if engine is not None: assert math.isfinite(engine.balance["total capital"].iloc[-1]) def test_extreme_vega_blocked(self): od = _options_data() if "vega" in od._data.columns: od._data["vega"] = -999.0 rm = RiskManager(constraints=[MaxVega(limit=0.01)]) engine = _assert_finite_or_error( lambda: _run_chaos(od, risk_manager=rm) ) if engine is not None: assert math.isfinite(engine.balance["total capital"].iloc[-1]) def test_extreme_delta_allowed(self): od = _options_data() if "delta" in od._data.columns: od._data["delta"] = 100.0 rm = RiskManager(constraints=[MaxDelta(limit=999999)]) engine = _assert_finite_or_error( lambda: _run_chaos(od, risk_manager=rm) ) if engine is not None: assert math.isfinite(engine.balance["total capital"].iloc[-1]) class TestDuplicateDates: """Duplicate all rows in options and stocks — should not crash.""" def test_duplicate_options_rows(self): od = _options_data() od._data = pd.concat([od._data, od._data], ignore_index=True) sd = _stocks_data() sd._data = pd.concat([sd._data, sd._data], ignore_index=True) _assert_finite_or_error(lambda: _run_chaos(od, stocks_data=sd)) class TestCapitalExhaustion: """Initial capital = 1 — should not crash, zero or minimal trades.""" def test_tiny_capital(self): od = _options_data() engine = _assert_finite_or_error( lambda: _run_chaos(od, initial_capital=1) ) if engine is not None: final = engine.balance["total capital"].iloc[-1] assert math.isfinite(final) def test_zero_capital(self): od = _options_data() engine = _assert_finite_or_error( lambda: _run_chaos(od, initial_capital=0) ) if engine is not None: final = engine.balance["total capital"].iloc[-1] assert math.isfinite(final) class TestMassiveSpread: """bid=0.01, ask=999 — extreme spread should produce finite fills.""" def test_massive_spread(self): od = _options_data() od._data["bid"] = 0.01 od._data["ask"] = 999.0 _assert_finite_or_error(lambda: _run_chaos(od)) def test_massive_spread_mid_fill(self): od = _options_data() od._data["bid"] = 0.01 od._data["ask"] = 999.0 _assert_finite_or_error( lambda: _run_chaos(od, fill_model=MidPrice()) ) class TestAllExpired: """All DTE=0 — no entries should happen, capital preserved.""" def test_all_expired_capital_preserved(self): od = _options_data() od._data["dte"] = 0 engine = _assert_finite_or_error(lambda: _run_chaos(od)) if engine is not None: final = engine.balance["total capital"].iloc[-1] assert math.isfinite(final) # Capital should be close to initial since no options trades assert final > 0 class TestSingleDay: """Filter data to a single date — stats computation should not crash.""" def test_single_date(self): od = _options_data() sd = _stocks_data() first_date = od._data["quotedate"].iloc[0] od._data = od._data[od._data["quotedate"] == first_date].copy() sd._data = sd._data[sd._data["date"] == first_date].copy() _assert_finite_or_error(lambda: _run_chaos(od, stocks_data=sd)) ================================================ FILE: tests/engine/test_clock.py ================================================ """Tests for TradingClock — date iteration and rebalance scheduling.""" import pandas as pd import numpy as np from options_portfolio_backtester.engine.clock import TradingClock def _make_data(n_dates=5): """Create minimal stocks + options DataFrames for clock tests.""" dates = pd.bdate_range("2020-01-06", periods=n_dates, freq="B") stocks = pd.DataFrame({ "date": np.repeat(dates, 2), "symbol": ["SPY", "IWM"] * n_dates, "adjClose": np.random.uniform(300, 400, n_dates * 2), }) options = pd.DataFrame({ "quotedate": np.repeat(dates, 3), "optionroot": [f"SPY_C_{i}" for i in range(n_dates * 3)], "volume": np.random.randint(100, 10000, n_dates * 3), }) return stocks, options, dates class TestDailyIteration: def test_yields_correct_number_of_dates(self): stocks, options, dates = _make_data(5) clock = TradingClock(stocks, options) result = list(clock.iter_dates()) assert len(result) == 5 def test_yields_tuples_of_date_stocks_options(self): stocks, options, dates = _make_data(3) clock = TradingClock(stocks, options) for date, s, o in clock.iter_dates(): assert isinstance(date, pd.Timestamp) assert isinstance(s, pd.DataFrame) assert isinstance(o, pd.DataFrame) class TestAllDates: def test_returns_all_unique_dates(self): stocks, options, dates = _make_data(5) clock = TradingClock(stocks, options) assert len(clock.all_dates) == 5 class TestRebalanceDates: def test_zero_freq_returns_empty(self): stocks, options, dates = _make_data(5) clock = TradingClock(stocks, options) rb = clock.rebalance_dates(0) assert len(rb) == 0 def test_negative_freq_returns_empty(self): stocks, options, dates = _make_data(5) clock = TradingClock(stocks, options) rb = clock.rebalance_dates(-1) assert len(rb) == 0 def test_positive_freq_returns_dates(self): # Use enough dates to span multiple months dates = pd.bdate_range("2020-01-06", periods=60, freq="B") stocks = pd.DataFrame({ "date": np.repeat(dates, 1), "symbol": ["SPY"] * 60, "adjClose": np.random.uniform(300, 400, 60), }) options = pd.DataFrame({ "quotedate": np.repeat(dates, 1), "optionroot": [f"SPY_C_{i}" for i in range(60)], "volume": np.random.randint(100, 10000, 60), }) clock = TradingClock(stocks, options) rb = clock.rebalance_dates(1) assert len(rb) > 0 assert isinstance(rb, pd.DatetimeIndex) class TestMonthlyIteration: def test_monthly_mode_yields_first_of_month_dates(self): # Span 3 months of business days dates = pd.bdate_range("2020-01-06", periods=60, freq="B") stocks = pd.DataFrame({ "date": np.repeat(dates, 1), "symbol": ["SPY"] * 60, "adjClose": np.random.uniform(300, 400, 60), }) options = pd.DataFrame({ "quotedate": np.repeat(dates, 1), "optionroot": [f"SPY_C_{i}" for i in range(60)], "volume": np.random.randint(100, 10000, 60), }) clock = TradingClock(stocks, options, monthly=True) result = list(clock.iter_dates()) # monthly=True should yield fewer dates than daily assert len(result) <= 60 assert len(result) >= 1 for date, s, o in result: assert isinstance(date, pd.Timestamp) ================================================ FILE: tests/engine/test_engine.py ================================================ """Tests for BacktestEngine — verifies regression values and engine behavior.""" import os import pytest import numpy as np from options_portfolio_backtester.engine.engine import BacktestEngine from options_portfolio_backtester.execution.cost_model import NoCosts, PerContractCommission from options_portfolio_backtester.execution.signal_selector import FirstMatch from options_portfolio_backtester.portfolio.risk import RiskManager from options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.core.types import Stock, OptionType as Type, Direction TEST_DIR = os.path.join(os.path.dirname(__file__), "..", "test_data") STOCKS_FILE = os.path.join(TEST_DIR, "ivy_5assets_data.csv") OPTIONS_FILE = os.path.join(TEST_DIR, "options_data.csv") def _ivy_stocks(): return [Stock("VTI", 0.2), Stock("VEU", 0.2), Stock("BND", 0.2), Stock("VNQ", 0.2), Stock("DBC", 0.2)] def _stocks_data(): data = TiingoData(STOCKS_FILE) data._data["adjClose"] = 10 return data def _options_data(): """Create test options data with known bid/ask values (same as conftest.options_data_2puts_buy).""" data = HistoricalOptionsData(OPTIONS_FILE) data._data.at[2, "ask"] = 1 data._data.at[2, "bid"] = 0.5 data._data.at[51, "ask"] = 1.5 data._data.at[50, "bid"] = 0.5 data._data.at[130, "bid"] = 0.5 data._data.at[131, "bid"] = 1.5 data._data.at[206, "bid"] = 0.5 data._data.at[207, "bid"] = 1.5 return data def _buy_strategy(schema): strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) leg.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat.add_legs([leg]) return strat def _run_engine(cost_model=None): stocks = _ivy_stocks() stocks_data = _stocks_data() options_data = _options_data() schema = options_data.schema engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=cost_model or NoCosts(), ) engine.stocks = stocks engine.stocks_data = stocks_data engine.options_data = options_data engine.options_strategy = _buy_strategy(schema) engine.run(rebalance_freq=1) return engine class TestEngineRegressionValues: """Verify the engine produces known regression values.""" @pytest.fixture(autouse=True) def setup(self): self.engine = _run_engine() def test_trade_log_not_empty(self): assert not self.engine.trade_log.empty def test_balance_not_empty(self): assert not self.engine.balance.empty def test_regression_costs(self): tol = 0.0001 bt = self.engine # Positions persist across rebalances — only new entries, no liquidation churn. assert np.allclose(bt.trade_log["totals"]["cost"].values, [100, 150], rtol=tol) assert np.allclose(bt.trade_log["leg_1"]["cost"].values, [100, 150], rtol=tol) def test_regression_qtys(self): tol = 0.0001 bt = self.engine assert np.allclose( bt.trade_log["totals"]["qty"].values, [300, 97], rtol=tol, ) class TestEngineWithCosts: """Test that adding costs changes the result (proves costs are wired in).""" def test_commission_reduces_final_capital(self): no_cost = _run_engine() with_cost = _run_engine(cost_model=PerContractCommission(rate=5.00, stock_rate=0.01)) no_cost_final = no_cost.balance["total capital"].iloc[-1] with_cost_final = with_cost.balance["total capital"].iloc[-1] assert with_cost_final < no_cost_final class TestRunMetadata: """Ensure reproducibility metadata is attached to outputs.""" def test_metadata_attached_to_trade_log_and_balance(self): engine = _run_engine() meta = engine.run_metadata assert meta["framework"] == "options_portfolio_backtester.engine.BacktestEngine" assert isinstance(meta["git_sha"], str) assert len(meta["config_hash"]) == 64 assert len(meta["data_snapshot_hash"]) == 64 assert meta["data_snapshot"]["options_rows"] > 0 assert meta["data_snapshot"]["stocks_rows"] > 0 assert engine.trade_log.attrs["run_metadata"] == meta assert engine.balance.attrs["run_metadata"] == meta class TestEngineInit: """Test engine initialization without running backtests.""" def test_default_allocation_normalized(self): e = BacktestEngine({"stocks": 60, "options": 30, "cash": 10}) assert abs(e.allocation["stocks"] - 0.6) < 1e-10 assert abs(e.allocation["options"] - 0.3) < 1e-10 assert abs(e.allocation["cash"] - 0.1) < 1e-10 def test_default_components(self): e = BacktestEngine({"stocks": 1.0}) assert isinstance(e.cost_model, NoCosts) assert isinstance(e.signal_selector, FirstMatch) assert isinstance(e.risk_manager, RiskManager) assert e.stop_if_broke is False def test_stop_if_broke_flag(self): e = BacktestEngine({"stocks": 1.0}, stop_if_broke=True) assert e.stop_if_broke is True ================================================ FILE: tests/engine/test_engine_deep.py ================================================ """Deep engine tests — multi-strategy, options_budget, SMA gating, monthly mode, capital flow invariants, event logging, check_exits_daily, stop_if_broke, and more. These tests exercise engine internals that the basic regression tests don't cover. """ import math import os import numpy as np import pandas as pd import pytest from options_portfolio_backtester.engine.engine import BacktestEngine, _intrinsic_value from options_portfolio_backtester.execution.cost_model import ( NoCosts, PerContractCommission, TieredCommission, ) from options_portfolio_backtester.execution.fill_model import MarketAtBidAsk, MidPrice, VolumeAwareFill from options_portfolio_backtester.execution.signal_selector import ( FirstMatch, NearestDelta, MaxOpenInterest, ) from options_portfolio_backtester.execution.sizer import ( CapitalBased, FixedQuantity, FixedDollar, PercentOfPortfolio, ) from options_portfolio_backtester.portfolio.risk import ( RiskManager, MaxDelta, MaxVega, MaxDrawdown, ) from options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.core.types import ( Stock, OptionType as Type, Direction, Greeks, ) TEST_DIR = os.path.join(os.path.dirname(__file__), "..", "test_data") STOCKS_FILE = os.path.join(TEST_DIR, "ivy_5assets_data.csv") OPTIONS_FILE = os.path.join(TEST_DIR, "options_data.csv") # --------------------------------------------------------------------------- # Shared fixtures # --------------------------------------------------------------------------- def _ivy_stocks(): return [ Stock("VTI", 0.2), Stock("VEU", 0.2), Stock("BND", 0.2), Stock("VNQ", 0.2), Stock("DBC", 0.2), ] def _stocks_data(): data = TiingoData(STOCKS_FILE) data._data["adjClose"] = 10 return data def _options_data(): data = HistoricalOptionsData(OPTIONS_FILE) data._data.at[2, "ask"] = 1 data._data.at[2, "bid"] = 0.5 data._data.at[51, "ask"] = 1.5 data._data.at[50, "bid"] = 0.5 data._data.at[130, "bid"] = 0.5 data._data.at[131, "bid"] = 1.5 data._data.at[206, "bid"] = 0.5 data._data.at[207, "bid"] = 1.5 return data def _buy_strategy(schema): strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) leg.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat.add_legs([leg]) return strat def _sell_strategy(schema): strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.SELL) leg.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat.add_legs([leg]) return strat def _run_engine(**kwargs): engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=kwargs.pop("cost_model", NoCosts()), fill_model=kwargs.pop("fill_model", MarketAtBidAsk()), signal_selector=kwargs.pop("signal_selector", NearestDelta(target_delta=-0.30)), risk_manager=kwargs.pop("risk_manager", RiskManager()), stop_if_broke=kwargs.pop("stop_if_broke", False), max_notional_pct=kwargs.pop("max_notional_pct", None), ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = _options_data() engine.options_strategy = _buy_strategy(engine.options_data.schema) if "options_budget_pct" in kwargs: engine.options_budget_pct = kwargs.pop("options_budget_pct") engine.run( rebalance_freq=kwargs.pop("rebalance_freq", 1), monthly=kwargs.pop("monthly", False), sma_days=kwargs.pop("sma_days", None), check_exits_daily=kwargs.pop("check_exits_daily", False), ) return engine # --------------------------------------------------------------------------- # Intrinsic value helper # --------------------------------------------------------------------------- class TestIntrinsicValue: """Test the _intrinsic_value helper used throughout the engine.""" def test_call_itm(self): assert _intrinsic_value("call", 100.0, 110.0) == 10.0 def test_call_otm(self): assert _intrinsic_value("call", 110.0, 100.0) == 0.0 def test_put_itm(self): assert _intrinsic_value("put", 110.0, 100.0) == 10.0 def test_put_otm(self): assert _intrinsic_value("put", 100.0, 110.0) == 0.0 def test_atm_both(self): assert _intrinsic_value("call", 100.0, 100.0) == 0.0 assert _intrinsic_value("put", 100.0, 100.0) == 0.0 # --------------------------------------------------------------------------- # Capital flow invariants # --------------------------------------------------------------------------- class TestCapitalFlowInvariants: """Verify accounting identities hold after a backtest run.""" def test_total_capital_equals_sum_of_parts(self): engine = _run_engine() bal = engine.balance computed = bal["cash"] + bal["stocks capital"] + bal["options capital"] diff = (bal["total capital"] - computed).abs() assert diff.max() < 0.01, f"Capital identity violated: max diff {diff.max()}" def test_accumulated_return_consistent_with_pct_change(self): engine = _run_engine() bal = engine.balance manual_acc = (1 + bal["% change"]).cumprod() diff = (bal["accumulated return"].dropna() - manual_acc.dropna()).abs() assert diff.max() < 1e-10 def test_initial_capital_preserved_in_first_row(self): engine = _run_engine() assert engine.balance["total capital"].iloc[0] == 1_000_000 def test_total_capital_never_negative_with_buy_only(self): engine = _run_engine() assert (engine.balance["total capital"].dropna() >= 0).all() def test_stock_qty_columns_present_for_all_stocks(self): engine = _run_engine() for stock in _ivy_stocks(): assert stock.symbol in engine.balance.columns assert f"{stock.symbol} qty" in engine.balance.columns # --------------------------------------------------------------------------- # Options budget # --------------------------------------------------------------------------- class TestOptionsBudget: """Test options_budget_pct feature.""" def test_budget_pct(self): engine = _run_engine(options_budget_pct=0.005) assert engine.balance is not None assert not engine.trade_log.empty def test_budget_preserves_raw_allocation(self): """With options_budget_pct, raw allocation should be used for stocks.""" engine = BacktestEngine( {"stocks": 1.0, "options": 0.005, "cash": 0}, cost_model=NoCosts(), signal_selector=NearestDelta(target_delta=-0.30), ) engine.options_budget_pct = 0.005 engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = _options_data() engine.options_strategy = _buy_strategy(engine.options_data.schema) engine.run(rebalance_freq=1) # Raw allocation for stocks should be 1.0, not normalized assert engine._raw_allocation["stocks"] == 1.0 def test_budget_changes_trade_sizes_vs_no_budget(self): """Different budgets should produce different position sizes.""" e1 = _run_engine(options_budget_pct=0.001) e2 = _run_engine(options_budget_pct=0.05) if not e1.trade_log.empty and not e2.trade_log.empty: q1 = e1.trade_log["totals"]["qty"].values q2 = e2.trade_log["totals"]["qty"].values # Larger budget should buy more contracts assert q2[0] > q1[0] or len(q2) != len(q1) # --------------------------------------------------------------------------- # Monthly mode # --------------------------------------------------------------------------- class TestMonthlyMode: """Test monthly iteration mode.""" def test_monthly_mode_runs(self): engine = _run_engine(monthly=True) assert engine.balance is not None def test_monthly_produces_fewer_balance_rows(self): daily_engine = _run_engine(monthly=False) monthly_engine = _run_engine(monthly=True) assert len(monthly_engine.balance) <= len(daily_engine.balance) # --------------------------------------------------------------------------- # check_exits_daily # --------------------------------------------------------------------------- class TestCheckExitsDaily: """Test daily exit checking on non-rebalance days.""" def test_daily_exits_runs_without_error(self): engine = _run_engine(check_exits_daily=True) assert engine.balance is not None def test_daily_exits_may_close_positions_earlier(self): """With daily exit checking, positions may be closed sooner.""" engine_no = _run_engine(check_exits_daily=False) engine_yes = _run_engine(check_exits_daily=True) # Both should complete; daily exits may produce more trade rows assert engine_no.balance is not None assert engine_yes.balance is not None # --------------------------------------------------------------------------- # stop_if_broke # --------------------------------------------------------------------------- class TestStopIfBroke: """Test stop_if_broke halting behavior.""" def test_completes_without_stopping(self): engine = _run_engine(stop_if_broke=True) assert engine.balance is not None assert len(engine.balance) > 1 # --------------------------------------------------------------------------- # SMA gating # --------------------------------------------------------------------------- class TestSMAGating: """Test SMA-based stock buying gating.""" def test_sma_gating_runs(self): engine = _run_engine(sma_days=20) assert engine.balance is not None def test_sma_gating_changes_stock_allocation(self): """SMA gating should reduce stock buying when price < SMA.""" engine_no_sma = _run_engine(sma_days=None) engine_sma = _run_engine(sma_days=5) # Both should produce valid results assert not engine_no_sma.balance.empty assert not engine_sma.balance.empty # Stock quantities may differ final_no = engine_no_sma.balance["stocks qty"].iloc[-1] final_sma = engine_sma.balance["stocks qty"].iloc[-1] # With constant adjClose=10 and sma also=10, SMA gate may pass or block # depending on initialization; key is it doesn't crash assert final_no >= 0 assert final_sma >= 0 # --------------------------------------------------------------------------- # Event log # --------------------------------------------------------------------------- class TestEventLog: """Test structured event logging. Events are not populated by the Rust full-loop (it bypasses Python event logging), so these tests just verify the events_dataframe() API works. """ def test_events_dataframe_returns_dataframe(self): engine = _run_engine() events = engine.events_dataframe() assert hasattr(events, "columns") def test_event_log_has_required_columns(self): engine = _run_engine() events = engine.events_dataframe() assert "date" in events.columns assert "event" in events.columns assert "status" in events.columns # --------------------------------------------------------------------------- # Allocation normalization # --------------------------------------------------------------------------- class TestAllocationNormalization: """Test that allocation dict is normalized correctly.""" def test_unnormalized_sums_to_one(self): engine = BacktestEngine({"stocks": 60, "options": 30, "cash": 10}) total = sum(engine.allocation.values()) assert abs(total - 1.0) < 1e-10 def test_already_normalized(self): engine = BacktestEngine({"stocks": 0.5, "options": 0.3, "cash": 0.2}) assert abs(engine.allocation["stocks"] - 0.5) < 1e-10 def test_missing_keys_default_to_zero(self): engine = BacktestEngine({"stocks": 1.0}) assert engine.allocation["options"] == 0.0 assert engine.allocation["cash"] == 0.0 def test_raw_allocation_preserved(self): engine = BacktestEngine({"stocks": 60, "options": 30, "cash": 10}) assert engine._raw_allocation["stocks"] == 60 assert engine._raw_allocation["options"] == 30 # --------------------------------------------------------------------------- # Multi-strategy mode # --------------------------------------------------------------------------- class TestMultiStrategy: """Test multi-strategy mode with multiple strategy slots.""" def _make_multi_engine(self): engine = BacktestEngine( {"stocks": 0.90, "options": 0.10, "cash": 0}, cost_model=NoCosts(), signal_selector=NearestDelta(target_delta=-0.30), ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = _options_data() schema = engine.options_data.schema return engine, schema def test_two_strategies_equal_weight(self): engine, schema = self._make_multi_engine() engine.add_strategy(_buy_strategy(schema), weight=0.5, rebalance_freq=1, name="buy_puts") engine.add_strategy(_buy_strategy(schema), weight=0.5, rebalance_freq=1, name="buy_puts_2") engine.run() assert engine.balance is not None assert "framework" in engine.run_metadata def test_multi_strategy_weights_must_sum_to_one(self): engine, schema = self._make_multi_engine() engine.add_strategy(_buy_strategy(schema), weight=0.3, rebalance_freq=1) engine.add_strategy(_buy_strategy(schema), weight=0.3, rebalance_freq=1) with pytest.raises(AssertionError, match="weights must sum"): engine.run() def test_multi_strategy_different_frequencies(self): engine, schema = self._make_multi_engine() engine.add_strategy(_buy_strategy(schema), weight=0.5, rebalance_freq=1, name="monthly") engine.add_strategy(_buy_strategy(schema), weight=0.5, rebalance_freq=2, name="bimonthly") engine.run() assert engine.balance is not None def test_multi_strategy_with_daily_exit_checks(self): engine, schema = self._make_multi_engine() engine.add_strategy( _buy_strategy(schema), weight=0.5, rebalance_freq=1, check_exits_daily=True, name="daily_exit" ) engine.add_strategy( _buy_strategy(schema), weight=0.5, rebalance_freq=1, name="no_daily_exit" ) engine.run(check_exits_daily=False) assert engine.balance is not None def test_multi_strategy_capital_identity(self): engine, schema = self._make_multi_engine() engine.add_strategy(_buy_strategy(schema), weight=0.5, rebalance_freq=1) engine.add_strategy(_buy_strategy(schema), weight=0.5, rebalance_freq=1) engine.run() bal = engine.balance computed = bal["cash"] + bal["stocks capital"] + bal["options capital"] diff = (bal["total capital"] - computed).abs() assert diff.max() < 0.01 # --------------------------------------------------------------------------- # Risk management integration # --------------------------------------------------------------------------- class TestRiskManagementIntegration: """Test that risk constraints actually block entries in the engine.""" def test_max_delta_blocks_large_positions(self): """Very tight delta limit should block some entries.""" rm = RiskManager([MaxDelta(limit=0.001)]) engine = _run_engine(risk_manager=rm) # Engine should complete; some entries may be blocked assert engine.balance is not None def test_max_vega_blocks_entries(self): rm = RiskManager([MaxVega(limit=0.001)]) engine = _run_engine(risk_manager=rm) assert engine.balance is not None def test_max_drawdown_blocks_during_dd(self): rm = RiskManager([MaxDrawdown(max_dd_pct=0.001)]) engine = _run_engine(risk_manager=rm) assert engine.balance is not None def test_no_constraints_allows_all(self): rm = RiskManager() engine = _run_engine(risk_manager=rm) assert not engine.trade_log.empty def test_compound_constraints(self): rm = RiskManager([MaxDelta(limit=1000.0), MaxVega(limit=1000.0)]) engine = _run_engine(risk_manager=rm) assert engine.balance is not None assert not engine.trade_log.empty def test_risk_events_logged_on_block(self): rm = RiskManager([MaxDelta(limit=0.001)]) engine = _run_engine(risk_manager=rm) events = engine.events_dataframe() # If delta was blocked, we should see risk_block_entry events blocked = events[events["event"] == "risk_block_entry"] # May or may not trigger depending on actual Greeks in test data assert engine.balance is not None # --------------------------------------------------------------------------- # Execution component combinations # --------------------------------------------------------------------------- class TestExecutionCombinations: """Test various execution component combinations in the engine.""" def test_midprice_fill(self): engine = _run_engine(fill_model=MidPrice()) assert engine.balance is not None def test_volume_aware_fill(self): engine = _run_engine(fill_model=VolumeAwareFill(full_volume_threshold=10)) assert engine.balance is not None def test_per_contract_commission(self): engine = _run_engine(cost_model=PerContractCommission(rate=1.0)) assert engine.balance is not None def test_tiered_commission(self): engine = _run_engine(cost_model=TieredCommission()) assert engine.balance is not None def test_max_open_interest_selector(self): engine = _run_engine(signal_selector=MaxOpenInterest(oi_column="openinterest")) assert engine.balance is not None def test_commission_reduces_capital_consistently(self): """Higher commission rates should strictly reduce final capital.""" e_free = _run_engine(cost_model=NoCosts()) e_cheap = _run_engine(cost_model=PerContractCommission(rate=0.50, stock_rate=0.001)) e_expensive = _run_engine(cost_model=PerContractCommission(rate=10.0, stock_rate=0.10)) f0 = e_free.balance["total capital"].iloc[-1] f1 = e_cheap.balance["total capital"].iloc[-1] f2 = e_expensive.balance["total capital"].iloc[-1] assert f0 >= f1 >= f2 # --------------------------------------------------------------------------- # max_notional_pct # --------------------------------------------------------------------------- class TestMaxNotionalPct: """Test max_notional_pct constraint on short selling.""" def test_max_notional_limits_sell_positions(self): """Very tight notional limit should restrict sell position size.""" engine = BacktestEngine( {"stocks": 0.90, "options": 0.10, "cash": 0}, cost_model=NoCosts(), signal_selector=NearestDelta(target_delta=-0.30), max_notional_pct=0.001, # very tight ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = _options_data() engine.options_strategy = _sell_strategy(engine.options_data.schema) engine.run(rebalance_freq=1) assert engine.balance is not None # --------------------------------------------------------------------------- # Sell-direction strategies # --------------------------------------------------------------------------- class TestSellDirectionStrategy: """Test that sell-direction legs have correct sign on costs.""" def test_sell_puts_run(self): engine = BacktestEngine( {"stocks": 0.90, "options": 0.10, "cash": 0}, cost_model=NoCosts(), signal_selector=NearestDelta(target_delta=-0.30), ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = _options_data() engine.options_strategy = _sell_strategy(engine.options_data.schema) engine.run(rebalance_freq=1) assert engine.balance is not None def test_sell_entry_costs_are_negative(self): """SELL entries should produce negative cost (credit received).""" engine = BacktestEngine( {"stocks": 0.90, "options": 0.10, "cash": 0}, cost_model=NoCosts(), signal_selector=NearestDelta(target_delta=-0.30), ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = _options_data() engine.options_strategy = _sell_strategy(engine.options_data.schema) engine.run(rebalance_freq=1) if not engine.trade_log.empty: costs = engine.trade_log["leg_1"]["cost"].values # At least some costs should be negative (credit) assert any(c < 0 for c in costs) # --------------------------------------------------------------------------- # Exit thresholds # --------------------------------------------------------------------------- class TestExitThresholds: """Test profit/loss threshold exits.""" def test_very_tight_profit_threshold(self): schema = _options_data().schema strat = _buy_strategy(schema) strat.add_exit_thresholds(profit_pct=0.001) engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), signal_selector=NearestDelta(target_delta=-0.30), ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = _options_data() engine.options_strategy = strat engine.run(rebalance_freq=1) assert engine.balance is not None def test_very_tight_loss_threshold(self): schema = _options_data().schema strat = _buy_strategy(schema) strat.add_exit_thresholds(loss_pct=0.001) engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), signal_selector=NearestDelta(target_delta=-0.30), ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = _options_data() engine.options_strategy = strat engine.run(rebalance_freq=1) assert engine.balance is not None def test_both_thresholds_at_zero_forces_immediate_exit(self): """Setting both thresholds to 0 should exit positions immediately.""" schema = _options_data().schema strat = _buy_strategy(schema) strat.add_exit_thresholds(profit_pct=0.0, loss_pct=0.0) engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), signal_selector=NearestDelta(target_delta=-0.30), ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = _options_data() engine.options_strategy = strat engine.run(rebalance_freq=1) assert engine.balance is not None # --------------------------------------------------------------------------- # Run metadata # --------------------------------------------------------------------------- class TestRunMetadataDeep: """Deep tests for run metadata integrity.""" def test_metadata_config_hash_deterministic(self): """Same configuration should produce same config hash.""" e1 = _run_engine() e2 = _run_engine() assert e1.run_metadata["config_hash"] == e2.run_metadata["config_hash"] def test_metadata_data_snapshot_hash_deterministic(self): e1 = _run_engine() e2 = _run_engine() assert e1.run_metadata["data_snapshot_hash"] == e2.run_metadata["data_snapshot_hash"] def test_metadata_has_data_snapshot(self): engine = _run_engine() snap = engine.run_metadata["data_snapshot"] assert snap["options_rows"] > 0 assert snap["stocks_rows"] > 0 assert isinstance(snap["options_columns"], list) def test_metadata_has_framework_key(self): engine = _run_engine() assert "framework" in engine.run_metadata def test_multi_strategy_has_metadata(self): engine, schema = BacktestEngine( {"stocks": 0.90, "options": 0.10, "cash": 0}, cost_model=NoCosts(), signal_selector=NearestDelta(target_delta=-0.30), ), None schema_obj = _options_data() engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = schema_obj schema = schema_obj.schema engine.add_strategy(_buy_strategy(schema), weight=0.5, rebalance_freq=1) engine.add_strategy(_buy_strategy(schema), weight=0.5, rebalance_freq=1) engine.run() assert "framework" in engine.run_metadata # --------------------------------------------------------------------------- # Rebalance frequency edge cases # --------------------------------------------------------------------------- class TestRebalanceFrequency: """Test different rebalance frequencies.""" def test_rebalance_freq_zero_means_no_rebalance(self): """Freq 0 should skip rebalancing entirely.""" engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), signal_selector=NearestDelta(target_delta=-0.30), ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = _options_data() engine.options_strategy = _buy_strategy(engine.options_data.schema) engine.run(rebalance_freq=0) # No rebalancing → no trades assert engine.trade_log.empty def test_high_rebalance_freq(self): engine = _run_engine(rebalance_freq=6) assert engine.balance is not None def test_rebalance_freq_1_vs_2_differ(self): """Different frequencies should produce different results.""" e1 = _run_engine(rebalance_freq=1) e2 = _run_engine(rebalance_freq=2) # Final capital should differ f1 = e1.balance["total capital"].iloc[-1] f2 = e2.balance["total capital"].iloc[-1] # They CAN be equal but usually aren't assert e1.balance is not None assert e2.balance is not None # --------------------------------------------------------------------------- # Per-leg overrides # --------------------------------------------------------------------------- class TestPerLegOverrides: """Test per-leg signal selector and fill model overrides.""" def test_per_leg_signal_selector(self): options_data = _options_data() schema = options_data.schema leg = StrategyLeg( "leg_1", schema, option_type=Type.PUT, direction=Direction.BUY, signal_selector=FirstMatch(), ) leg.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat = Strategy(schema) strat.add_legs([leg]) engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = options_data engine.options_strategy = strat engine.run(rebalance_freq=1) assert engine.balance is not None def test_per_leg_fill_model(self): options_data = _options_data() schema = options_data.schema leg = StrategyLeg( "leg_1", schema, option_type=Type.PUT, direction=Direction.BUY, fill_model=MidPrice(), ) leg.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat = Strategy(schema) strat.add_legs([leg]) engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), signal_selector=NearestDelta(target_delta=-0.30), ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = options_data engine.options_strategy = strat engine.run(rebalance_freq=1) assert engine.balance is not None def test_midprice_fill_produces_different_costs(self): """MidPrice should produce different costs than MarketAtBidAsk.""" e_market = _run_engine(fill_model=MarketAtBidAsk()) e_mid = _run_engine(fill_model=MidPrice()) if not e_market.trade_log.empty and not e_mid.trade_log.empty: c_m = e_market.trade_log["totals"]["cost"].values[0] c_mid = e_mid.trade_log["totals"]["cost"].values[0] # MidPrice should be between bid and ask assert c_mid != c_m or c_mid == c_m # just confirm no crash # --------------------------------------------------------------------------- # Edge cases # --------------------------------------------------------------------------- class TestEngineEdgeCases: """Edge cases that should not crash the engine.""" def test_all_cash_allocation(self): engine = BacktestEngine( {"stocks": 0, "options": 0, "cash": 1.0}, cost_model=NoCosts(), signal_selector=NearestDelta(target_delta=-0.30), ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = _options_data() engine.options_strategy = _buy_strategy(engine.options_data.schema) engine.run(rebalance_freq=1) assert engine.balance is not None def test_tiny_initial_capital(self): engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, initial_capital=100, cost_model=NoCosts(), signal_selector=NearestDelta(target_delta=-0.30), ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = _options_data() engine.options_strategy = _buy_strategy(engine.options_data.schema) engine.run(rebalance_freq=1) assert engine.balance is not None def test_large_initial_capital(self): engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, initial_capital=10_000_000_000, cost_model=NoCosts(), signal_selector=NearestDelta(target_delta=-0.30), ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = _options_data() engine.options_strategy = _buy_strategy(engine.options_data.schema) engine.run(rebalance_freq=1) assert engine.balance is not None assert engine.balance["total capital"].iloc[0] == 10_000_000_000 ================================================ FILE: tests/engine/test_engine_unit.py ================================================ """Unit tests for BacktestEngine internals — repr, metadata, static methods.""" import json from options_portfolio_backtester.engine.engine import BacktestEngine class TestBacktestEngineRepr: def test_repr_basic(self): engine = BacktestEngine( allocation={"stocks": 0.9, "options": 0.1, "cash": 0.0}, initial_capital=500_000, ) r = repr(engine) assert "BacktestEngine" in r assert "500000" in r assert "NoCosts" in r def test_repr_with_custom_cost_model(self): from options_portfolio_backtester.execution.cost_model import PerContractCommission engine = BacktestEngine( allocation={"stocks": 0.9, "options": 0.1, "cash": 0.0}, cost_model=PerContractCommission(0.65), ) r = repr(engine) assert "PerContractCommission" in r class TestSha256Json: def test_deterministic(self): payload = {"a": 1, "b": "hello"} h1 = BacktestEngine._sha256_json(payload) h2 = BacktestEngine._sha256_json(payload) assert h1 == h2 assert len(h1) == 64 # SHA-256 hex length def test_different_inputs_different_hashes(self): h1 = BacktestEngine._sha256_json({"x": 1}) h2 = BacktestEngine._sha256_json({"x": 2}) assert h1 != h2 def test_key_order_independent(self): h1 = BacktestEngine._sha256_json({"a": 1, "b": 2}) h2 = BacktestEngine._sha256_json({"b": 2, "a": 1}) assert h1 == h2 class TestGitSha: def test_returns_string(self): sha = BacktestEngine._git_sha() assert isinstance(sha, str) # Should be either a hex sha or "unknown" assert sha == "unknown" or len(sha) == 40 class TestFlatTradeLogToMultiIndex: def test_empty_dataframe(self): import pandas as pd engine = BacktestEngine( allocation={"stocks": 0.9, "options": 0.1, "cash": 0.0}, ) result = engine._flat_trade_log_to_multiindex(pd.DataFrame()) assert result.empty def test_converts_double_underscore_columns(self): import pandas as pd df = pd.DataFrame({ "leg_1__contract": ["SPY_C_001"], "leg_1__cost": [500.0], "totals__qty": [1], }) engine = BacktestEngine( allocation={"stocks": 0.9, "options": 0.1, "cash": 0.0}, ) result = engine._flat_trade_log_to_multiindex(df) assert isinstance(result.columns, pd.MultiIndex) assert ("leg_1", "contract") in result.columns assert ("totals", "qty") in result.columns class TestEventsDataframe: def test_empty_events(self): engine = BacktestEngine( allocation={"stocks": 0.9, "options": 0.1, "cash": 0.0}, ) df = engine.events_dataframe() assert list(df.columns) == ["date", "event", "status"] assert len(df) == 0 class TestAllocationNormalization: def test_normalizes_to_sum_one(self): engine = BacktestEngine( allocation={"stocks": 60, "options": 30, "cash": 10}, ) total = sum(engine.allocation.values()) assert abs(total - 1.0) < 1e-10 def test_missing_keys_default_to_zero(self): engine = BacktestEngine(allocation={"stocks": 1.0}) assert engine.allocation["options"] == 0.0 assert engine.allocation["cash"] == 0.0 ================================================ FILE: tests/engine/test_full_liquidation.py ================================================ """Tests for option rebalance accounting. Verifies that at every rebalance: 1. Exit filters run on held positions (positions persist if not matched) 2. Fresh options matching entry criteria are purchased with remaining budget 3. Cash accounting is clean (no money creation via double-counting) 4. Max drawdown never exceeds 100% 5. Total capital = cash + stocks capital + options capital """ import os import numpy as np import pandas as pd import pytest from options_portfolio_backtester.engine.engine import BacktestEngine from options_portfolio_backtester.execution.cost_model import NoCosts, PerContractCommission from options_portfolio_backtester.execution.signal_selector import NearestDelta from options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.core.types import Stock, OptionType as Type, Direction TEST_DIR = os.path.join(os.path.dirname(__file__), "..", "test_data") STOCKS_FILE = os.path.join(TEST_DIR, "ivy_5assets_data.csv") OPTIONS_FILE = os.path.join(TEST_DIR, "options_data.csv") def _ivy_stocks(): return [Stock("VTI", 0.2), Stock("VEU", 0.2), Stock("BND", 0.2), Stock("VNQ", 0.2), Stock("DBC", 0.2)] def _stocks_data(): data = TiingoData(STOCKS_FILE) data._data["adjClose"] = 10 return data def _options_data(): data = HistoricalOptionsData(OPTIONS_FILE) data._data.at[2, "ask"] = 1 data._data.at[2, "bid"] = 0.5 data._data.at[51, "ask"] = 1.5 data._data.at[50, "bid"] = 0.5 data._data.at[130, "bid"] = 0.5 data._data.at[131, "bid"] = 1.5 data._data.at[206, "bid"] = 0.5 data._data.at[207, "bid"] = 1.5 return data def _build_strategy(schema, direction=Direction.BUY): strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=direction) leg.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat.add_legs([leg]) return strat def _run(cost_model=None, direction=Direction.BUY, signal_selector=None): stocks = _ivy_stocks() stocks_data = _stocks_data() options_data = _options_data() schema = options_data.schema engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=cost_model or NoCosts(), signal_selector=signal_selector or NearestDelta(target_delta=-0.30), ) engine.stocks = stocks engine.stocks_data = stocks_data engine.options_data = options_data engine.options_strategy = _build_strategy(schema, direction=direction) engine.run(rebalance_freq=1, monthly=False) return engine # --------------------------------------------------------------------------- # Liquidation trade pattern # --------------------------------------------------------------------------- class TestTradePattern: """Verify trade log reflects positions persisting across rebalances.""" @pytest.fixture(autouse=True) def setup(self): self.engine = _run() def test_trades_are_entries(self): """With no exit filter triggers, trades should all be BTO entries.""" tl = self.engine.trade_log orders = tl["leg_1"]["order"].values assert all(o == "BTO" for o in orders), f"Expected all BTO, got {orders}" def test_exit_filter_produces_exits(self): """Positions matching exit filter should eventually be exited.""" engine = _run() # Engine should complete with trades (entries happen) assert not engine.trade_log.empty def test_first_trade_is_entry(self): """First trade should be an entry (BTO for BUY direction).""" tl = self.engine.trade_log orders = tl["leg_1"]["order"].values assert orders[0] == "BTO" # --------------------------------------------------------------------------- # Cash accounting # --------------------------------------------------------------------------- class TestCashAccounting: """Verify no money creation or destruction.""" @pytest.fixture(autouse=True) def setup(self): self.engine = _run() def test_max_drawdown_under_100_pct(self): """Max drawdown must never exceed 100% — portfolio can't go negative.""" bal = self.engine.balance["total capital"] running_max = bal.cummax() dd = (running_max - bal) / running_max assert dd.max() < 1.0, f"Max drawdown {dd.max():.4f} >= 100%" def test_total_capital_always_positive(self): """Total capital should stay positive.""" bal = self.engine.balance["total capital"] assert (bal > 0).all(), f"Negative capital found: min={bal.min()}" def test_total_capital_equals_sum_of_parts(self): """total capital = cash + stocks capital + options capital on every row.""" bal = self.engine.balance computed = bal["cash"] + bal["stocks capital"] + bal["options capital"] # Allow small floating point differences diff = (bal["total capital"] - computed).abs() assert diff.max() < 0.01, f"Max capital discrepancy: {diff.max()}" def test_initial_capital_preserved(self): """First row has the initial capital.""" first_total = self.engine.balance["total capital"].iloc[0] assert abs(first_total - 1_000_000) < 1.0 def test_no_capital_inflation(self): """Final capital should not exceed initial by an unreasonable amount. With a 3% put allocation on flat stock data ($10 fixed), capital should decrease (put premiums are a cost) or stay roughly the same. """ final = self.engine.balance["total capital"].iloc[-1] assert final <= 1_050_000, f"Capital inflated to {final} — possible money creation" # --------------------------------------------------------------------------- # Direction variants # --------------------------------------------------------------------------- class TestDirectionVariants: def test_buy_put_cash_stays_positive(self): engine = _run(direction=Direction.BUY) assert (engine.balance["cash"] >= -0.01).all() def test_sell_put_has_credit_entries(self): engine = _run(direction=Direction.SELL) tl = engine.trade_log sto_mask = tl["leg_1"]["order"] == "STO" sto_costs = tl.loc[sto_mask, ("leg_1", "cost")].values assert all(c < 0 for c in sto_costs if c != 0), ( f"STO costs should be negative (credit), got: {sto_costs}" ) def test_sell_put_max_dd_under_100(self): engine = _run(direction=Direction.SELL) bal = engine.balance["total capital"] dd = ((bal.cummax() - bal) / bal.cummax()).max() assert dd < 1.0, f"Sell-put max drawdown {dd:.4f} >= 100%" # --------------------------------------------------------------------------- # Commission impact # --------------------------------------------------------------------------- class TestCommissionImpact: def test_commission_reduces_capital(self): """More trades from liquidation means commission impact is larger.""" no_cost = _run() with_cost = _run(cost_model=PerContractCommission(0.65)) no_cost_final = no_cost.balance["total capital"].iloc[-1] cost_final = with_cost.balance["total capital"].iloc[-1] assert cost_final < no_cost_final def test_high_commission_still_positive(self): """Even with high commissions, capital stays positive.""" engine = _run(cost_model=PerContractCommission(5.00)) assert (engine.balance["total capital"] > 0).all() # Rust-Python parity tests removed: all execution is now Rust-only. ================================================ FILE: tests/engine/test_max_notional.py ================================================ """Tests for max_notional_pct engine parameter.""" import os from options_portfolio_backtester.engine.engine import BacktestEngine from options_portfolio_backtester.execution.cost_model import NoCosts from options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.core.types import Stock, OptionType as Type, Direction TEST_DIR = os.path.join(os.path.dirname(__file__), "..", "test_data") STOCKS_FILE = os.path.join(TEST_DIR, "ivy_5assets_data.csv") OPTIONS_FILE = os.path.join(TEST_DIR, "options_data.csv") def _ivy_stocks(): return [Stock("VTI", 0.2), Stock("VEU", 0.2), Stock("BND", 0.2), Stock("VNQ", 0.2), Stock("DBC", 0.2)] def _stocks_data(): data = TiingoData(STOCKS_FILE) data._data["adjClose"] = 10 return data def _options_data(): data = HistoricalOptionsData(OPTIONS_FILE) data._data.at[2, "ask"] = 1 data._data.at[2, "bid"] = 0.5 data._data.at[51, "ask"] = 1.5 data._data.at[50, "bid"] = 0.5 data._data.at[130, "bid"] = 0.5 data._data.at[131, "bid"] = 1.5 data._data.at[206, "bid"] = 0.5 data._data.at[207, "bid"] = 1.5 return data def _buy_strategy(schema): """Long-only put strategy (no SELL legs).""" strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) leg.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat.add_legs([leg]) return strat def _sell_strategy(schema): """Short put strategy (SELL leg) — triggers notional cap.""" strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.SELL) leg.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat.add_legs([leg]) return strat def _straddle_strategy(schema): """Short straddle (2 SELL legs) — tests multi-leg notional summing.""" strat = Strategy(schema) call = StrategyLeg("leg_1", schema, option_type=Type.CALL, direction=Direction.SELL) call.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) call.exit_filter = schema.dte <= 30 put = StrategyLeg("leg_2", schema, option_type=Type.PUT, direction=Direction.SELL) put.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) put.exit_filter = schema.dte <= 30 strat.add_legs([call, put]) return strat def _run_engine(max_notional_pct=None, strategy_fn=None): stocks = _ivy_stocks() stocks_data = _stocks_data() options_data = _options_data() schema = options_data.schema engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), max_notional_pct=max_notional_pct, ) engine.stocks = stocks engine.stocks_data = stocks_data engine.options_data = options_data engine.options_strategy = (strategy_fn or _buy_strategy)(schema) engine.run(rebalance_freq=1) return engine class TestMaxNotionalPct: """Verify max_notional_pct caps short option notional exposure.""" def test_none_is_backward_compatible(self): """max_notional_pct=None should produce the same result as before.""" engine_default = _run_engine(max_notional_pct=None) assert not engine_default.balance.empty assert not engine_default.trade_log.empty def test_long_only_unaffected(self): """Long-only strategies should be unaffected by the notional cap.""" engine_no_cap = _run_engine(max_notional_pct=None, strategy_fn=_buy_strategy) engine_with_cap = _run_engine(max_notional_pct=0.01, strategy_fn=_buy_strategy) assert len(engine_no_cap.trade_log) == len(engine_with_cap.trade_log) def test_sell_strategy_capped(self): """A tight notional cap should reduce qty on short strategies.""" engine_no_cap = _run_engine(max_notional_pct=None, strategy_fn=_sell_strategy) # 1% of 1M = 10k; one contract at strike 650 = 65k notional → 0 qty engine_tight = _run_engine(max_notional_pct=0.01, strategy_fn=_sell_strategy) no_cap_trades = len(engine_no_cap.trade_log) tight_trades = len(engine_tight.trade_log) assert tight_trades <= no_cap_trades def test_zero_cap_blocks_all_short_trades(self): """max_notional_pct=0 should block all short option entries.""" engine = _run_engine(max_notional_pct=0.0, strategy_fn=_sell_strategy) assert len(engine.trade_log) == 0 def test_generous_cap_allows_trades(self): """A generous notional cap should still allow trades (more than a tight cap).""" engine_generous = _run_engine(max_notional_pct=10.0, strategy_fn=_sell_strategy) engine_tight = _run_engine(max_notional_pct=0.01, strategy_fn=_sell_strategy) assert len(engine_generous.trade_log) >= len(engine_tight.trade_log) assert len(engine_generous.trade_log) > 0 def test_straddle_both_legs_contribute_notional(self): """A straddle has 2 SELL legs — both should count toward notional cap.""" # Tight cap blocks straddle (2× notional vs single put) engine_straddle = _run_engine(max_notional_pct=0.01, strategy_fn=_straddle_strategy) engine_put = _run_engine(max_notional_pct=0.01, strategy_fn=_sell_strategy) # Both should be blocked at this cap level assert len(engine_straddle.trade_log) <= len(engine_put.trade_log) def test_cap_monotonic(self): """Increasing the cap should never reduce the number of trades.""" caps = [0.01, 0.10, 0.50, 1.0, 10.0] trade_counts = [] for cap in caps: engine = _run_engine(max_notional_pct=cap, strategy_fn=_sell_strategy) trade_counts.append(len(engine.trade_log)) for i in range(len(trade_counts) - 1): assert trade_counts[i] <= trade_counts[i + 1], ( f"cap {caps[i]} had {trade_counts[i]} trades but " f"cap {caps[i+1]} had {trade_counts[i+1]}" ) ================================================ FILE: tests/engine/test_multi_strategy.py ================================================ """Tests for MultiStrategyEngine.""" import os import numpy as np import pandas as pd import pytest from options_portfolio_backtester.engine.multi_strategy import ( StrategyAllocation, MultiStrategyEngine, ) from options_portfolio_backtester.engine.engine import BacktestEngine from options_portfolio_backtester.data.providers import ( TiingoData, HistoricalOptionsData, ) from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.core.types import Direction, OptionType, Stock @pytest.fixture def data_dir(): return os.path.join(os.path.dirname(os.path.dirname(__file__)), "data") def _make_engine(data_dir): stocks_path = os.path.join(data_dir, "test_stocks.csv") options_path = os.path.join(data_dir, "test_options.csv") if not os.path.exists(stocks_path) or not os.path.exists(options_path): pytest.skip("test data files not available") stocks_data = TiingoData(stocks_path) options_data = HistoricalOptionsData(options_path) schema = options_data.schema strategy = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=OptionType.CALL, direction=Direction.SELL) leg.entry_filter = ( (schema.underlying == "SPY") & (schema.dte >= 30) & (schema.dte <= 60) ) leg.exit_filter = schema.dte <= 7 strategy.add_leg(leg) engine = BacktestEngine( allocation={"stocks": 0.9, "options": 0.1, "cash": 0.0}, initial_capital=500_000, ) engine.stocks = [Stock("SPY", 1.0)] engine.stocks_data = stocks_data engine.options_data = options_data engine.options_strategy = strategy return engine class TestStrategyAllocation: def test_fields(self): engine = BacktestEngine(allocation={"stocks": 1.0}) sa = StrategyAllocation(name="test", engine=engine, weight=0.5) assert sa.name == "test" assert sa.weight == 0.5 assert sa.engine is engine class TestMultiStrategyEngine: def test_weight_normalization(self): e1 = BacktestEngine(allocation={"stocks": 1.0}) e2 = BacktestEngine(allocation={"stocks": 1.0}) mse = MultiStrategyEngine( strategies=[ StrategyAllocation("a", e1, weight=3.0), StrategyAllocation("b", e2, weight=1.0), ], initial_capital=1_000_000, ) assert abs(mse._weights["a"] - 0.75) < 1e-10 assert abs(mse._weights["b"] - 0.25) < 1e-10 def test_equal_weights(self): engines = [BacktestEngine(allocation={"stocks": 1.0}) for _ in range(3)] mse = MultiStrategyEngine( strategies=[ StrategyAllocation(f"s{i}", e) for i, e in enumerate(engines) ], ) for w in mse._weights.values(): assert abs(w - 1.0 / 3) < 1e-10 def test_run_with_mocked_engines(self): """Test run() and _build_combined_balance() without real data.""" dates = pd.bdate_range("2020-01-01", periods=5) class FakeEngine: def __init__(self): self.initial_capital = 100_000 self.balance = pd.DataFrame({ "total capital": [100000, 101000, 102000, 101500, 103000], "% change": [0.0, 0.01, 0.0099, -0.0049, 0.0148], }, index=dates) def run(self, **kwargs): return pd.DataFrame() # empty trade log e1, e2 = FakeEngine(), FakeEngine() mse = MultiStrategyEngine( strategies=[ StrategyAllocation("a", e1, weight=0.6), StrategyAllocation("b", e2, weight=0.4), ], initial_capital=1_000_000, ) results = mse.run(rebalance_freq=1) assert "a" in results assert "b" in results assert hasattr(mse, "balance") assert "total capital" in mse.balance.columns assert "% change" in mse.balance.columns assert "accumulated return" in mse.balance.columns assert len(mse.balance) == 5 # Capital share should be updated assert e1.initial_capital == 600_000 assert e2.initial_capital == 400_000 def test_run_engine_without_balance(self): """Engines that don't produce a balance still work.""" class NoBalanceEngine: def __init__(self): self.initial_capital = 0 def run(self, **kwargs): return pd.DataFrame() e1 = NoBalanceEngine() mse = MultiStrategyEngine( strategies=[StrategyAllocation("x", e1, weight=1.0)], initial_capital=500_000, ) results = mse.run() assert "x" in results assert mse.balance.empty def test_run_with_data(self, data_dir): e1 = _make_engine(data_dir) e2 = _make_engine(data_dir) mse = MultiStrategyEngine( strategies=[ StrategyAllocation("strat_a", e1, weight=0.6), StrategyAllocation("strat_b", e2, weight=0.4), ], initial_capital=1_000_000, ) results = mse.run(rebalance_freq=1) assert "strat_a" in results assert "strat_b" in results assert hasattr(mse, "balance") assert "total capital" in mse.balance.columns assert "% change" in mse.balance.columns assert "accumulated return" in mse.balance.columns ================================================ FILE: tests/engine/test_multi_strategy_engine.py ================================================ """Tests for multi-strategy support within BacktestEngine. Verifies that add_strategy() + run() produces correct results when multiple strategies share a single capital pool and balance sheet. """ import math import os import numpy as np import pandas as pd import pytest from options_portfolio_backtester.engine.engine import BacktestEngine, _StrategySlot from options_portfolio_backtester.execution.cost_model import NoCosts from options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.core.types import ( Stock, OptionType as Type, Direction, ) TEST_DIR = os.path.join(os.path.dirname(__file__), "..", "test_data") STOCKS_FILE = os.path.join(TEST_DIR, "ivy_5assets_data.csv") OPTIONS_FILE = os.path.join(TEST_DIR, "options_data.csv") def _ivy_stocks(): return [ Stock("VTI", 0.2), Stock("VEU", 0.2), Stock("BND", 0.2), Stock("VNQ", 0.2), Stock("DBC", 0.2), ] def _stocks_data(): data = TiingoData(STOCKS_FILE) data._data["adjClose"] = 10 return data def _options_data(): data = HistoricalOptionsData(OPTIONS_FILE) data._data.at[2, "ask"] = 1 data._data.at[2, "bid"] = 0.5 data._data.at[51, "ask"] = 1.5 data._data.at[50, "bid"] = 0.5 data._data.at[130, "bid"] = 0.5 data._data.at[131, "bid"] = 1.5 data._data.at[206, "bid"] = 0.5 data._data.at[207, "bid"] = 1.5 return data def _buy_put_strategy(schema): """Single BUY PUT leg.""" strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) leg.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat.add_legs([leg]) return strat def _sell_call_strategy(schema): """Single SELL CALL leg.""" strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.CALL, direction=Direction.SELL) leg.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat.add_legs([leg]) return strat def _make_engine(**kwargs): engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), **kwargs, ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = _options_data() return engine # --------------------------------------------------------------------------- # _StrategySlot unit tests # --------------------------------------------------------------------------- class TestStrategySlot: def test_dataclass_fields(self): schema = _options_data().schema strat = _buy_put_strategy(schema) slot = _StrategySlot( strategy=strat, weight=0.5, rebalance_freq=1, name="test_slot", ) assert slot.weight == 0.5 assert slot.rebalance_freq == 1 assert slot.rebalance_unit == "BMS" assert slot.check_exits_daily is False assert slot.name == "test_slot" assert slot.inventory is None assert slot.rebalance_dates is None # --------------------------------------------------------------------------- # add_strategy() API tests # --------------------------------------------------------------------------- class TestAddStrategy: def test_adds_slot(self): engine = _make_engine() schema = engine.options_data.schema strat = _buy_put_strategy(schema) engine.add_strategy(strat, weight=1.0, rebalance_freq=1) assert engine._is_multi_strategy assert len(engine._strategy_slots) == 1 def test_auto_names(self): engine = _make_engine() schema = engine.options_data.schema engine.add_strategy(_buy_put_strategy(schema), weight=0.5, rebalance_freq=1) engine.add_strategy(_sell_call_strategy(schema), weight=0.5, rebalance_freq=1) assert engine._strategy_slots[0].name == "strategy_0" assert engine._strategy_slots[1].name == "strategy_1" def test_custom_names(self): engine = _make_engine() schema = engine.options_data.schema engine.add_strategy( _buy_put_strategy(schema), weight=0.5, rebalance_freq=1, name="hedge" ) engine.add_strategy( _sell_call_strategy(schema), weight=0.5, rebalance_freq=1, name="income" ) assert engine._strategy_slots[0].name == "hedge" assert engine._strategy_slots[1].name == "income" def test_not_multi_strategy_by_default(self): engine = _make_engine() assert not engine._is_multi_strategy # --------------------------------------------------------------------------- # Validation # --------------------------------------------------------------------------- class TestValidation: def test_weights_must_sum_to_one(self): engine = _make_engine() schema = engine.options_data.schema engine.add_strategy(_buy_put_strategy(schema), weight=0.5, rebalance_freq=1) engine.add_strategy(_sell_call_strategy(schema), weight=0.3, rebalance_freq=1) with pytest.raises(AssertionError, match="weights must sum to 1.0"): engine.run(rebalance_freq=1) # --------------------------------------------------------------------------- # Two strategies, same frequency # --------------------------------------------------------------------------- class TestSameFrequency: @pytest.fixture(autouse=True) def setup(self): self.engine = _make_engine() schema = self.engine.options_data.schema self.engine.add_strategy( _buy_put_strategy(schema), weight=0.5, rebalance_freq=1, name="hedge" ) self.engine.add_strategy( _buy_put_strategy(schema), weight=0.5, rebalance_freq=1, name="hedge2" ) self.engine.run() def test_balance_not_empty(self): assert not self.engine.balance.empty def test_balance_has_required_columns(self): cols = self.engine.balance.columns for c in ["cash", "calls capital", "puts capital", "options capital", "stocks capital", "total capital", "% change", "accumulated return"]: assert c in cols, f"Missing column: {c}" def test_has_run_metadata(self): assert "framework" in self.engine.run_metadata def test_trade_log_type(self): # May be empty if no candidates, but must be a DataFrame assert isinstance(self.engine.trade_log, pd.DataFrame) # --------------------------------------------------------------------------- # Two strategies, different frequencies # --------------------------------------------------------------------------- class TestDifferentFrequency: @pytest.fixture(autouse=True) def setup(self): self.engine = _make_engine() schema = self.engine.options_data.schema # Strategy A: rebalance every 1 BMS self.engine.add_strategy( _buy_put_strategy(schema), weight=0.7, rebalance_freq=1, name="monthly" ) # Strategy B: rebalance every 2 BMS (less frequent) self.engine.add_strategy( _buy_put_strategy(schema), weight=0.3, rebalance_freq=2, name="bimonthly" ) self.engine.run() def test_balance_not_empty(self): assert not self.engine.balance.empty def test_total_capital_computed(self): assert "total capital" in self.engine.balance.columns # Total capital should exist and be positive assert self.engine.balance["total capital"].iloc[-1] > 0 # --------------------------------------------------------------------------- # Single strategy via old API is unchanged # --------------------------------------------------------------------------- class TestBackwardCompat: def test_single_strategy_api_unchanged(self): engine = _make_engine() schema = engine.options_data.schema engine.options_strategy = _buy_put_strategy(schema) engine.run(rebalance_freq=1) assert not engine.balance.empty assert "framework" in engine.run_metadata # --------------------------------------------------------------------------- # Shared cash pool # --------------------------------------------------------------------------- class TestSharedCash: def test_cash_flows_into_shared_pool(self): engine = _make_engine() schema = engine.options_data.schema engine.add_strategy( _buy_put_strategy(schema), weight=0.5, rebalance_freq=1, name="a" ) engine.add_strategy( _buy_put_strategy(schema), weight=0.5, rebalance_freq=1, name="b" ) engine.run() # After running, current_cash should be a single float (shared pool) assert isinstance(engine.current_cash, float) # --------------------------------------------------------------------------- # Per-strategy exit thresholds # --------------------------------------------------------------------------- class TestPerStrategyExitThresholds: def test_different_exit_thresholds(self): engine = _make_engine() schema = engine.options_data.schema strat_tight = _buy_put_strategy(schema) strat_tight.add_exit_thresholds(profit_pct=0.1, loss_pct=0.1) strat_loose = _buy_put_strategy(schema) strat_loose.add_exit_thresholds(profit_pct=math.inf, loss_pct=math.inf) engine.add_strategy(strat_tight, weight=0.5, rebalance_freq=1, name="tight") engine.add_strategy(strat_loose, weight=0.5, rebalance_freq=1, name="loose") # Should not crash — exit thresholds are read from each strategy via context engine.run() assert not engine.balance.empty # --------------------------------------------------------------------------- # check_exits_daily per-strategy # --------------------------------------------------------------------------- class TestPerStrategyDailyExits: def test_daily_exits_per_slot(self): engine = _make_engine() schema = engine.options_data.schema engine.add_strategy( _buy_put_strategy(schema), weight=0.5, rebalance_freq=1, check_exits_daily=True, name="daily_exits" ) engine.add_strategy( _buy_put_strategy(schema), weight=0.5, rebalance_freq=1, check_exits_daily=False, name="no_daily_exits" ) engine.run() assert not engine.balance.empty def test_global_check_exits_daily(self): engine = _make_engine() schema = engine.options_data.schema engine.add_strategy( _buy_put_strategy(schema), weight=0.5, rebalance_freq=1, name="a" ) engine.add_strategy( _buy_put_strategy(schema), weight=0.5, rebalance_freq=1, name="b" ) # Global check_exits_daily should apply to all slots engine.run(check_exits_daily=True) assert not engine.balance.empty # --------------------------------------------------------------------------- # stop_if_broke halts entire engine # --------------------------------------------------------------------------- class TestStopIfBroke: def test_stop_halts_multi_strategy(self): engine = _make_engine(stop_if_broke=True) schema = engine.options_data.schema engine.add_strategy( _buy_put_strategy(schema), weight=0.5, rebalance_freq=1, name="a" ) engine.add_strategy( _buy_put_strategy(schema), weight=0.5, rebalance_freq=1, name="b" ) # Should not crash; stop_if_broke is checked in multi-strategy loop engine.run() assert isinstance(engine.balance, pd.DataFrame) # --------------------------------------------------------------------------- # Rust full-loop is NOT used in multi-strategy mode # --------------------------------------------------------------------------- class TestRustGate: def test_multi_strategy_produces_metadata(self): engine = _make_engine() schema = engine.options_data.schema engine.add_strategy( _buy_put_strategy(schema), weight=1.0, rebalance_freq=1 ) engine.run() assert "framework" in engine.run_metadata # --------------------------------------------------------------------------- # Comparison: multi-strategy with single slot vs single-strategy # --------------------------------------------------------------------------- class TestSingleSlotEquivalence: def test_single_slot_produces_balance(self): """A single add_strategy() call should produce a valid balance.""" engine = _make_engine() schema = engine.options_data.schema engine.add_strategy( _buy_put_strategy(schema), weight=1.0, rebalance_freq=1 ) engine.run() assert not engine.balance.empty assert engine.balance["total capital"].iloc[-1] > 0 # --------------------------------------------------------------------------- # options_budget compatibility # --------------------------------------------------------------------------- class TestOptionsBudget: def test_options_budget_pct(self): engine = _make_engine() engine.options_budget_pct = 0.005 schema = engine.options_data.schema engine.add_strategy( _buy_put_strategy(schema), weight=0.5, rebalance_freq=1, name="a" ) engine.add_strategy( _buy_put_strategy(schema), weight=0.5, rebalance_freq=1, name="b" ) engine.run() assert not engine.balance.empty ================================================ FILE: tests/engine/test_per_leg_overrides.py ================================================ """Tests for per-leg signal_selector and fill_model overrides.""" import os import numpy as np import pandas as pd import pytest from options_portfolio_backtester.engine.engine import BacktestEngine from options_portfolio_backtester.execution.cost_model import NoCosts from options_portfolio_backtester.execution.fill_model import MarketAtBidAsk, MidPrice from options_portfolio_backtester.execution.signal_selector import FirstMatch, NearestDelta, SignalSelector from options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.core.types import Stock, OptionType as Type, Direction # Use new StrategyLeg that supports signal_selector/fill_model from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg TEST_DIR = os.path.join(os.path.dirname(__file__), "..", "test_data") STOCKS_FILE = os.path.join(TEST_DIR, "ivy_5assets_data.csv") OPTIONS_FILE = os.path.join(TEST_DIR, "options_data.csv") def _ivy_stocks(): return [Stock("VTI", 0.2), Stock("VEU", 0.2), Stock("BND", 0.2), Stock("VNQ", 0.2), Stock("DBC", 0.2)] def _stocks_data(): data = TiingoData(STOCKS_FILE) data._data["adjClose"] = 10 return data def _options_data(): data = HistoricalOptionsData(OPTIONS_FILE) data._data.at[2, "ask"] = 1 data._data.at[2, "bid"] = 0.5 data._data.at[51, "ask"] = 1.5 data._data.at[50, "bid"] = 0.5 data._data.at[130, "bid"] = 0.5 data._data.at[131, "bid"] = 1.5 data._data.at[206, "bid"] = 0.5 data._data.at[207, "bid"] = 1.5 return data class TestPerLegSignalSelector: """Verify per-leg signal selector overrides the engine-level one. All execution goes through Rust, so we verify per-leg overrides via the Rust config translation (to_rust_config on standard selectors). """ def test_leg_selector_overrides_engine(self): """A per-leg selector should be used instead of the engine-level one.""" from options_portfolio_backtester.execution.signal_selector import NearestDelta, MaxOpenInterest options_data = _options_data() schema = options_data.schema # Create leg with per-leg NearestDelta selector leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY, signal_selector=NearestDelta(target_delta=-0.30)) leg.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat = Strategy(schema) strat.add_legs([leg]) engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), signal_selector=FirstMatch(), # engine-level, should be overridden ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = options_data engine.options_strategy = strat engine.run(rebalance_freq=1) assert engine.balance is not None # Per-leg selector is translated to Rust config; engine completes successfully assert not engine.balance.empty def test_engine_selector_used_when_leg_has_none(self): """When leg has no signal_selector, engine-level selector is used.""" options_data = _options_data() schema = options_data.schema leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) leg.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat = Strategy(schema) strat.add_legs([leg]) engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), signal_selector=FirstMatch(), ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = options_data engine.options_strategy = strat engine.run(rebalance_freq=1) assert not engine.balance.empty class TestPerLegFillModel: """Verify per-leg fill model overrides the engine-level one.""" def test_midprice_differs_from_market(self): """MidPrice fill model should produce different costs than MarketAtBidAsk.""" options_data = _options_data() schema = options_data.schema # Run with default MarketAtBidAsk leg_market = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY, fill_model=MarketAtBidAsk()) leg_market.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg_market.exit_filter = schema.dte <= 30 strat_market = Strategy(schema) strat_market.add_legs([leg_market]) engine_market = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), ) engine_market.stocks = _ivy_stocks() engine_market.stocks_data = _stocks_data() engine_market.options_data = _options_data() engine_market.options_strategy = strat_market engine_market.run(rebalance_freq=1) # Run with MidPrice fill model options_data2 = _options_data() schema2 = options_data2.schema leg_mid = StrategyLeg("leg_1", schema2, option_type=Type.PUT, direction=Direction.BUY, fill_model=MidPrice()) leg_mid.entry_filter = (schema2.underlying == "SPX") & (schema2.dte >= 60) leg_mid.exit_filter = schema2.dte <= 30 strat_mid = Strategy(schema2) strat_mid.add_legs([leg_mid]) engine_mid = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), ) engine_mid.stocks = _ivy_stocks() engine_mid.stocks_data = _stocks_data() engine_mid.options_data = options_data2 engine_mid.options_strategy = strat_mid engine_mid.run(rebalance_freq=1) # Both should have trades assert not engine_market.trade_log.empty assert not engine_mid.trade_log.empty # Costs should differ because MidPrice uses (bid+ask)/2 instead of ask market_costs = engine_market.trade_log["leg_1"]["cost"].values mid_costs = engine_mid.trade_log["leg_1"]["cost"].values if len(market_costs) > 0 and len(mid_costs) > 0: # MidPrice for BUY should be cheaper than MarketAtBidAsk (which uses ask) # Different fill models may produce different numbers of trades if len(market_costs) != len(mid_costs): pass # Different lengths means different results else: assert not np.allclose(market_costs, mid_costs, rtol=1e-6), \ "MidPrice should produce different costs than MarketAtBidAsk" ================================================ FILE: tests/engine/test_pipeline.py ================================================ from __future__ import annotations import pandas as pd import numpy as np from options_portfolio_backtester.engine.pipeline import ( AlgoPipelineBacktester, CapitalFlow, CloseDead, ClosePositionsAfterDates, CouponPayingPosition, HedgeRisks, LimitDeltas, LimitWeights, Margin, MaxDrawdownGuard, Not, Or, PipelineContext, RandomBenchmarkResult, Rebalance, RebalanceOverTime, ReplayTransactions, Require, RunAfterDate, RunAfterDays, RunDaily, RunEveryNPeriods, RunIfOutOfBounds, RunMonthly, RunOnce, RunOnDate, RunQuarterly, RunWeekly, RunYearly, ScaleWeights, SelectActive, SelectAll, SelectHasData, SelectMomentum, SelectN, SelectRandomly, SelectRegex, SelectThese, SelectWhere, StepDecision, TargetVol, WeighERC, WeighEqually, WeighInvVol, WeighMeanVar, WeighRandomly, WeighSpecified, WeighTarget, benchmark_random, ) def _prices() -> pd.DataFrame: idx = pd.to_datetime(["2024-01-02", "2024-01-03", "2024-02-01", "2024-02-02"]) return pd.DataFrame({"SPY": [100.0, 102.0, 101.0, 103.0]}, index=idx) # --------------------------------------------------------------------------- # RunMonthly # --------------------------------------------------------------------------- def test_pipeline_rebalances_on_month_start_only(): bt = AlgoPipelineBacktester( prices=_prices(), initial_capital=1000.0, algos=[RunMonthly(), SelectThese(["SPY"]), WeighSpecified({"SPY": 1.0}), Rebalance()], ) bal = bt.run() assert "SPY qty" in bal.columns assert bal.loc[pd.Timestamp("2024-01-02"), "SPY qty"] == 10 assert bal.loc[pd.Timestamp("2024-01-03"), "SPY qty"] == 10 assert bal.loc[pd.Timestamp("2024-02-01"), "SPY qty"] == 10 logs = bt.logs_dataframe() jan3 = logs[logs["date"] == pd.Timestamp("2024-01-03")] assert (jan3["status"] == "skip_day").any() def test_run_monthly_reset_allows_rerun(): """After reset(), the algo should not skip the first month on a second run.""" algos = [RunMonthly(), SelectThese(["SPY"]), WeighSpecified({"SPY": 1.0}), Rebalance()] bt = AlgoPipelineBacktester(prices=_prices(), initial_capital=1000.0, algos=algos) bal1 = bt.run() bal2 = bt.run() # second run should reset state assert bal1.loc[pd.Timestamp("2024-01-02"), "SPY qty"] == bal2.loc[pd.Timestamp("2024-01-02"), "SPY qty"] # --------------------------------------------------------------------------- # MaxDrawdownGuard # --------------------------------------------------------------------------- def test_drawdown_guard_blocks_rebalance(): prices = pd.DataFrame( {"SPY": [100.0, 60.0, 55.0]}, index=pd.to_datetime(["2024-01-02", "2024-02-01", "2024-03-01"]), ) bt = AlgoPipelineBacktester( prices=prices, initial_capital=1000.0, algos=[ RunMonthly(), SelectThese(["SPY"]), WeighSpecified({"SPY": 1.0}), MaxDrawdownGuard(max_drawdown_pct=0.20), Rebalance(), ], ) bal = bt.run() assert bal.loc[pd.Timestamp("2024-01-02"), "SPY qty"] == 10 assert bal.loc[pd.Timestamp("2024-02-01"), "SPY qty"] == 10 logs = bt.logs_dataframe() feb = logs[(logs["date"] == pd.Timestamp("2024-02-01")) & (logs["step"] == "MaxDrawdownGuard")] assert not feb.empty assert feb.iloc[0]["status"] == "skip_day" def test_drawdown_guard_reset(): guard = MaxDrawdownGuard(max_drawdown_pct=0.10) ctx = PipelineContext( date=pd.Timestamp("2024-01-02"), prices=pd.Series({"SPY": 100.0}), total_capital=1000.0, cash=1000.0, positions={}, ) guard(ctx) # sets _peak = 1000 assert guard._peak == 1000.0 guard.reset() assert guard._peak == 0.0 # --------------------------------------------------------------------------- # Stop status (item 13) # --------------------------------------------------------------------------- class _StopAlgo: def __call__(self, ctx: PipelineContext) -> StepDecision: if ctx.date >= pd.Timestamp("2024-02-01"): return StepDecision(status="stop", message="halt") return StepDecision() def test_stop_algo_halts_pipeline_early(): bt = AlgoPipelineBacktester( prices=_prices(), initial_capital=1000.0, algos=[_StopAlgo(), SelectThese(["SPY"]), WeighSpecified({"SPY": 1.0}), Rebalance()], ) bal = bt.run() # Should stop at 2024-02-01 — only 3 rows (Jan 2, Jan 3, Feb 1) assert len(bal) == 3 logs = bt.logs_dataframe() stop_rows = logs[logs["status"] == "stop"] assert len(stop_rows) == 1 assert stop_rows.iloc[0]["date"] == pd.Timestamp("2024-02-01") # --------------------------------------------------------------------------- # SelectThese # --------------------------------------------------------------------------- def test_select_these_filters_missing_symbols(): prices = pd.DataFrame( {"SPY": [100.0, 102.0], "TLT": [50.0, np.nan]}, index=pd.to_datetime(["2024-01-02", "2024-01-03"]), ) bt = AlgoPipelineBacktester( prices=prices, initial_capital=1000.0, algos=[SelectThese(["SPY", "TLT"]), WeighSpecified({"SPY": 0.5, "TLT": 0.5}), Rebalance()], ) bal = bt.run() # On Jan 3, TLT is NaN, so only SPY should be selected with normalized weight = 1.0 assert bal.loc[pd.Timestamp("2024-01-03"), "SPY qty"] > 0 def test_select_these_case_insensitive(): algo = SelectThese(["spy", "Tlt"]) assert algo.symbols == ["SPY", "TLT"] # --------------------------------------------------------------------------- # WeighSpecified # --------------------------------------------------------------------------- def test_weigh_specified_normalizes(): algo = WeighSpecified({"SPY": 2.0, "TLT": 1.0}) ctx = PipelineContext( date=pd.Timestamp("2024-01-02"), prices=pd.Series({"SPY": 100.0, "TLT": 50.0}), total_capital=1000.0, cash=1000.0, positions={}, selected_symbols=["SPY", "TLT"], ) algo(ctx) assert abs(ctx.target_weights["SPY"] - 2.0 / 3.0) < 1e-12 assert abs(ctx.target_weights["TLT"] - 1.0 / 3.0) < 1e-12 def test_weigh_specified_skips_on_empty_selected(): algo = WeighSpecified({"SPY": 1.0}) ctx = PipelineContext( date=pd.Timestamp("2024-01-02"), prices=pd.Series({"SPY": 100.0}), total_capital=1000.0, cash=1000.0, positions={}, selected_symbols=[], ) decision = algo(ctx) assert decision.status == "skip_day" # --------------------------------------------------------------------------- # Rebalance # --------------------------------------------------------------------------- def test_rebalance_computes_floor_qty(): ctx = PipelineContext( date=pd.Timestamp("2024-01-02"), prices=pd.Series({"SPY": 333.0}), total_capital=1000.0, cash=1000.0, positions={}, target_weights={"SPY": 1.0}, ) Rebalance()(ctx) # floor(1000 / 333) = 3 assert ctx.positions["SPY"] == 3.0 assert ctx.cash == 1000.0 - 3 * 333.0 def test_rebalance_skips_zero_price(): ctx = PipelineContext( date=pd.Timestamp("2024-01-02"), prices=pd.Series({"SPY": 0.0, "TLT": 50.0}), total_capital=1000.0, cash=1000.0, positions={}, target_weights={"SPY": 0.5, "TLT": 0.5}, ) Rebalance()(ctx) assert "SPY" not in ctx.positions assert ctx.positions["TLT"] == 10.0 # --------------------------------------------------------------------------- # Balance output structure # --------------------------------------------------------------------------- def test_balance_has_expected_columns(): bt = AlgoPipelineBacktester( prices=_prices(), initial_capital=1000.0, algos=[RunMonthly(), SelectThese(["SPY"]), WeighSpecified({"SPY": 1.0}), Rebalance()], ) bal = bt.run() for col in ["cash", "stocks capital", "total capital", "% change", "accumulated return"]: assert col in bal.columns, f"Missing column: {col}" def test_logs_dataframe_schema(): bt = AlgoPipelineBacktester( prices=_prices(), initial_capital=1000.0, algos=[RunMonthly(), SelectThese(["SPY"]), WeighSpecified({"SPY": 1.0}), Rebalance()], ) bt.run() logs = bt.logs_dataframe() assert list(logs.columns) == ["date", "step", "status", "message"] assert set(logs["status"].unique()) <= {"continue", "skip_day", "stop"} def test_empty_run_returns_empty_balance(): prices = pd.DataFrame({"SPY": pd.Series(dtype=float)}) bt = AlgoPipelineBacktester(prices=prices, initial_capital=1000.0, algos=[]) bal = bt.run() assert bal.empty # --------------------------------------------------------------------------- # Multi-symbol # --------------------------------------------------------------------------- def test_multi_symbol_rebalance(): idx = pd.to_datetime(["2024-01-02", "2024-02-01"]) prices = pd.DataFrame({"SPY": [100.0, 110.0], "TLT": [50.0, 48.0]}, index=idx) bt = AlgoPipelineBacktester( prices=prices, initial_capital=10_000.0, algos=[RunMonthly(), SelectThese(["SPY", "TLT"]), WeighSpecified({"SPY": 0.6, "TLT": 0.4}), Rebalance()], ) bal = bt.run() assert "SPY qty" in bal.columns assert "TLT qty" in bal.columns # SPY target = 10000 * 0.6 = 6000, qty = floor(6000/100) = 60 assert bal.loc[pd.Timestamp("2024-01-02"), "SPY qty"] == 60 # TLT target = 10000 * 0.4 = 4000, qty = floor(4000/50) = 80 assert bal.loc[pd.Timestamp("2024-01-02"), "TLT qty"] == 80 # --------------------------------------------------------------------------- # Helper: longer price history for algos needing lookback # --------------------------------------------------------------------------- def _daily_prices(symbols=("SPY", "TLT"), days=60, seed=42) -> pd.DataFrame: """Generate synthetic daily prices for testing.""" rng = np.random.RandomState(seed) idx = pd.bdate_range("2024-01-02", periods=days) data = {} for s in symbols: base = 100.0 if s == "SPY" else 50.0 rets = rng.normal(0.0005, 0.01, days) data[s] = base * np.cumprod(1 + rets) return pd.DataFrame(data, index=idx) def _weekly_prices() -> pd.DataFrame: """Prices spanning two full weeks (Mon-Fri), so RunWeekly triggers twice.""" idx = pd.to_datetime([ "2024-01-08", "2024-01-09", "2024-01-10", "2024-01-11", "2024-01-12", "2024-01-15", "2024-01-16", "2024-01-17", "2024-01-18", "2024-01-19", ]) return pd.DataFrame({"SPY": np.linspace(100, 110, 10)}, index=idx) def _ctx(prices=None, total_capital=1000.0, cash=1000.0, positions=None, selected_symbols=None, target_weights=None, price_history=None, date=None) -> PipelineContext: """Shortcut to build a PipelineContext.""" if prices is None: prices = pd.Series({"SPY": 100.0}) if date is None: date = pd.Timestamp("2024-01-02") return PipelineContext( date=date, prices=prices, total_capital=total_capital, cash=cash, positions=positions or {}, selected_symbols=selected_symbols or [], target_weights=target_weights or {}, price_history=price_history, ) # =========================================================================== # SCHEDULING ALGOS # =========================================================================== # --------------------------------------------------------------------------- # RunWeekly # --------------------------------------------------------------------------- def test_run_weekly_triggers_once_per_week(): algo = RunWeekly() prices = _weekly_prices() bt = AlgoPipelineBacktester( prices=prices, initial_capital=1000.0, algos=[algo, SelectThese(["SPY"]), WeighSpecified({"SPY": 1.0}), Rebalance()], ) bal = bt.run() logs = bt.logs_dataframe() rebalance_dates = logs[(logs["step"] == "Rebalance") & (logs["status"] == "continue")]["date"] # Should rebalance on the first day of each week assert len(rebalance_dates) == 2 def test_run_weekly_reset(): algo = RunWeekly() ctx1 = _ctx(date=pd.Timestamp("2024-01-08")) algo(ctx1) assert algo._last_week is not None algo.reset() assert algo._last_week is None def test_run_weekly_skips_same_week(): algo = RunWeekly() d1 = algo(_ctx(date=pd.Timestamp("2024-01-08"))) # Mon d2 = algo(_ctx(date=pd.Timestamp("2024-01-09"))) # Tue same week assert d1.status == "continue" assert d2.status == "skip_day" # --------------------------------------------------------------------------- # RunQuarterly # --------------------------------------------------------------------------- def test_run_quarterly_triggers_once_per_quarter(): idx = pd.to_datetime([ "2024-01-02", "2024-02-01", "2024-03-01", "2024-04-01", "2024-05-01", "2024-06-01", "2024-07-01", ]) prices = pd.DataFrame({"SPY": [100] * 7}, index=idx) bt = AlgoPipelineBacktester( prices=prices, initial_capital=1000.0, algos=[RunQuarterly(), SelectThese(["SPY"]), WeighSpecified({"SPY": 1.0}), Rebalance()], ) bt.run() logs = bt.logs_dataframe() rebalance_dates = logs[(logs["step"] == "Rebalance") & (logs["status"] == "continue")]["date"] # Q1 (Jan), Q2 (Apr), Q3 (Jul) = 3 rebalances assert len(rebalance_dates) == 3 def test_run_quarterly_skips_same_quarter(): algo = RunQuarterly() d1 = algo(_ctx(date=pd.Timestamp("2024-01-02"))) d2 = algo(_ctx(date=pd.Timestamp("2024-02-15"))) assert d1.status == "continue" assert d2.status == "skip_day" def test_run_quarterly_reset(): algo = RunQuarterly() algo(_ctx(date=pd.Timestamp("2024-01-02"))) algo.reset() assert algo._last_quarter is None # --------------------------------------------------------------------------- # RunYearly # --------------------------------------------------------------------------- def test_run_yearly_triggers_once_per_year(): idx = pd.to_datetime(["2024-01-02", "2024-06-01", "2024-12-31", "2025-01-02", "2025-06-01"]) prices = pd.DataFrame({"SPY": [100] * 5}, index=idx) bt = AlgoPipelineBacktester( prices=prices, initial_capital=1000.0, algos=[RunYearly(), SelectThese(["SPY"]), WeighSpecified({"SPY": 1.0}), Rebalance()], ) bt.run() logs = bt.logs_dataframe() rebalance_dates = logs[(logs["step"] == "Rebalance") & (logs["status"] == "continue")]["date"] # 2024 and 2025 = 2 rebalances assert len(rebalance_dates) == 2 def test_run_yearly_skips_same_year(): algo = RunYearly() d1 = algo(_ctx(date=pd.Timestamp("2024-01-02"))) d2 = algo(_ctx(date=pd.Timestamp("2024-06-15"))) assert d1.status == "continue" assert d2.status == "skip_day" def test_run_yearly_reset(): algo = RunYearly() algo(_ctx(date=pd.Timestamp("2024-01-02"))) algo.reset() assert algo._last_year is None # --------------------------------------------------------------------------- # RunDaily # --------------------------------------------------------------------------- def test_run_daily_always_continues(): algo = RunDaily() for d in ["2024-01-02", "2024-01-03", "2024-01-04"]: assert algo(_ctx(date=pd.Timestamp(d))).status == "continue" def test_run_daily_full_pipeline(): prices = _prices() bt = AlgoPipelineBacktester( prices=prices, initial_capital=1000.0, algos=[RunDaily(), SelectThese(["SPY"]), WeighSpecified({"SPY": 1.0}), Rebalance()], ) bt.run() logs = bt.logs_dataframe() rebalance_count = len(logs[(logs["step"] == "Rebalance") & (logs["status"] == "continue")]) assert rebalance_count == len(prices) # --------------------------------------------------------------------------- # RunOnce # --------------------------------------------------------------------------- def test_run_once_only_first_date(): algo = RunOnce() d1 = algo(_ctx(date=pd.Timestamp("2024-01-02"))) d2 = algo(_ctx(date=pd.Timestamp("2024-01-03"))) d3 = algo(_ctx(date=pd.Timestamp("2024-02-01"))) assert d1.status == "continue" assert d2.status == "skip_day" assert d3.status == "skip_day" def test_run_once_reset(): algo = RunOnce() algo(_ctx(date=pd.Timestamp("2024-01-02"))) assert algo._ran is True algo.reset() assert algo._ran is False d = algo(_ctx(date=pd.Timestamp("2024-02-01"))) assert d.status == "continue" def test_run_once_full_pipeline(): prices = _prices() bt = AlgoPipelineBacktester( prices=prices, initial_capital=1000.0, algos=[RunOnce(), SelectThese(["SPY"]), WeighSpecified({"SPY": 1.0}), Rebalance()], ) bt.run() logs = bt.logs_dataframe() rebalance_count = len(logs[(logs["step"] == "Rebalance") & (logs["status"] == "continue")]) assert rebalance_count == 1 # --------------------------------------------------------------------------- # RunOnDate # --------------------------------------------------------------------------- def test_run_on_date_specific_dates(): algo = RunOnDate(["2024-01-02", "2024-02-01"]) d1 = algo(_ctx(date=pd.Timestamp("2024-01-02"))) d2 = algo(_ctx(date=pd.Timestamp("2024-01-03"))) d3 = algo(_ctx(date=pd.Timestamp("2024-02-01"))) assert d1.status == "continue" assert d2.status == "skip_day" assert d3.status == "continue" def test_run_on_date_accepts_timestamps(): algo = RunOnDate([pd.Timestamp("2024-03-15")]) d = algo(_ctx(date=pd.Timestamp("2024-03-15"))) assert d.status == "continue" def test_run_on_date_full_pipeline(): prices = _prices() bt = AlgoPipelineBacktester( prices=prices, initial_capital=1000.0, algos=[ RunOnDate(["2024-02-01"]), SelectThese(["SPY"]), WeighSpecified({"SPY": 1.0}), Rebalance(), ], ) bt.run() logs = bt.logs_dataframe() rebalance_count = len(logs[(logs["step"] == "Rebalance") & (logs["status"] == "continue")]) assert rebalance_count == 1 # --------------------------------------------------------------------------- # RunAfterDate # --------------------------------------------------------------------------- def test_run_after_date_skips_before(): algo = RunAfterDate("2024-02-01") d1 = algo(_ctx(date=pd.Timestamp("2024-01-15"))) d2 = algo(_ctx(date=pd.Timestamp("2024-02-01"))) d3 = algo(_ctx(date=pd.Timestamp("2024-03-01"))) assert d1.status == "skip_day" assert d2.status == "continue" assert d3.status == "continue" def test_run_after_date_full_pipeline(): prices = _prices() bt = AlgoPipelineBacktester( prices=prices, initial_capital=1000.0, algos=[ RunAfterDate("2024-02-01"), SelectThese(["SPY"]), WeighSpecified({"SPY": 1.0}), Rebalance(), ], ) bal = bt.run() # First two dates (Jan 2, Jan 3) are skipped, Feb 1 and Feb 2 rebalance logs = bt.logs_dataframe() rebalance_count = len(logs[(logs["step"] == "Rebalance") & (logs["status"] == "continue")]) assert rebalance_count == 2 # --------------------------------------------------------------------------- # RunEveryNPeriods # --------------------------------------------------------------------------- def test_run_every_n_periods(): algo = RunEveryNPeriods(3) results = [] for i in range(9): d = algo(_ctx(date=pd.Timestamp("2024-01-02") + pd.Timedelta(days=i))) results.append(d.status) # Period 1: continue (first), 2: skip, 3: skip, 4: continue, 5: skip, 6: skip, 7: continue, ... assert results == [ "continue", "skip_day", "skip_day", "continue", "skip_day", "skip_day", "continue", "skip_day", "skip_day", ] def test_run_every_n_periods_reset(): algo = RunEveryNPeriods(5) for _ in range(3): algo(_ctx()) algo.reset() assert algo._count == 0 d = algo(_ctx()) assert d.status == "continue" # --------------------------------------------------------------------------- # Or combinator # --------------------------------------------------------------------------- def test_or_passes_if_any_child_passes(): algo = Or(RunMonthly(), RunWeekly()) # First call: both children haven't seen any date, so both pass → Or passes d1 = algo(_ctx(date=pd.Timestamp("2024-01-08"))) assert d1.status == "continue" def test_or_skips_if_all_children_skip(): monthly = RunMonthly() weekly = RunWeekly() algo = Or(monthly, weekly) # First call: RunMonthly passes → Or short-circuits, RunWeekly never called algo(_ctx(date=pd.Timestamp("2024-01-08"))) # Second call in same month: RunMonthly skips, RunWeekly sees first date → passes algo(_ctx(date=pd.Timestamp("2024-01-09"))) # Third call: same month AND same week → both skip → Or skips d3 = algo(_ctx(date=pd.Timestamp("2024-01-10"))) assert d3.status == "skip_day" def test_or_passes_when_one_passes(): monthly = RunMonthly() weekly = RunWeekly() algo = Or(monthly, weekly) algo(_ctx(date=pd.Timestamp("2024-01-08"))) # New week but same month → weekly passes → Or passes d = algo(_ctx(date=pd.Timestamp("2024-01-15"))) assert d.status == "continue" def test_or_reset(): monthly = RunMonthly() weekly = RunWeekly() algo = Or(monthly, weekly) algo(_ctx(date=pd.Timestamp("2024-01-08"))) algo.reset() assert monthly._last_month is None assert weekly._last_week is None # --------------------------------------------------------------------------- # Not combinator # --------------------------------------------------------------------------- def test_not_inverts_skip_to_continue(): monthly = RunMonthly() algo = Not(monthly) # First call: RunMonthly returns continue → Not inverts to skip_day d1 = algo(_ctx(date=pd.Timestamp("2024-01-02"))) assert d1.status == "skip_day" def test_not_inverts_continue_to_skip(): monthly = RunMonthly() algo = Not(monthly) algo(_ctx(date=pd.Timestamp("2024-01-02"))) # Same month → RunMonthly skips → Not inverts to continue d2 = algo(_ctx(date=pd.Timestamp("2024-01-03"))) assert d2.status == "continue" def test_not_reset(): monthly = RunMonthly() algo = Not(monthly) algo(_ctx(date=pd.Timestamp("2024-01-02"))) algo.reset() assert monthly._last_month is None # =========================================================================== # SELECTION ALGOS # =========================================================================== # --------------------------------------------------------------------------- # SelectAll # --------------------------------------------------------------------------- def test_select_all_picks_valid_prices(): ctx = _ctx(prices=pd.Series({"SPY": 100.0, "TLT": 50.0, "BAD": np.nan})) d = SelectAll()(ctx) assert d.status == "continue" assert set(ctx.selected_symbols) == {"SPY", "TLT"} def test_select_all_skips_zero_price(): ctx = _ctx(prices=pd.Series({"SPY": 0.0})) d = SelectAll()(ctx) assert d.status == "skip_day" def test_select_all_skips_all_nan(): ctx = _ctx(prices=pd.Series({"SPY": np.nan, "TLT": np.nan})) d = SelectAll()(ctx) assert d.status == "skip_day" # --------------------------------------------------------------------------- # SelectHasData # --------------------------------------------------------------------------- def test_select_has_data_filters_by_history_length(): prices = _daily_prices(days=10) ctx = _ctx( prices=prices.iloc[-1], date=prices.index[-1], selected_symbols=["SPY", "TLT"], price_history=prices, ) algo = SelectHasData(min_days=10) d = algo(ctx) assert d.status == "continue" assert set(ctx.selected_symbols) == {"SPY", "TLT"} def test_select_has_data_removes_short_history(): prices = _daily_prices(days=5) ctx = _ctx( prices=prices.iloc[-1], date=prices.index[-1], selected_symbols=["SPY", "TLT"], price_history=prices, ) algo = SelectHasData(min_days=10) d = algo(ctx) assert d.status == "skip_day" def test_select_has_data_no_history(): ctx = _ctx(selected_symbols=["SPY"]) algo = SelectHasData(min_days=1) d = algo(ctx) assert d.status == "skip_day" def test_select_has_data_uses_all_symbols_if_none_selected(): prices = _daily_prices(days=5) ctx = _ctx( prices=prices.iloc[-1], date=prices.index[-1], selected_symbols=[], # empty price_history=prices, ) algo = SelectHasData(min_days=3) d = algo(ctx) assert d.status == "continue" assert set(ctx.selected_symbols) == {"SPY", "TLT"} # --------------------------------------------------------------------------- # SelectMomentum # --------------------------------------------------------------------------- def test_select_momentum_picks_top_n(): # SPY goes up, TLT goes down idx = pd.bdate_range("2024-01-02", periods=20) prices = pd.DataFrame({ "SPY": np.linspace(100, 120, 20), # +20% "TLT": np.linspace(100, 90, 20), # -10% "GLD": np.linspace(100, 105, 20), # +5% }, index=idx) ctx = _ctx( prices=prices.iloc[-1], date=prices.index[-1], selected_symbols=["SPY", "TLT", "GLD"], price_history=prices, ) algo = SelectMomentum(n=2, lookback=20) d = algo(ctx) assert d.status == "continue" assert ctx.selected_symbols == ["SPY", "GLD"] def test_select_momentum_ascending(): idx = pd.bdate_range("2024-01-02", periods=20) prices = pd.DataFrame({ "SPY": np.linspace(100, 120, 20), "TLT": np.linspace(100, 90, 20), }, index=idx) ctx = _ctx( prices=prices.iloc[-1], date=prices.index[-1], selected_symbols=["SPY", "TLT"], price_history=prices, ) algo = SelectMomentum(n=1, lookback=20, sort_descending=False) algo(ctx) assert ctx.selected_symbols == ["TLT"] def test_select_momentum_no_history(): ctx = _ctx(selected_symbols=["SPY"]) d = SelectMomentum(n=1)(ctx) assert d.status == "skip_day" # --------------------------------------------------------------------------- # SelectN # --------------------------------------------------------------------------- def test_select_n_truncates(): ctx = _ctx(selected_symbols=["SPY", "TLT", "GLD", "QQQ"]) d = SelectN(2)(ctx) assert d.status == "continue" assert ctx.selected_symbols == ["SPY", "TLT"] def test_select_n_empty(): ctx = _ctx(selected_symbols=[]) d = SelectN(5)(ctx) assert d.status == "skip_day" def test_select_n_fewer_than_n(): ctx = _ctx(selected_symbols=["SPY"]) d = SelectN(5)(ctx) assert d.status == "continue" assert ctx.selected_symbols == ["SPY"] # --------------------------------------------------------------------------- # SelectWhere # --------------------------------------------------------------------------- def test_select_where_custom_filter(): ctx = _ctx( prices=pd.Series({"SPY": 100.0, "TLT": 50.0, "GLD": 200.0}), selected_symbols=["SPY", "TLT", "GLD"], ) # Only keep symbols with price > 80 algo = SelectWhere(lambda s, c: float(c.prices[s]) > 80) d = algo(ctx) assert d.status == "continue" assert set(ctx.selected_symbols) == {"SPY", "GLD"} def test_select_where_all_filtered(): ctx = _ctx( prices=pd.Series({"SPY": 100.0}), selected_symbols=["SPY"], ) algo = SelectWhere(lambda s, c: False) d = algo(ctx) assert d.status == "skip_day" def test_select_where_falls_back_to_prices_index(): ctx = _ctx( prices=pd.Series({"SPY": 100.0, "TLT": 50.0}), selected_symbols=[], # empty ) algo = SelectWhere(lambda s, c: s == "TLT") d = algo(ctx) assert d.status == "continue" assert ctx.selected_symbols == ["TLT"] # =========================================================================== # WEIGHTING ALGOS # =========================================================================== # --------------------------------------------------------------------------- # WeighEqually # --------------------------------------------------------------------------- def test_weigh_equally_two_symbols(): ctx = _ctx(selected_symbols=["SPY", "TLT"]) d = WeighEqually()(ctx) assert d.status == "continue" assert abs(ctx.target_weights["SPY"] - 0.5) < 1e-12 assert abs(ctx.target_weights["TLT"] - 0.5) < 1e-12 def test_weigh_equally_single_symbol(): ctx = _ctx(selected_symbols=["SPY"]) WeighEqually()(ctx) assert abs(ctx.target_weights["SPY"] - 1.0) < 1e-12 def test_weigh_equally_empty(): ctx = _ctx(selected_symbols=[]) d = WeighEqually()(ctx) assert d.status == "skip_day" def test_weigh_equally_three_symbols(): ctx = _ctx(selected_symbols=["SPY", "TLT", "GLD"]) WeighEqually()(ctx) for s in ["SPY", "TLT", "GLD"]: assert abs(ctx.target_weights[s] - 1.0 / 3) < 1e-12 # --------------------------------------------------------------------------- # WeighInvVol # --------------------------------------------------------------------------- def test_weigh_inv_vol_basic(): prices = _daily_prices(days=30) ctx = _ctx( prices=prices.iloc[-1], date=prices.index[-1], selected_symbols=["SPY", "TLT"], price_history=prices, ) d = WeighInvVol(lookback=30)(ctx) assert d.status == "continue" assert abs(sum(ctx.target_weights.values()) - 1.0) < 1e-10 assert all(w > 0 for w in ctx.target_weights.values()) def test_weigh_inv_vol_lower_vol_gets_higher_weight(): # Create data where TLT has much lower vol than SPY idx = pd.bdate_range("2024-01-02", periods=30) rng = np.random.RandomState(99) spy = 100 * np.cumprod(1 + rng.normal(0, 0.03, 30)) # high vol tlt = 50 * np.cumprod(1 + rng.normal(0, 0.005, 30)) # low vol prices = pd.DataFrame({"SPY": spy, "TLT": tlt}, index=idx) ctx = _ctx( prices=prices.iloc[-1], date=prices.index[-1], selected_symbols=["SPY", "TLT"], price_history=prices, ) WeighInvVol(lookback=30)(ctx) # Lower vol (TLT) should get higher weight assert ctx.target_weights["TLT"] > ctx.target_weights["SPY"] def test_weigh_inv_vol_no_history(): ctx = _ctx(selected_symbols=["SPY"]) d = WeighInvVol()(ctx) assert d.status == "skip_day" def test_weigh_inv_vol_no_selected(): ctx = _ctx(selected_symbols=[]) d = WeighInvVol()(ctx) assert d.status == "skip_day" # --------------------------------------------------------------------------- # WeighMeanVar # --------------------------------------------------------------------------- def test_weigh_mean_var_basic(): prices = _daily_prices(days=30) ctx = _ctx( prices=prices.iloc[-1], date=prices.index[-1], selected_symbols=["SPY", "TLT"], price_history=prices, ) d = WeighMeanVar(lookback=30)(ctx) assert d.status == "continue" assert abs(sum(ctx.target_weights.values()) - 1.0) < 1e-10 assert all(w >= 0 for w in ctx.target_weights.values()) def test_weigh_mean_var_single_asset(): prices = _daily_prices(symbols=("SPY",), days=30) ctx = _ctx( prices=prices.iloc[-1], date=prices.index[-1], selected_symbols=["SPY"], price_history=prices, ) d = WeighMeanVar(lookback=30)(ctx) assert d.status == "continue" assert abs(ctx.target_weights["SPY"] - 1.0) < 1e-10 def test_weigh_mean_var_no_history(): ctx = _ctx(selected_symbols=["SPY"]) d = WeighMeanVar()(ctx) assert d.status == "skip_day" def test_weigh_mean_var_insufficient_data(): idx = pd.to_datetime(["2024-01-02", "2024-01-03"]) prices = pd.DataFrame({"SPY": [100.0, 101.0]}, index=idx) ctx = _ctx( prices=prices.iloc[-1], date=prices.index[-1], selected_symbols=["SPY"], price_history=prices, ) d = WeighMeanVar(lookback=252)(ctx) assert d.status == "skip_day" # --------------------------------------------------------------------------- # WeighERC # --------------------------------------------------------------------------- def test_weigh_erc_basic(): prices = _daily_prices(days=30) ctx = _ctx( prices=prices.iloc[-1], date=prices.index[-1], selected_symbols=["SPY", "TLT"], price_history=prices, ) d = WeighERC(lookback=30)(ctx) assert d.status == "continue" assert abs(sum(ctx.target_weights.values()) - 1.0) < 1e-10 assert all(w > 0 for w in ctx.target_weights.values()) def test_weigh_erc_no_history(): ctx = _ctx(selected_symbols=["SPY"]) d = WeighERC()(ctx) assert d.status == "skip_day" def test_weigh_erc_single_asset(): prices = _daily_prices(symbols=("SPY",), days=30) ctx = _ctx( prices=prices.iloc[-1], date=prices.index[-1], selected_symbols=["SPY"], price_history=prices, ) d = WeighERC(lookback=30)(ctx) assert d.status == "continue" assert abs(ctx.target_weights["SPY"] - 1.0) < 1e-10 def test_weigh_erc_weights_sum_to_one(): prices = _daily_prices(symbols=("SPY", "TLT", "GLD"), days=60, seed=123) ctx = _ctx( prices=prices.iloc[-1], date=prices.index[-1], selected_symbols=["SPY", "TLT", "GLD"], price_history=prices, ) WeighERC(lookback=60)(ctx) assert abs(sum(ctx.target_weights.values()) - 1.0) < 1e-8 # --------------------------------------------------------------------------- # TargetVol # --------------------------------------------------------------------------- def test_target_vol_scales_weights(): prices = _daily_prices(days=60) ctx = _ctx( prices=prices.iloc[-1], date=prices.index[-1], selected_symbols=["SPY", "TLT"], target_weights={"SPY": 0.6, "TLT": 0.4}, price_history=prices, ) d = TargetVol(target=0.05, lookback=60)(ctx) assert d.status == "continue" # Weights should be scaled down (realized vol likely > 5%) total_w = sum(ctx.target_weights.values()) assert total_w <= 1.0 + 1e-10 def test_target_vol_no_weights(): ctx = _ctx(target_weights={}) d = TargetVol(target=0.10)(ctx) assert d.status == "skip_day" def test_target_vol_no_history(): ctx = _ctx(target_weights={"SPY": 1.0}) d = TargetVol(target=0.10)(ctx) assert d.status == "skip_day" def test_target_vol_never_levers(): """TargetVol should never scale weights above 1.0.""" # Create very low vol data idx = pd.bdate_range("2024-01-02", periods=60) prices = pd.DataFrame({ "SPY": np.linspace(100, 100.5, 60), # nearly flat → near-zero vol }, index=idx) ctx = _ctx( prices=prices.iloc[-1], date=prices.index[-1], target_weights={"SPY": 1.0}, price_history=prices, ) TargetVol(target=0.50, lookback=60)(ctx) # Scale should be capped at 1.0 assert ctx.target_weights["SPY"] <= 1.0 + 1e-10 # =========================================================================== # WEIGHT LIMITS # =========================================================================== def test_limit_weights_caps(): ctx = _ctx(target_weights={"SPY": 0.80, "TLT": 0.20}) LimitWeights(limit=0.50)(ctx) assert ctx.target_weights["SPY"] <= 0.50 + 1e-10 # Total should still be close to 1.0 assert abs(sum(ctx.target_weights.values()) - 1.0) < 1e-8 def test_limit_weights_no_change_under_limit(): ctx = _ctx(target_weights={"SPY": 0.40, "TLT": 0.60}) LimitWeights(limit=0.70)(ctx) assert abs(ctx.target_weights["SPY"] - 0.40) < 1e-10 assert abs(ctx.target_weights["TLT"] - 0.60) < 1e-10 def test_limit_weights_empty(): ctx = _ctx(target_weights={}) d = LimitWeights(limit=0.25)(ctx) assert d.status == "continue" def test_limit_weights_redistributes(): ctx = _ctx(target_weights={"A": 0.70, "B": 0.20, "C": 0.10}) LimitWeights(limit=0.40)(ctx) assert ctx.target_weights["A"] <= 0.40 + 1e-10 # B and C should get the excess redistributed assert ctx.target_weights["B"] > 0.20 assert ctx.target_weights["C"] > 0.10 # =========================================================================== # CAPITAL FLOWS # =========================================================================== def test_capital_flow_dict(): flow = CapitalFlow({"2024-02-01": 500.0}) ctx = _ctx(date=pd.Timestamp("2024-02-01"), cash=1000.0, total_capital=1000.0) flow(ctx) assert ctx.cash == 1500.0 assert ctx.total_capital == 1500.0 def test_capital_flow_no_flow_date(): flow = CapitalFlow({"2024-02-01": 500.0}) ctx = _ctx(date=pd.Timestamp("2024-01-15"), cash=1000.0, total_capital=1000.0) flow(ctx) assert ctx.cash == 1000.0 def test_capital_flow_withdrawal(): flow = CapitalFlow({"2024-02-01": -200.0}) ctx = _ctx(date=pd.Timestamp("2024-02-01"), cash=1000.0, total_capital=1000.0) flow(ctx) assert ctx.cash == 800.0 assert ctx.total_capital == 800.0 def test_capital_flow_callable(): # Add 100 on every Monday def monday_flow(d: pd.Timestamp) -> float: return 100.0 if d.weekday() == 0 else 0.0 flow = CapitalFlow(monday_flow) ctx_mon = _ctx(date=pd.Timestamp("2024-01-08"), cash=1000.0, total_capital=1000.0) flow(ctx_mon) assert ctx_mon.cash == 1100.0 ctx_tue = _ctx(date=pd.Timestamp("2024-01-09"), cash=1000.0, total_capital=1000.0) flow(ctx_tue) assert ctx_tue.cash == 1000.0 def test_capital_flow_in_pipeline(): idx = pd.to_datetime(["2024-01-02", "2024-02-01", "2024-03-01"]) prices = pd.DataFrame({"SPY": [100.0, 100.0, 100.0]}, index=idx) bt = AlgoPipelineBacktester( prices=prices, initial_capital=1000.0, algos=[ RunMonthly(), CapitalFlow({"2024-02-01": 500.0}), SelectThese(["SPY"]), WeighSpecified({"SPY": 1.0}), Rebalance(), ], ) bal = bt.run() # On Feb 1, capital should include the 500 addition assert bal.loc[pd.Timestamp("2024-02-01"), "total capital"] > 1000.0 # =========================================================================== # REBALANCE OVER TIME # =========================================================================== def test_rebalance_over_time_gradual(): idx = pd.bdate_range("2024-01-02", periods=10) prices = pd.DataFrame({"SPY": [100.0] * 10}, index=idx) bt = AlgoPipelineBacktester( prices=prices, initial_capital=1000.0, algos=[ RunDaily(), SelectThese(["SPY"]), WeighSpecified({"SPY": 1.0}), RebalanceOverTime(n=5), ], ) bal = bt.run() # With n=5, the first 5 days should gradually increase position qtys = [bal.iloc[i].get("SPY qty", 0) for i in range(5)] # Each day should get closer to full position assert qtys[-1] >= qtys[0] def test_rebalance_over_time_reset(): algo = RebalanceOverTime(n=3) algo._target = {"SPY": 1.0} algo._remaining = 2 algo.reset() assert algo._target == {} assert algo._remaining == 0 def test_rebalance_over_time_no_target(): algo = RebalanceOverTime(n=3) ctx = _ctx() d = algo(ctx) assert d.status == "skip_day" # =========================================================================== # INTEGRATION: Full pipeline with new algos # =========================================================================== def test_pipeline_select_all_weigh_equally(): idx = pd.to_datetime(["2024-01-02", "2024-02-01"]) prices = pd.DataFrame({"SPY": [100.0, 100.0], "TLT": [50.0, 50.0]}, index=idx) bt = AlgoPipelineBacktester( prices=prices, initial_capital=10_000.0, algos=[RunMonthly(), SelectAll(), WeighEqually(), Rebalance()], ) bal = bt.run() # 50% each: SPY = floor(5000/100) = 50, TLT = floor(5000/50) = 100 assert bal.loc[pd.Timestamp("2024-01-02"), "SPY qty"] == 50 assert bal.loc[pd.Timestamp("2024-01-02"), "TLT qty"] == 100 def test_pipeline_momentum_selection(): idx = pd.bdate_range("2024-01-02", periods=30) prices = pd.DataFrame({ "SPY": np.linspace(100, 130, 30), # +30% "TLT": np.linspace(100, 95, 30), # -5% "GLD": np.linspace(100, 110, 30), # +10% }, index=idx) bt = AlgoPipelineBacktester( prices=prices, initial_capital=10_000.0, algos=[ RunMonthly(), SelectAll(), SelectMomentum(n=2, lookback=30), WeighEqually(), Rebalance(), ], ) bal = bt.run() # Should pick SPY and GLD (top 2 momentum), not TLT assert "SPY qty" in bal.columns assert "GLD qty" in bal.columns # TLT should not have been bought (or have 0 qty) if "TLT qty" in bal.columns: assert bal["TLT qty"].fillna(0).sum() == 0 def test_pipeline_limit_weights_integration(): idx = pd.to_datetime(["2024-01-02"]) prices = pd.DataFrame({"SPY": [100.0], "TLT": [50.0], "GLD": [200.0]}, index=idx) bt = AlgoPipelineBacktester( prices=prices, initial_capital=10_000.0, algos=[ SelectThese(["SPY", "TLT", "GLD"]), WeighSpecified({"SPY": 0.8, "TLT": 0.1, "GLD": 0.1}), LimitWeights(limit=0.40), Rebalance(), ], ) bal = bt.run() spy_val = bal.iloc[0]["SPY qty"] * 100.0 total = bal.iloc[0]["total capital"] # SPY weight should be ≤ 40% assert spy_val / total <= 0.45 # small tolerance for floor rounding def test_pipeline_run_on_date_with_capital_flow(): idx = pd.to_datetime(["2024-01-02", "2024-01-15", "2024-02-01", "2024-02-15"]) prices = pd.DataFrame({"SPY": [100.0] * 4}, index=idx) bt = AlgoPipelineBacktester( prices=prices, initial_capital=1000.0, algos=[ RunOnDate(["2024-01-02", "2024-02-01"]), CapitalFlow({"2024-02-01": 500.0}), SelectThese(["SPY"]), WeighSpecified({"SPY": 1.0}), Rebalance(), ], ) bal = bt.run() # Jan 2: 1000/100 = 10 shares assert bal.loc[pd.Timestamp("2024-01-02"), "SPY qty"] == 10 # Feb 1: 1000 + 500 = 1500, 1500/100 = 15 shares assert bal.loc[pd.Timestamp("2024-02-01"), "SPY qty"] == 15 def test_pipeline_inv_vol_with_limit_weights(): prices = _daily_prices(symbols=("SPY", "TLT", "GLD"), days=60, seed=77) bt = AlgoPipelineBacktester( prices=prices, initial_capital=100_000.0, algos=[ RunMonthly(), SelectAll(), WeighInvVol(lookback=60), LimitWeights(limit=0.50), Rebalance(), ], ) bal = bt.run() assert not bal.empty # All weights should respect the 50% limit (check via position values) row = bal.iloc[-1] total = row["total capital"] for sym in ["SPY", "TLT", "GLD"]: qty_col = f"{sym} qty" if qty_col in row.index and row[qty_col] > 0: price = prices[sym].iloc[-1] weight = row[qty_col] * price / total assert weight <= 0.55 # tolerance for floor rounding # =========================================================================== # NEW ALGOS (round 2) # =========================================================================== # --------------------------------------------------------------------------- # RunAfterDays # --------------------------------------------------------------------------- def test_run_after_days_skips_warmup(): algo = RunAfterDays(3) results = [] for i in range(6): d = algo(_ctx(date=pd.Timestamp("2024-01-02") + pd.Timedelta(days=i))) results.append(d.status) assert results == ["skip_day", "skip_day", "skip_day", "continue", "continue", "continue"] def test_run_after_days_reset(): algo = RunAfterDays(2) algo(_ctx()) algo(_ctx()) algo.reset() assert algo._count == 0 d = algo(_ctx()) assert d.status == "skip_day" # back to warmup def test_run_after_days_in_pipeline(): idx = pd.bdate_range("2024-01-02", periods=10) prices = pd.DataFrame({"SPY": [100.0] * 10}, index=idx) bt = AlgoPipelineBacktester( prices=prices, initial_capital=1000.0, algos=[RunAfterDays(5), SelectThese(["SPY"]), WeighSpecified({"SPY": 1.0}), Rebalance()], ) bal = bt.run() # First 5 days skipped, rebalance on days 6-10 logs = bt.logs_dataframe() rebalance_count = len(logs[(logs["step"] == "Rebalance") & (logs["status"] == "continue")]) assert rebalance_count == 5 # --------------------------------------------------------------------------- # RunIfOutOfBounds # --------------------------------------------------------------------------- def test_run_if_out_of_bounds_skips_when_in_bounds(): algo = RunIfOutOfBounds(tolerance=0.10) algo.update_target({"SPY": 0.60, "TLT": 0.40}) # Positions match target closely ctx = _ctx( prices=pd.Series({"SPY": 100.0, "TLT": 50.0}), total_capital=10000.0, positions={"SPY": 60.0, "TLT": 80.0}, # SPY=60%, TLT=40% ) d = algo(ctx) assert d.status == "skip_day" def test_run_if_out_of_bounds_triggers_when_drifted(): algo = RunIfOutOfBounds(tolerance=0.05) algo.update_target({"SPY": 0.50, "TLT": 0.50}) # SPY drifted to 70%, TLT to 30% ctx = _ctx( prices=pd.Series({"SPY": 100.0, "TLT": 50.0}), total_capital=10000.0, positions={"SPY": 70.0, "TLT": 60.0}, # SPY=70%, TLT=30% ) d = algo(ctx) assert d.status == "continue" def test_run_if_out_of_bounds_no_prior_target(): algo = RunIfOutOfBounds(tolerance=0.05) d = algo(_ctx()) assert d.status == "skip_day" def test_run_if_out_of_bounds_reset(): algo = RunIfOutOfBounds(tolerance=0.05) algo.update_target({"SPY": 1.0}) algo.reset() assert algo._last_target == {} # --------------------------------------------------------------------------- # LimitDeltas # --------------------------------------------------------------------------- def test_limit_deltas_clips_large_change(): ctx = _ctx( prices=pd.Series({"SPY": 100.0, "TLT": 50.0}), total_capital=10000.0, positions={"SPY": 50.0, "TLT": 100.0}, # SPY=50%, TLT=50% target_weights={"SPY": 0.80, "TLT": 0.20}, # want to move 30% ) LimitDeltas(limit=0.10)(ctx) # SPY delta capped: 0.50 + 0.10 = 0.60 max assert ctx.target_weights["SPY"] <= 0.65 # after renorm def test_limit_deltas_no_change_needed(): ctx = _ctx( prices=pd.Series({"SPY": 100.0}), total_capital=10000.0, positions={"SPY": 98.0}, # ~98% target_weights={"SPY": 1.0}, ) LimitDeltas(limit=0.10)(ctx) # Small delta, should pass through mostly unchanged assert ctx.target_weights["SPY"] > 0.9 def test_limit_deltas_empty(): ctx = _ctx(target_weights={}) d = LimitDeltas(limit=0.10)(ctx) assert d.status == "continue" # --------------------------------------------------------------------------- # ScaleWeights # --------------------------------------------------------------------------- def test_scale_weights_half(): ctx = _ctx(target_weights={"SPY": 0.60, "TLT": 0.40}) ScaleWeights(scale=0.5)(ctx) assert abs(ctx.target_weights["SPY"] - 0.30) < 1e-10 assert abs(ctx.target_weights["TLT"] - 0.20) < 1e-10 def test_scale_weights_double(): ctx = _ctx(target_weights={"SPY": 0.30, "TLT": 0.20}) ScaleWeights(scale=2.0)(ctx) assert abs(ctx.target_weights["SPY"] - 0.60) < 1e-10 assert abs(ctx.target_weights["TLT"] - 0.40) < 1e-10 def test_scale_weights_empty(): ctx = _ctx(target_weights={}) d = ScaleWeights(scale=0.5)(ctx) assert d.status == "continue" # --------------------------------------------------------------------------- # SelectRandomly # --------------------------------------------------------------------------- def test_select_randomly_picks_n(): ctx = _ctx( prices=pd.Series({"SPY": 100.0, "TLT": 50.0, "GLD": 200.0, "QQQ": 300.0}), selected_symbols=["SPY", "TLT", "GLD", "QQQ"], ) algo = SelectRandomly(n=2, seed=42) d = algo(ctx) assert d.status == "continue" assert len(ctx.selected_symbols) == 2 assert all(s in ["SPY", "TLT", "GLD", "QQQ"] for s in ctx.selected_symbols) def test_select_randomly_deterministic(): ctx1 = _ctx( prices=pd.Series({"SPY": 100.0, "TLT": 50.0, "GLD": 200.0}), selected_symbols=["SPY", "TLT", "GLD"], ) ctx2 = _ctx( prices=pd.Series({"SPY": 100.0, "TLT": 50.0, "GLD": 200.0}), selected_symbols=["SPY", "TLT", "GLD"], ) algo1 = SelectRandomly(n=2, seed=42) algo2 = SelectRandomly(n=2, seed=42) algo1(ctx1) algo2(ctx2) assert ctx1.selected_symbols == ctx2.selected_symbols def test_select_randomly_n_exceeds_candidates(): ctx = _ctx( prices=pd.Series({"SPY": 100.0}), selected_symbols=["SPY"], ) algo = SelectRandomly(n=5, seed=1) d = algo(ctx) assert d.status == "continue" assert ctx.selected_symbols == ["SPY"] def test_select_randomly_no_candidates(): ctx = _ctx( prices=pd.Series({"SPY": np.nan}), selected_symbols=[], ) algo = SelectRandomly(n=2, seed=1) d = algo(ctx) assert d.status == "skip_day" # --------------------------------------------------------------------------- # SelectActive # --------------------------------------------------------------------------- def test_select_active_filters_dead(): ctx = _ctx( prices=pd.Series({"SPY": 100.0, "TLT": 0.0, "GLD": np.nan}), selected_symbols=["SPY", "TLT", "GLD"], ) d = SelectActive()(ctx) assert d.status == "continue" assert ctx.selected_symbols == ["SPY"] def test_select_active_all_dead(): ctx = _ctx( prices=pd.Series({"SPY": 0.0, "TLT": np.nan}), selected_symbols=["SPY", "TLT"], ) d = SelectActive()(ctx) assert d.status == "skip_day" # --------------------------------------------------------------------------- # WeighRandomly # --------------------------------------------------------------------------- def test_weigh_randomly_sums_to_one(): ctx = _ctx(selected_symbols=["SPY", "TLT", "GLD"]) WeighRandomly(seed=42)(ctx) assert abs(sum(ctx.target_weights.values()) - 1.0) < 1e-10 assert all(w > 0 for w in ctx.target_weights.values()) def test_weigh_randomly_deterministic(): ctx1 = _ctx(selected_symbols=["SPY", "TLT"]) ctx2 = _ctx(selected_symbols=["SPY", "TLT"]) WeighRandomly(seed=99)(ctx1) WeighRandomly(seed=99)(ctx2) assert abs(ctx1.target_weights["SPY"] - ctx2.target_weights["SPY"]) < 1e-10 def test_weigh_randomly_empty(): ctx = _ctx(selected_symbols=[]) d = WeighRandomly(seed=1)(ctx) assert d.status == "skip_day" # --------------------------------------------------------------------------- # WeighTarget # --------------------------------------------------------------------------- def test_weigh_target_basic(): weights_df = pd.DataFrame( {"SPY": [0.60, 0.70], "TLT": [0.40, 0.30]}, index=pd.to_datetime(["2024-01-01", "2024-02-01"]), ) algo = WeighTarget(weights_df) ctx = _ctx( date=pd.Timestamp("2024-01-15"), selected_symbols=["SPY", "TLT"], ) d = algo(ctx) assert d.status == "continue" # Should pick Jan 1 row (most recent before Jan 15) assert abs(ctx.target_weights["SPY"] - 0.60) < 1e-10 assert abs(ctx.target_weights["TLT"] - 0.40) < 1e-10 def test_weigh_target_uses_latest_row(): weights_df = pd.DataFrame( {"SPY": [0.50, 0.80]}, index=pd.to_datetime(["2024-01-01", "2024-02-01"]), ) algo = WeighTarget(weights_df) ctx = _ctx( date=pd.Timestamp("2024-03-01"), selected_symbols=["SPY"], ) algo(ctx) assert abs(ctx.target_weights["SPY"] - 1.0) < 1e-10 # normalized from 0.80 def test_weigh_target_no_data_before_date(): weights_df = pd.DataFrame( {"SPY": [1.0]}, index=pd.to_datetime(["2024-06-01"]), ) algo = WeighTarget(weights_df) ctx = _ctx( date=pd.Timestamp("2024-01-01"), selected_symbols=["SPY"], ) d = algo(ctx) assert d.status == "skip_day" def test_weigh_target_empty_selected(): weights_df = pd.DataFrame( {"SPY": [1.0]}, index=pd.to_datetime(["2024-01-01"]), ) ctx = _ctx(date=pd.Timestamp("2024-01-15"), selected_symbols=[]) d = WeighTarget(weights_df)(ctx) assert d.status == "skip_day" # --------------------------------------------------------------------------- # CloseDead # --------------------------------------------------------------------------- def test_close_dead_removes_zero_price(): ctx = _ctx( prices=pd.Series({"SPY": 100.0, "TLT": 0.0}), positions={"SPY": 10.0, "TLT": 20.0}, ) CloseDead()(ctx) assert "SPY" in ctx.positions assert "TLT" not in ctx.positions def test_close_dead_removes_nan_price(): ctx = _ctx( prices=pd.Series({"SPY": 100.0, "TLT": np.nan}), positions={"SPY": 10.0, "TLT": 20.0}, ) CloseDead()(ctx) assert "TLT" not in ctx.positions def test_close_dead_no_dead(): ctx = _ctx( prices=pd.Series({"SPY": 100.0, "TLT": 50.0}), positions={"SPY": 10.0, "TLT": 20.0}, ) d = CloseDead()(ctx) assert d.status == "continue" assert len(ctx.positions) == 2 def test_close_dead_missing_price(): ctx = _ctx( prices=pd.Series({"SPY": 100.0}), positions={"SPY": 10.0, "XYZ": 5.0}, # XYZ not in prices ) CloseDead()(ctx) assert "XYZ" not in ctx.positions # --------------------------------------------------------------------------- # ClosePositionsAfterDates # --------------------------------------------------------------------------- def test_close_positions_after_dates(): algo = ClosePositionsAfterDates({"TLT": "2024-02-01"}) ctx = _ctx( date=pd.Timestamp("2024-02-15"), positions={"SPY": 10.0, "TLT": 20.0}, ) d = algo(ctx) assert "TLT" not in ctx.positions assert "SPY" in ctx.positions assert "closed after date" in d.message def test_close_positions_before_date(): algo = ClosePositionsAfterDates({"TLT": "2024-06-01"}) ctx = _ctx( date=pd.Timestamp("2024-02-15"), positions={"SPY": 10.0, "TLT": 20.0}, ) algo(ctx) assert "TLT" in ctx.positions # not yet def test_close_positions_on_exact_date(): algo = ClosePositionsAfterDates({"SPY": "2024-02-01"}) ctx = _ctx( date=pd.Timestamp("2024-02-01"), positions={"SPY": 10.0}, ) algo(ctx) assert "SPY" not in ctx.positions # --------------------------------------------------------------------------- # Require # --------------------------------------------------------------------------- def test_require_passes_when_inner_passes(): inner = RunDaily() # always passes algo = Require(inner) d = algo(_ctx()) assert d.status == "continue" def test_require_blocks_when_inner_skips(): inner = RunOnce() inner._ran = True # already ran → will skip algo = Require(inner) d = algo(_ctx()) assert d.status == "skip_day" def test_require_reset(): inner = RunOnce() inner._ran = True algo = Require(inner) algo.reset() assert inner._ran is False # --------------------------------------------------------------------------- # benchmark_random # --------------------------------------------------------------------------- def test_benchmark_random_basic(): prices = _daily_prices(symbols=("SPY", "TLT"), days=30) strategy_algos = [ RunMonthly(), SelectThese(["SPY"]), WeighSpecified({"SPY": 1.0}), Rebalance(), ] result = benchmark_random( prices=prices, strategy_algos=strategy_algos, n_random=10, initial_capital=10_000.0, seed=42, ) assert isinstance(result, RandomBenchmarkResult) assert len(result.random_returns) == 10 assert 0 <= result.percentile <= 100 assert result.mean_random != 0.0 or all(r == 0 for r in result.random_returns) def test_benchmark_random_deterministic(): prices = _daily_prices(days=30) algos = [RunMonthly(), SelectAll(), WeighEqually(), Rebalance()] r1 = benchmark_random(prices, algos, n_random=5, seed=42) r2 = benchmark_random(prices, algos, n_random=5, seed=42) assert r1.random_returns == r2.random_returns assert r1.percentile == r2.percentile def test_benchmark_random_result_properties(): result = RandomBenchmarkResult( strategy_return=0.10, random_returns=[0.05, 0.08, 0.12, 0.03], percentile=50.0, ) assert abs(result.mean_random - np.mean([0.05, 0.08, 0.12, 0.03])) < 1e-10 assert abs(result.std_random - np.std([0.05, 0.08, 0.12, 0.03])) < 1e-10 # =========================================================================== # INTEGRATION: Round 2 algos in full pipelines # =========================================================================== def test_pipeline_or_run_if_out_of_bounds(): """Or(RunQuarterly(), RunIfOutOfBounds(0.05)) pattern.""" idx = pd.bdate_range("2024-01-02", periods=5) prices = pd.DataFrame({"SPY": [100.0] * 5}, index=idx) oob = RunIfOutOfBounds(tolerance=0.05) bt = AlgoPipelineBacktester( prices=prices, initial_capital=1000.0, algos=[ Or(RunQuarterly(), oob), SelectThese(["SPY"]), WeighSpecified({"SPY": 1.0}), Rebalance(), ], ) bal = bt.run() assert not bal.empty def test_pipeline_close_dead_then_rebalance(): idx = pd.to_datetime(["2024-01-02", "2024-02-01"]) prices = pd.DataFrame({"SPY": [100.0, 100.0], "TLT": [50.0, 0.0]}, index=idx) bt = AlgoPipelineBacktester( prices=prices, initial_capital=10_000.0, algos=[ RunMonthly(), CloseDead(), SelectActive(), WeighEqually(), Rebalance(), ], ) bal = bt.run() # On Feb 1, TLT is dead → should not hold any TLT if "TLT qty" in bal.columns: assert bal.loc[pd.Timestamp("2024-02-01"), "TLT qty"] == 0 or \ pd.isna(bal.loc[pd.Timestamp("2024-02-01"), "TLT qty"]) def test_pipeline_scale_weights_deleverage(): idx = pd.to_datetime(["2024-01-02"]) prices = pd.DataFrame({"SPY": [100.0]}, index=idx) bt = AlgoPipelineBacktester( prices=prices, initial_capital=10_000.0, algos=[ SelectThese(["SPY"]), WeighSpecified({"SPY": 1.0}), ScaleWeights(scale=0.5), Rebalance(), ], ) bal = bt.run() # 50% of 10000 = 5000, floor(5000/100) = 50 shares assert bal.iloc[0]["SPY qty"] == 50 def test_pipeline_select_randomly_weigh_randomly(): prices = _daily_prices(symbols=("SPY", "TLT", "GLD"), days=30) bt = AlgoPipelineBacktester( prices=prices, initial_capital=10_000.0, algos=[ RunMonthly(), SelectRandomly(n=2, seed=42), WeighRandomly(seed=42), Rebalance(), ], ) bal = bt.run() assert not bal.empty def test_pipeline_weigh_target_from_df(): weights_df = pd.DataFrame( {"SPY": [0.60, 0.40], "TLT": [0.40, 0.60]}, index=pd.to_datetime(["2024-01-01", "2024-02-01"]), ) idx = pd.to_datetime(["2024-01-15", "2024-02-15"]) prices = pd.DataFrame({"SPY": [100.0, 100.0], "TLT": [50.0, 50.0]}, index=idx) bt = AlgoPipelineBacktester( prices=prices, initial_capital=10_000.0, algos=[ SelectThese(["SPY", "TLT"]), WeighTarget(weights_df), Rebalance(), ], ) bal = bt.run() # Jan 15 → uses Jan 1 weights (60/40) spy_val = bal.iloc[0]["SPY qty"] * 100.0 total = bal.iloc[0]["total capital"] assert spy_val / total > 0.55 # roughly 60% # --------------------------------------------------------------------------- # SelectRegex # --------------------------------------------------------------------------- def test_select_regex_matches(): ctx = _ctx() ctx.prices = pd.Series({"SPY": 100, "SPXL": 50, "QQQ": 200, "IWM": 80}) algo = SelectRegex(r"^SP") algo(ctx) assert sorted(ctx.selected_symbols) == ["SPXL", "SPY"] def test_select_regex_no_match_skips(): ctx = _ctx() algo = SelectRegex(r"^ZZZZZ") decision = algo(ctx) assert decision.status == "skip_day" def test_select_regex_case_insensitive(): ctx = _ctx() ctx.prices = pd.Series({"spy": 100, "SPY": 100, "QQQ": 200}) algo = SelectRegex(r"(?i)spy") algo(ctx) assert set(ctx.selected_symbols) == {"spy", "SPY"} def test_select_regex_in_pipeline(): prices = pd.DataFrame( {"SPY": [100, 102], "SPXL": [50, 51], "QQQ": [200, 202]}, index=pd.date_range("2024-01-01", periods=2, freq="B"), ) bt = AlgoPipelineBacktester( prices=prices, initial_capital=1000.0, algos=[RunDaily(), SelectRegex(r"^SP"), WeighEqually(), Rebalance()], ) bal = bt.run() # Should only hold SPY and SPXL, not QQQ assert bal.iloc[-1].get("QQQ qty", 0) == 0 assert bal.iloc[-1]["SPY qty"] > 0 assert bal.iloc[-1]["SPXL qty"] > 0 # =========================================================================== # HEDGE RISKS # =========================================================================== def test_hedge_risks_adjusts_weights(): prices = _daily_prices(symbols=("SPY", "TLT"), days=30) ctx = _ctx( prices=prices.iloc[-1], date=prices.index[-1], selected_symbols=["SPY", "TLT"], target_weights={"SPY": 0.80}, price_history=prices, ) algo = HedgeRisks(target_delta=0.0, hedge_symbols=["TLT"]) d = algo(ctx) assert d.status == "continue" # TLT should now have a hedge weight assigned assert "TLT" in ctx.target_weights def test_hedge_risks_no_target_weights(): ctx = _ctx(target_weights={}) d = HedgeRisks()(ctx) assert d.status == "skip_day" def test_hedge_risks_no_history(): ctx = _ctx( target_weights={"SPY": 1.0}, selected_symbols=["TLT"], prices=pd.Series({"SPY": 100.0, "TLT": 50.0}), ) d = HedgeRisks()(ctx) assert d.status == "skip_day" def test_hedge_risks_in_pipeline(): prices = _daily_prices(symbols=("SPY", "TLT"), days=30) bt = AlgoPipelineBacktester( prices=prices, initial_capital=10_000.0, algos=[ RunMonthly(), SelectThese(["SPY", "TLT"]), WeighSpecified({"SPY": 0.80, "TLT": 0.20}), HedgeRisks(target_delta=0.0, hedge_symbols=["TLT"]), Rebalance(), ], ) bal = bt.run() assert not bal.empty # =========================================================================== # MARGIN # =========================================================================== def test_margin_scales_weights(): ctx = _ctx( target_weights={"SPY": 0.50}, total_capital=10000.0, cash=5000.0, ) algo = Margin(leverage=2.0) d = algo(ctx) assert d.status == "continue" assert abs(ctx.target_weights["SPY"] - 1.0) < 1e-10 def test_margin_charges_interest(): ctx = _ctx( total_capital=10000.0, cash=3000.0, # invested=7000, borrowed=max(0, 7000-3000)=4000 positions={"SPY": 70.0}, prices=pd.Series({"SPY": 100.0}), ) algo = Margin(leverage=1.0, interest_rate=0.05) original_capital = ctx.total_capital algo(ctx) # Should have charged interest: 0.05/252 * 4000 ≈ 0.79 assert ctx.total_capital < original_capital def test_margin_reset(): algo = Margin(leverage=2.0) algo._borrowed = 5000.0 algo.reset() assert algo._borrowed == 0.0 def test_margin_call_stops(): # equity = cash + stock_value = -400 + 500 = 100 # exposure = stock_value = 500 # equity/exposure = 100/500 = 0.20 < 0.25 → margin call ctx = _ctx( total_capital=100.0, cash=-400.0, positions={"SPY": 5.0}, prices=pd.Series({"SPY": 100.0}), ) algo = Margin(leverage=2.0, maintenance_pct=0.25) d = algo(ctx) assert d.status == "stop" # =========================================================================== # COUPON PAYING POSITION # =========================================================================== def test_coupon_pays_on_schedule(): algo = CouponPayingPosition(coupon_amount=500.0, frequency="monthly") ctx1 = _ctx(date=pd.Timestamp("2024-01-15"), cash=10000.0, total_capital=10000.0) d1 = algo(ctx1) assert ctx1.cash == 10500.0 assert "coupon paid" in d1.message # Same month → no second coupon ctx2 = _ctx(date=pd.Timestamp("2024-01-20"), cash=10000.0, total_capital=10000.0) d2 = algo(ctx2) assert ctx2.cash == 10000.0 def test_coupon_semi_annual_spacing(): algo = CouponPayingPosition(coupon_amount=250.0, frequency="semi-annual") ctx1 = _ctx(date=pd.Timestamp("2024-01-15"), cash=10000.0, total_capital=10000.0) algo(ctx1) assert ctx1.cash == 10250.0 # first coupon # 3 months later → too early ctx2 = _ctx(date=pd.Timestamp("2024-04-15"), cash=10000.0, total_capital=10000.0) algo(ctx2) assert ctx2.cash == 10000.0 # 6 months later → pays ctx3 = _ctx(date=pd.Timestamp("2024-07-15"), cash=10000.0, total_capital=10000.0) algo(ctx3) assert ctx3.cash == 10250.0 def test_coupon_stops_at_maturity(): algo = CouponPayingPosition( coupon_amount=100.0, frequency="monthly", maturity_date="2024-03-01", ) ctx = _ctx(date=pd.Timestamp("2024-03-15"), cash=5000.0, total_capital=5000.0) d = algo(ctx) assert d.status == "stop" assert ctx.cash == 5100.0 # final coupon paid def test_coupon_before_start_date(): algo = CouponPayingPosition( coupon_amount=100.0, frequency="monthly", start_date="2024-06-01", ) ctx = _ctx(date=pd.Timestamp("2024-01-15"), cash=5000.0, total_capital=5000.0) algo(ctx) assert ctx.cash == 5000.0 # no payment before start def test_coupon_invalid_frequency(): import pytest with pytest.raises(ValueError, match="frequency must be one of"): CouponPayingPosition(coupon_amount=100.0, frequency="bi-weekly") def test_coupon_reset(): algo = CouponPayingPosition(coupon_amount=100.0, frequency="monthly") algo._last_coupon_month = (2024, 5) algo.reset() assert algo._last_coupon_month is None # =========================================================================== # REPLAY TRANSACTIONS # =========================================================================== def test_replay_buys_on_matching_date(): blotter = pd.DataFrame({ "date": ["2024-01-02", "2024-01-02"], "symbol": ["SPY", "TLT"], "quantity": [10, 20], }) algo = ReplayTransactions(blotter) ctx = _ctx( date=pd.Timestamp("2024-01-02"), prices=pd.Series({"SPY": 100.0, "TLT": 50.0}), cash=5000.0, positions={}, ) d = algo(ctx) assert d.status == "continue" assert ctx.positions["SPY"] == 10 assert ctx.positions["TLT"] == 20 # cash = 5000 - 10*100 - 20*50 = 5000 - 1000 - 1000 = 3000 assert abs(ctx.cash - 3000.0) < 1e-10 def test_replay_sells(): blotter = pd.DataFrame({ "date": ["2024-01-02"], "symbol": ["SPY"], "quantity": [-5], }) algo = ReplayTransactions(blotter) ctx = _ctx( date=pd.Timestamp("2024-01-02"), prices=pd.Series({"SPY": 100.0}), cash=0.0, positions={"SPY": 10.0}, ) algo(ctx) assert ctx.positions["SPY"] == 5.0 assert abs(ctx.cash - 500.0) < 1e-10 # received 5*100 def test_replay_no_trades_on_date(): blotter = pd.DataFrame({ "date": ["2024-02-01"], "symbol": ["SPY"], "quantity": [10], }) algo = ReplayTransactions(blotter) ctx = _ctx(date=pd.Timestamp("2024-01-15"), cash=5000.0, positions={}) d = algo(ctx) assert d.status == "continue" assert ctx.positions == {} assert ctx.cash == 5000.0 def test_replay_closes_position_to_zero(): blotter = pd.DataFrame({ "date": ["2024-01-02"], "symbol": ["SPY"], "quantity": [-10], }) algo = ReplayTransactions(blotter) ctx = _ctx( date=pd.Timestamp("2024-01-02"), prices=pd.Series({"SPY": 100.0}), cash=0.0, positions={"SPY": 10.0}, ) algo(ctx) assert "SPY" not in ctx.positions # fully closed def test_replay_missing_columns_raises(): import pytest bad_blotter = pd.DataFrame({"date": ["2024-01-02"], "symbol": ["SPY"]}) with pytest.raises(ValueError, match="missing columns"): ReplayTransactions(bad_blotter) def test_replay_in_pipeline(): blotter = pd.DataFrame({ "date": ["2024-01-02"], "symbol": ["SPY"], "quantity": [5], }) idx = pd.to_datetime(["2024-01-02", "2024-01-03"]) prices = pd.DataFrame({"SPY": [100.0, 105.0]}, index=idx) bt = AlgoPipelineBacktester( prices=prices, initial_capital=1000.0, algos=[RunDaily(), ReplayTransactions(blotter)], ) bal = bt.run() assert bal.loc[pd.Timestamp("2024-01-02"), "SPY qty"] == 5 # =========================================================================== # set_date_range on AlgoPipelineBacktester # =========================================================================== def test_set_date_range_returns_stats(): prices = _daily_prices(days=60) bt = AlgoPipelineBacktester( prices=prices, initial_capital=10_000.0, algos=[RunMonthly(), SelectAll(), WeighEqually(), Rebalance()], ) bt.run() stats = bt.set_date_range(start="2024-02-01") assert stats.total_return != 0.0 or stats.total_trades == 0 assert hasattr(stats, "sharpe_ratio") ================================================ FILE: tests/engine/test_portfolio_integration.py ================================================ """Tests verifying Portfolio dataclass is kept in sync with legacy MultiIndex inventory.""" import os import pytest import numpy as np from options_portfolio_backtester.engine.engine import BacktestEngine from options_portfolio_backtester.execution.cost_model import NoCosts from options_portfolio_backtester.portfolio.portfolio import Portfolio from options_portfolio_backtester.portfolio.position import OptionPosition from options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.core.types import Stock, OptionType as Type, Direction TEST_DIR = os.path.join(os.path.dirname(__file__), "..", "test_data") STOCKS_FILE = os.path.join(TEST_DIR, "ivy_5assets_data.csv") OPTIONS_FILE = os.path.join(TEST_DIR, "options_data.csv") def _ivy_stocks(): return [Stock("VTI", 0.2), Stock("VEU", 0.2), Stock("BND", 0.2), Stock("VNQ", 0.2), Stock("DBC", 0.2)] def _stocks_data(): data = TiingoData(STOCKS_FILE) data._data["adjClose"] = 10 return data def _options_data(): data = HistoricalOptionsData(OPTIONS_FILE) data._data.at[2, "ask"] = 1 data._data.at[2, "bid"] = 0.5 data._data.at[51, "ask"] = 1.5 data._data.at[50, "bid"] = 0.5 data._data.at[130, "bid"] = 0.5 data._data.at[131, "bid"] = 1.5 data._data.at[206, "bid"] = 0.5 data._data.at[207, "bid"] = 1.5 return data def _buy_strategy(schema): strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) leg.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat.add_legs([leg]) return strat def _run_engine(): stocks = _ivy_stocks() stocks_data = _stocks_data() options_data = _options_data() schema = options_data.schema engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), ) engine.stocks = stocks engine.stocks_data = stocks_data engine.options_data = options_data engine.options_strategy = _buy_strategy(schema) engine.run(rebalance_freq=1) return engine class TestPortfolioIntegration: """Verify _portfolio dataclass is maintained alongside legacy DataFrames.""" @pytest.fixture(autouse=True) def setup(self): self.engine = _run_engine() def test_portfolio_exists(self): assert hasattr(self.engine, '_portfolio') assert isinstance(self.engine._portfolio, Portfolio) def test_portfolio_position_count_matches_inventory(self): """After backtest, portfolio positions should match remaining inventory rows.""" inv_count = len(self.engine._options_inventory) port_count = len(self.engine._portfolio.option_positions) assert inv_count == port_count, ( f"Inventory has {inv_count} rows but Portfolio has {port_count} positions" ) def test_positions_have_correct_legs(self): """Each position should have legs matching strategy leg names.""" for pid, pos in self.engine._portfolio.option_positions.items(): assert isinstance(pos, OptionPosition) assert len(pos.legs) > 0 for leg_name in pos.legs: assert leg_name.startswith("leg_") def test_trade_log_not_empty(self): """Backtest should produce trades.""" assert not self.engine.trade_log.empty def test_portfolio_contracts_match_inventory(self): """Portfolio contract IDs should match inventory contract IDs.""" for idx, inv_row in self.engine._options_inventory.iterrows(): if idx in self.engine._portfolio.option_positions: pos = self.engine._portfolio.option_positions[idx] for leg in self.engine._options_strategy.legs: inv_contract = inv_row[leg.name]["contract"] pos_contract = pos.legs[leg.name].contract_id assert inv_contract == pos_contract, ( f"Contract mismatch at {idx}/{leg.name}: " f"inventory={inv_contract}, portfolio={pos_contract}" ) ================================================ FILE: tests/engine/test_regression_snapshots.py ================================================ """Regression snapshot tests — lock backtest outputs against golden values. Run a full backtest with fixed data + deterministic config, assert against hardcoded values. Any change in output = regression. Uses NearestDelta selector to force the Python path for determinism (avoids Rust dispatch which may differ across platforms). """ import math import os import pytest from options_portfolio_backtester.engine.engine import BacktestEngine from options_portfolio_backtester.execution.cost_model import NoCosts, PerContractCommission from options_portfolio_backtester.execution.signal_selector import NearestDelta from options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.core.types import Stock, OptionType as Type, Direction TEST_DIR = os.path.join(os.path.dirname(__file__), "..", "test_data") STOCKS_FILE = os.path.join(TEST_DIR, "ivy_5assets_data.csv") OPTIONS_FILE = os.path.join(TEST_DIR, "options_data.csv") # --------------------------------------------------------------------------- # Shared helpers (mirrors test_engine.py pattern) # --------------------------------------------------------------------------- def _ivy_stocks(): return [Stock("VTI", 0.2), Stock("VEU", 0.2), Stock("BND", 0.2), Stock("VNQ", 0.2), Stock("DBC", 0.2)] def _stocks_data(): data = TiingoData(STOCKS_FILE) data._data["adjClose"] = 10 return data def _options_data(): data = HistoricalOptionsData(OPTIONS_FILE) data._data.at[2, "ask"] = 1 data._data.at[2, "bid"] = 0.5 data._data.at[51, "ask"] = 1.5 data._data.at[50, "bid"] = 0.5 data._data.at[130, "bid"] = 0.5 data._data.at[131, "bid"] = 1.5 data._data.at[206, "bid"] = 0.5 data._data.at[207, "bid"] = 1.5 return data def _build_strategy(schema, direction=Direction.BUY): strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=direction) leg.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat.add_legs([leg]) return strat def _run(cost_model=None, direction=Direction.BUY, monthly=False): stocks = _ivy_stocks() stocks_data = _stocks_data() options_data = _options_data() schema = options_data.schema engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=cost_model or NoCosts(), signal_selector=NearestDelta(target_delta=-0.30), ) engine.stocks = stocks engine.stocks_data = stocks_data engine.options_data = options_data engine.options_strategy = _build_strategy(schema, direction=direction) engine.run(rebalance_freq=1, monthly=monthly) return engine # --------------------------------------------------------------------------- # Golden values captured from deterministic runs # --------------------------------------------------------------------------- class TestSnapshotBuyPutNoCosts: """Buy-put backtest with NoCosts, daily rebalance.""" @pytest.fixture(autouse=True) def setup(self): self.engine = _run() def test_final_capital(self): final = self.engine.balance["total capital"].iloc[-1] assert abs(final - 948325.0) < 0.01, f"Regression: final_capital={final}" def test_trade_count(self): n = len(self.engine.trade_log) assert n == 2, f"Regression: trade_count={n}" def test_balance_rows(self): n = len(self.engine.balance) assert n == 61, f"Regression: balance_rows={n}" def test_total_return(self): bal = self.engine.balance["total capital"] ret = (bal.iloc[-1] - bal.iloc[0]) / bal.iloc[0] assert abs(ret - (-0.051675)) < 1e-4, f"Regression: total_return={ret}" def test_max_drawdown(self): bal = self.engine.balance["total capital"] running_max = bal.cummax() dd = (running_max - bal) / running_max max_dd = dd.max() assert abs(max_dd - 0.051675) < 1e-4, f"Regression: max_drawdown={max_dd}" class TestSnapshotBuyPutWithCommission: """Buy-put with PerContractCommission — costs must reduce final capital.""" @pytest.fixture(autouse=True) def setup(self): self.engine_no_cost = _run() self.engine_cost = _run(cost_model=PerContractCommission(0.65)) def test_commission_reduces_capital(self): no_cost_final = self.engine_no_cost.balance["total capital"].iloc[-1] cost_final = self.engine_cost.balance["total capital"].iloc[-1] assert cost_final < no_cost_final def test_final_capital(self): final = self.engine_cost.balance["total capital"].iloc[-1] assert abs(final - 946336.9) < 0.01, f"Regression: final_capital={final}" class TestSnapshotSellPut: """Sell-put (reversed direction) — verifies direction wiring.""" @pytest.fixture(autouse=True) def setup(self): self.engine = _run(direction=Direction.SELL) def test_final_capital(self): final = self.engine.balance["total capital"].iloc[-1] assert abs(final - 869300.0) < 1.0, f"Regression: final_capital={final}" def test_trade_count(self): n = len(self.engine.trade_log) assert n == 2, f"Regression: trade_count={n}" def test_sell_vs_buy_differ(self): buy_engine = _run(direction=Direction.BUY) buy_final = buy_engine.balance["total capital"].iloc[-1] sell_final = self.engine.balance["total capital"].iloc[-1] assert buy_final != sell_final class TestSnapshotMonthlyRebalance: """Monthly rebalance — fewer balance rows than daily.""" @pytest.fixture(autouse=True) def setup(self): self.engine_daily = _run(monthly=False) self.engine_monthly = _run(monthly=True) def test_fewer_balance_rows(self): daily_rows = len(self.engine_daily.balance) monthly_rows = len(self.engine_monthly.balance) assert monthly_rows <= daily_rows def test_final_capital(self): final = self.engine_monthly.balance["total capital"].iloc[-1] assert abs(final - 948325.0) < 0.01, f"Regression: final_capital={final}" def test_balance_rows(self): n = len(self.engine_monthly.balance) assert n == 61, f"Regression: balance_rows={n}" ================================================ FILE: tests/engine/test_risk_wiring.py ================================================ """Tests that RiskManager is actually wired into the engine.""" import os import pytest import numpy as np from options_portfolio_backtester.engine.engine import BacktestEngine from options_portfolio_backtester.execution.cost_model import NoCosts from options_portfolio_backtester.portfolio.risk import RiskManager, MaxDelta, MaxDrawdown from options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.core.types import Stock, OptionType as Type, Direction TEST_DIR = os.path.join(os.path.dirname(__file__), "..", "test_data") STOCKS_FILE = os.path.join(TEST_DIR, "ivy_5assets_data.csv") OPTIONS_FILE = os.path.join(TEST_DIR, "options_data.csv") def _ivy_stocks(): return [Stock("VTI", 0.2), Stock("VEU", 0.2), Stock("BND", 0.2), Stock("VNQ", 0.2), Stock("DBC", 0.2)] def _stocks_data(): data = TiingoData(STOCKS_FILE) data._data["adjClose"] = 10 return data def _options_data(): data = HistoricalOptionsData(OPTIONS_FILE) data._data.at[2, "ask"] = 1 data._data.at[2, "bid"] = 0.5 data._data.at[51, "ask"] = 1.5 data._data.at[50, "bid"] = 0.5 data._data.at[130, "bid"] = 0.5 data._data.at[131, "bid"] = 1.5 data._data.at[206, "bid"] = 0.5 data._data.at[207, "bid"] = 1.5 return data def _buy_strategy(schema): strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) leg.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat.add_legs([leg]) return strat def _run_engine(risk_manager=None): stocks = _ivy_stocks() stocks_data = _stocks_data() options_data = _options_data() schema = options_data.schema engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), risk_manager=risk_manager or RiskManager(), ) engine.stocks = stocks engine.stocks_data = stocks_data engine.options_data = options_data engine.options_strategy = _buy_strategy(schema) engine.run(rebalance_freq=1) return engine class TestRiskManagerWiring: """Verify the engine actually calls the risk manager.""" def test_no_constraints_allows_all(self): engine = _run_engine(RiskManager()) assert not engine.trade_log.empty def test_max_delta_blocks_entries(self): """Tiny delta limit should block all entries.""" no_risk = _run_engine(RiskManager()) tight_risk = _run_engine(RiskManager([MaxDelta(limit=0.0001)])) no_risk_trades = len(no_risk.trade_log) tight_trades = len(tight_risk.trade_log) assert tight_trades < no_risk_trades, ( f"MaxDelta should block entries: got {tight_trades} vs {no_risk_trades}" ) def test_max_drawdown_blocks_during_crash(self): """Very tight drawdown limit should block entries once any loss occurs.""" no_risk = _run_engine(RiskManager()) tight_dd = _run_engine(RiskManager([MaxDrawdown(max_dd_pct=0.0001)])) no_risk_trades = len(no_risk.trade_log) tight_trades = len(tight_dd.trade_log) # With 0.01% max drawdown, most entries after the first should be blocked assert tight_trades <= no_risk_trades def test_risk_manager_preserves_capital(self): """Blocked entries should leave cash unchanged (budget stays as cash).""" blocked = _run_engine(RiskManager([MaxDelta(limit=0.0001)])) # If all entries are blocked, the final capital should be close to initial # minus stock movements assert blocked.balance is not None ================================================ FILE: tests/engine/test_rust_parity.py ================================================ """Rust vs Python numerical parity: run same strategy both paths, compare values. When the Rust extension is available, the engine auto-dispatches to Rust for default configs. These tests force both paths and compare results. """ import os import math import numpy as np import pytest from options_portfolio_backtester.engine.engine import BacktestEngine try: from options_portfolio_backtester import _ob_rust # noqa: F401 _RUST_OK = True except ImportError: _RUST_OK = False from options_portfolio_backtester.execution.cost_model import NoCosts, PerContractCommission from options_portfolio_backtester.execution.signal_selector import FirstMatch, NearestDelta from options_portfolio_backtester.portfolio.risk import RiskManager from options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.core.types import Stock, OptionType as Type, Direction TEST_DIR = os.path.join(os.path.dirname(__file__), "..", "test_data") STOCKS_FILE = os.path.join(TEST_DIR, "ivy_5assets_data.csv") OPTIONS_FILE = os.path.join(TEST_DIR, "options_data.csv") def _ivy_stocks(): return [Stock("VTI", 0.2), Stock("VEU", 0.2), Stock("BND", 0.2), Stock("VNQ", 0.2), Stock("DBC", 0.2)] def _stocks_data(): data = TiingoData(STOCKS_FILE) data._data["adjClose"] = 10 return data def _options_data(): data = HistoricalOptionsData(OPTIONS_FILE) data._data.at[2, "ask"] = 1 data._data.at[2, "bid"] = 0.5 data._data.at[51, "ask"] = 1.5 data._data.at[50, "bid"] = 0.5 data._data.at[130, "bid"] = 0.5 data._data.at[131, "bid"] = 1.5 data._data.at[206, "bid"] = 0.5 data._data.at[207, "bid"] = 1.5 return data def _buy_strategy(schema): strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) leg.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat.add_legs([leg]) return strat def _make_engine(): """Create engine with default config (will use Rust if available).""" engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = _options_data() engine.options_strategy = _buy_strategy(engine.options_data.schema) return engine def _run_python_path(): """Run engine with options_budget_pct equivalent to 3% allocation.""" engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), ) engine.options_budget_pct = 0.03 engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = _options_data() engine.options_strategy = _buy_strategy(engine.options_data.schema) engine.run(rebalance_freq=1) return engine def _run_rust_path(): """Run engine with default config so Rust dispatch kicks in.""" engine = _make_engine() engine.run(rebalance_freq=1) return engine @pytest.mark.skipif(not _RUST_OK, reason="Rust extension not installed") class TestRustVsPythonParity: """Numerical parity: Rust auto-dispatch must match original Backtest regression values. The TestEngineMatchesOriginal in test_engine.py already asserts that with Rust dispatch active, the engine produces identical results to the original Backtest. Here we verify additional properties of the Rust output. """ @pytest.fixture(autouse=True) def setup(self): self.rs = _run_rust_path() def test_trade_log_shape(self): assert self.rs.trade_log.shape == (2, 10) def test_regression_costs(self): """Known regression values — positions persist across rebalances.""" tol = 0.0001 costs = self.rs.trade_log["totals"]["cost"].values assert np.allclose(costs, [100, 150], rtol=tol) def test_regression_qtys(self): tol = 0.0001 qtys = self.rs.trade_log["totals"]["qty"].values assert np.allclose(qtys, [300, 97], rtol=tol) def test_final_capital(self): final = self.rs.balance["total capital"].iloc[-1] assert abs(final - 957920.0) < 1.0 def test_balance_row_count(self): assert len(self.rs.balance) == 61 def test_balance_column_count(self): assert len(self.rs.balance.columns) == 20 def test_balance_has_all_columns(self): required = [ "cash", "options qty", "calls capital", "puts capital", "stocks qty", "options capital", "stocks capital", "total capital", "% change", "accumulated return", ] for col in required: assert col in self.rs.balance.columns, f"Missing: {col}" for stock in _ivy_stocks(): assert stock.symbol in self.rs.balance.columns assert f"{stock.symbol} qty" in self.rs.balance.columns def test_initial_row_capital(self): """First row should have initial capital.""" assert self.rs.balance["total capital"].iloc[0] == 1_000_000 def test_accumulated_return_starts_at_one(self): """Second row's accumulated return should be close to 1.0.""" # First row is NaN (no pct_change), second row is the first real value acc_ret = self.rs.balance["accumulated return"].dropna() assert len(acc_ret) > 0 @pytest.mark.skipif(not _RUST_OK, reason="Rust extension not installed") class TestRustDispatchGating: """Verify Rust dispatch is used/skipped under the right conditions.""" def test_default_config_runs(self): """Default config completes successfully.""" engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = _options_data() engine.options_strategy = _buy_strategy(engine.options_data.schema) engine.run(rebalance_freq=1) assert not engine.trade_log.empty def test_custom_cost_model_runs(self): """Custom cost model completes successfully.""" engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=PerContractCommission(rate=1.0), ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = _options_data() engine.options_strategy = _buy_strategy(engine.options_data.schema) engine.run(rebalance_freq=1) assert not engine.trade_log.empty def test_custom_selector_runs(self): """Custom signal selector completes successfully.""" engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), signal_selector=NearestDelta(target_delta=-0.30), ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = _options_data() engine.options_strategy = _buy_strategy(engine.options_data.schema) engine.run(rebalance_freq=1) assert not engine.trade_log.empty def test_per_leg_override_runs(self): """Per-leg signal selector completes successfully.""" from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg as NewLeg from options_portfolio_backtester.execution.signal_selector import FirstMatch as FM options_data = _options_data() schema = options_data.schema leg = NewLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY, signal_selector=FM()) leg.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat = Strategy(schema) strat.add_legs([leg]) engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = options_data engine.options_strategy = strat engine.run(rebalance_freq=1) assert not engine.trade_log.empty class TestThresholdExits: """Test profit/loss threshold exits work in the engine.""" def test_profit_threshold_triggers_exit(self): options_data = _options_data() schema = options_data.schema strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) leg.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat.add_legs([leg]) # Very tight profit threshold — should trigger exit quickly strat.add_exit_thresholds(profit_pct=0.01) engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), signal_selector=NearestDelta(target_delta=-0.30), # force Python path ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = options_data engine.options_strategy = strat engine.run(rebalance_freq=1) # Should have completed without error assert engine.balance is not None def test_loss_threshold_triggers_exit(self): options_data = _options_data() schema = options_data.schema strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) leg.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat.add_legs([leg]) strat.add_exit_thresholds(loss_pct=0.3) engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = options_data engine.options_strategy = strat engine.run(rebalance_freq=1) assert engine.balance is not None def test_both_thresholds(self): options_data = _options_data() schema = options_data.schema strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) leg.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat.add_legs([leg]) strat.add_exit_thresholds(profit_pct=0.5, loss_pct=0.3) engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = options_data engine.options_strategy = strat engine.run(rebalance_freq=1) assert engine.balance is not None assert not engine.trade_log.empty class TestPerLegSellDirection: """Verify per-leg fill model works for SELL-direction legs.""" def test_sell_leg_with_midprice(self): """SELL leg with MidPrice fill should have correct sign on cost.""" from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg as NewLeg from options_portfolio_backtester.execution.fill_model import MidPrice options_data = _options_data() schema = options_data.schema leg = NewLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.SELL, fill_model=MidPrice()) leg.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat = Strategy(schema) strat.add_legs([leg]) engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = options_data engine.options_strategy = strat engine.run(rebalance_freq=1) if not engine.trade_log.empty: tl = engine.trade_log # SELL-to-open (STO) entries should have negative cost (credit) # Buy-to-close (BTC) liquidation trades will have positive cost sto_mask = tl["leg_1"]["order"] == "STO" sto_costs = tl.loc[sto_mask, ("leg_1", "cost")].values assert all(c < 0 for c in sto_costs if c != 0), ( f"STO costs should be negative, got: {sto_costs}" ) class TestBalanceCompleteness: """Verify balance DataFrame has all expected columns after a run.""" def test_balance_columns_present(self): engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), signal_selector=NearestDelta(target_delta=-0.30), # force Python ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = _options_data() engine.options_strategy = _buy_strategy(engine.options_data.schema) engine.run(rebalance_freq=1) required = [ "cash", "options qty", "calls capital", "puts capital", "stocks qty", "options capital", "stocks capital", "total capital", "% change", "accumulated return", ] for col in required: assert col in engine.balance.columns, f"Missing column: {col}" # Per-stock columns for stock in _ivy_stocks(): assert stock.symbol in engine.balance.columns, ( f"Missing stock column: {stock.symbol}" ) assert f"{stock.symbol} qty" in engine.balance.columns, ( f"Missing stock qty column: {stock.symbol} qty" ) def test_balance_no_negative_total_capital(self): """Total capital should never go negative in a standard backtest.""" engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), signal_selector=NearestDelta(target_delta=-0.30), ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = _options_data() engine.options_strategy = _buy_strategy(engine.options_data.schema) engine.run(rebalance_freq=1) total_cap = engine.balance["total capital"].dropna() assert (total_cap >= 0).all(), ( f"Negative total capital found: {total_cap[total_cap < 0].tolist()}" ) class TestEdgeCases: """Edge cases that should not crash.""" def test_high_rebalance_freq(self): """High rebalance frequency should still work.""" engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = _options_data() engine.options_strategy = _buy_strategy(engine.options_data.schema) engine.run(rebalance_freq=3) assert engine.balance is not None assert not engine.trade_log.empty def test_stop_if_broke(self): """stop_if_broke=True should halt cleanly.""" engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), signal_selector=NearestDelta(target_delta=-0.30), stop_if_broke=True, ) engine.stocks = _ivy_stocks() engine.stocks_data = _stocks_data() engine.options_data = _options_data() engine.options_strategy = _buy_strategy(engine.options_data.schema) engine.run(rebalance_freq=1) assert engine.balance is not None ================================================ FILE: tests/engine/test_signal_selector_wiring.py ================================================ """Tests that SignalSelector is actually wired into the engine. All execution goes through Rust, so we verify via standard selectors that have to_rust_config() and produce different Rust behavior. """ import os import pandas as pd import numpy as np import pytest from options_portfolio_backtester.engine.engine import BacktestEngine from options_portfolio_backtester.execution.cost_model import NoCosts from options_portfolio_backtester.execution.signal_selector import ( FirstMatch, NearestDelta, MaxOpenInterest, ) from options_portfolio_backtester.data.providers import HistoricalOptionsData, TiingoData from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.core.types import Stock, OptionType as Type, Direction TEST_DIR = os.path.join(os.path.dirname(__file__), "..", "test_data") STOCKS_FILE = os.path.join(TEST_DIR, "ivy_5assets_data.csv") OPTIONS_FILE = os.path.join(TEST_DIR, "options_data.csv") def _ivy_stocks(): return [Stock("VTI", 0.2), Stock("VEU", 0.2), Stock("BND", 0.2), Stock("VNQ", 0.2), Stock("DBC", 0.2)] def _stocks_data(): data = TiingoData(STOCKS_FILE) data._data["adjClose"] = 10 return data def _options_data(): data = HistoricalOptionsData(OPTIONS_FILE) data._data.at[2, "ask"] = 1 data._data.at[2, "bid"] = 0.5 data._data.at[51, "ask"] = 1.5 data._data.at[50, "bid"] = 0.5 data._data.at[130, "bid"] = 0.5 data._data.at[131, "bid"] = 1.5 data._data.at[206, "bid"] = 0.5 data._data.at[207, "bid"] = 1.5 return data def _buy_strategy(schema): strat = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) leg.entry_filter = (schema.underlying == "SPX") & (schema.dte >= 60) leg.exit_filter = schema.dte <= 30 strat.add_legs([leg]) return strat def _run_engine(signal_selector): stocks = _ivy_stocks() stocks_data = _stocks_data() options_data = _options_data() schema = options_data.schema engine = BacktestEngine( {"stocks": 0.97, "options": 0.03, "cash": 0}, cost_model=NoCosts(), signal_selector=signal_selector, ) engine.stocks = stocks engine.stocks_data = stocks_data engine.options_data = options_data engine.options_strategy = _buy_strategy(schema) engine.run(rebalance_freq=1) return engine class TestSignalSelectorWiring: """Verify the engine uses the plugged-in signal selector via Rust dispatch.""" def test_first_match_still_works(self): engine = _run_engine(FirstMatch()) assert not engine.trade_log.empty def test_different_selectors_may_pick_different_contracts(self): """FirstMatch and NearestDelta should produce valid results (may differ).""" first_engine = _run_engine(FirstMatch()) delta_engine = _run_engine(NearestDelta(target_delta=-0.30)) assert not first_engine.balance.empty assert not delta_engine.balance.empty def test_nearest_delta_runs_without_error(self): engine = _run_engine(NearestDelta(target_delta=-0.30)) assert engine.balance is not None def test_max_open_interest_runs(self): """MaxOpenInterest selector completes without error.""" engine = _run_engine(MaxOpenInterest(oi_column="openinterest")) assert engine.balance is not None ================================================ FILE: tests/engine/test_strategy_tree.py ================================================ from __future__ import annotations import pytest from options_portfolio_backtester.engine.strategy_tree import StrategyTreeNode, StrategyTreeEngine from tests.engine.test_engine import _run_engine def test_strategy_tree_allocates_capital_by_weights(): leaf_a = StrategyTreeNode(name="a", weight=2.0, engine=_run_engine()) leaf_b = StrategyTreeNode(name="b", weight=1.0, engine=_run_engine()) root = StrategyTreeNode(name="root", children=[leaf_a, leaf_b]) tree = StrategyTreeEngine(root, initial_capital=900_000) tree.run(rebalance_freq=1) assert abs(tree.leaf_weights["a"] - (2.0 / 3.0)) < 1e-12 assert abs(tree.leaf_weights["b"] - (1.0 / 3.0)) < 1e-12 assert tree.attribution["a"]["capital"] == round(900_000 * (2.0 / 3.0)) assert tree.attribution["b"]["capital"] == round(900_000 * (1.0 / 3.0)) assert "total capital" in tree.balance.columns def test_nested_tree_weight_propagation(): leaf_a = StrategyTreeNode(name="a", weight=1.0, engine=_run_engine()) leaf_b = StrategyTreeNode(name="b", weight=3.0, engine=_run_engine()) branch = StrategyTreeNode(name="branch", weight=2.0, children=[leaf_a, leaf_b]) leaf_c = StrategyTreeNode(name="c", weight=1.0, engine=_run_engine()) root = StrategyTreeNode(name="root", children=[branch, leaf_c]) tree = StrategyTreeEngine(root, initial_capital=1_000_000) tree.run(rebalance_freq=1) # branch share = 2/3; inside branch, a=1/4 and b=3/4 assert abs(tree.leaf_weights["a"] - (2.0 / 3.0) * (1.0 / 4.0)) < 1e-12 assert abs(tree.leaf_weights["b"] - (2.0 / 3.0) * (3.0 / 4.0)) < 1e-12 assert abs(tree.leaf_weights["c"] - (1.0 / 3.0)) < 1e-12 def test_leaf_max_share_throttles_allocation(): leaf_a = StrategyTreeNode(name="a", weight=1.0, max_share=0.20, engine=_run_engine()) leaf_b = StrategyTreeNode(name="b", weight=1.0, engine=_run_engine()) root = StrategyTreeNode(name="root", children=[leaf_a, leaf_b]) tree = StrategyTreeEngine(root, initial_capital=1_000_000) tree.run(rebalance_freq=1) assert abs(tree.leaf_weights["a"] - 0.20) < 1e-12 assert "a" in tree.throttles assert "unallocated_cash" in tree.balance.columns # --------------------------------------------------------------------------- # Validation (item 11) # --------------------------------------------------------------------------- def test_node_rejects_engine_and_children(): """A node cannot be both a leaf (engine) and a branch (children).""" with pytest.raises(ValueError, match="both engine and children"): StrategyTreeNode( name="bad", engine=_run_engine(), children=[StrategyTreeNode(name="child", engine=_run_engine())], ) def test_empty_branch_produces_no_leaves(): empty = StrategyTreeNode(name="empty", children=[]) root = StrategyTreeNode(name="root", children=[empty]) tree = StrategyTreeEngine(root, initial_capital=1_000_000) results = tree.run(rebalance_freq=1) assert len(results) == 0 assert tree.balance.empty # --------------------------------------------------------------------------- # Capital restoration (item 7) # --------------------------------------------------------------------------- def test_engine_capital_restored_after_tree_run(): """StrategyTreeEngine should not permanently mutate leaf engine capital.""" engine = _run_engine() original_capital = engine.initial_capital leaf = StrategyTreeNode(name="a", weight=1.0, engine=engine) root = StrategyTreeNode(name="root", children=[leaf]) tree = StrategyTreeEngine(root, initial_capital=500_000) tree.run(rebalance_freq=1) assert engine.initial_capital == original_capital # --------------------------------------------------------------------------- # Round vs int for capital allocation (item 5) # --------------------------------------------------------------------------- def test_capital_uses_round_not_truncate(): """With 3 equal-weight leaves and 1M capital, round(1e6/3) = 333333.""" engines = [_run_engine() for _ in range(3)] leaves = [StrategyTreeNode(name=f"l{i}", weight=1.0, engine=e) for i, e in enumerate(engines)] root = StrategyTreeNode(name="root", children=leaves) tree = StrategyTreeEngine(root, initial_capital=1_000_000) tree.run(rebalance_freq=1) total_allocated = sum(tree.attribution[f"l{i}"]["capital"] for i in range(3)) # With round(), 333333 * 3 = 999999 — only $1 lost vs $3 with int() assert total_allocated >= 999_999 # --------------------------------------------------------------------------- # Balance structure # --------------------------------------------------------------------------- def test_balance_has_pct_change_and_accumulated_return(): leaf = StrategyTreeNode(name="a", weight=1.0, engine=_run_engine()) root = StrategyTreeNode(name="root", children=[leaf]) tree = StrategyTreeEngine(root, initial_capital=1_000_000) tree.run(rebalance_freq=1) assert "% change" in tree.balance.columns assert "accumulated return" in tree.balance.columns assert "total capital" in tree.balance.columns def test_attribution_dict_structure(): leaf = StrategyTreeNode(name="a", weight=1.0, engine=_run_engine()) root = StrategyTreeNode(name="root", children=[leaf]) tree = StrategyTreeEngine(root, initial_capital=1_000_000) tree.run(rebalance_freq=1) assert "a" in tree.attribution assert "weight" in tree.attribution["a"] assert "capital" in tree.attribution["a"] assert tree.attribution["a"]["weight"] == 1.0 # --------------------------------------------------------------------------- # to_dot() — Graphviz DOT export # --------------------------------------------------------------------------- def test_to_dot_single_leaf(): leaf = StrategyTreeNode(name="leaf_a", weight=1.0, engine=_run_engine()) dot = leaf.to_dot() assert "digraph StrategyTree" in dot assert "leaf_a" in dot assert "w=1.0" in dot assert "ellipse" in dot # leaf → ellipse shape def test_to_dot_nested_tree(): leaf_a = StrategyTreeNode(name="a", weight=2.0, engine=_run_engine()) leaf_b = StrategyTreeNode(name="b", weight=1.0, engine=_run_engine()) root = StrategyTreeNode(name="root", children=[leaf_a, leaf_b]) dot = root.to_dot() assert "digraph StrategyTree" in dot assert "root" in dot assert "box" in dot # branch → box shape assert "->" in dot # edges exist assert "w=2.0" in dot assert "w=1.0" in dot def test_to_dot_max_share_shown(): leaf = StrategyTreeNode(name="capped", weight=1.0, max_share=0.25, engine=_run_engine()) dot = leaf.to_dot() assert "max=0.25" in dot def test_engine_to_dot_delegates_to_root(): leaf = StrategyTreeNode(name="x", weight=1.0, engine=_run_engine()) root = StrategyTreeNode(name="top", children=[leaf]) tree = StrategyTreeEngine(root, initial_capital=1_000_000) dot = tree.to_dot() assert "digraph StrategyTree" in dot assert "top" in dot assert "x" in dot ================================================ FILE: tests/execution/__init__.py ================================================ ================================================ FILE: tests/execution/test_cost_model.py ================================================ """Tests for transaction cost models.""" from options_portfolio_backtester.execution.cost_model import ( NoCosts, PerContractCommission, TieredCommission, SpreadSlippage, ) class TestNoCosts: def test_option_cost_is_zero(self): m = NoCosts() assert m.option_cost(2.50, 10, 100) == 0.0 def test_stock_cost_is_zero(self): m = NoCosts() assert m.stock_cost(150.0, 100) == 0.0 class TestPerContractCommission: def test_default_rate(self): m = PerContractCommission() assert m.option_cost(2.50, 10, 100) == 6.50 def test_custom_rate(self): m = PerContractCommission(rate=1.00) assert m.option_cost(2.50, 10, 100) == 10.00 def test_stock_rate(self): m = PerContractCommission(stock_rate=0.01) assert m.stock_cost(150.0, 100) == 1.00 def test_negative_qty_uses_abs(self): m = PerContractCommission(rate=0.65) assert m.option_cost(2.50, -10, 100) == 6.50 class TestTieredCommission: def test_default_tiers_small_qty(self): m = TieredCommission() cost = m.option_cost(2.50, 100, 100) assert cost == 100 * 0.65 def test_default_tiers_large_qty(self): m = TieredCommission() # 10000 @ 0.65 + 5000 @ 0.50 cost = m.option_cost(2.50, 15000, 100) assert cost == 10000 * 0.65 + 5000 * 0.50 class TestSpreadSlippage: def test_zero_pct(self): m = SpreadSlippage(pct=0.0) assert m.slippage(1.0, 1.10, 10, 100) == 0.0 def test_half_spread(self): m = SpreadSlippage(pct=0.5) # spread = 0.10, half = 0.05, * 10 * 100 = 50.0 assert abs(m.slippage(1.0, 1.10, 10, 100) - 50.0) < 1e-8 def test_full_spread(self): m = SpreadSlippage(pct=1.0) assert abs(m.slippage(1.0, 1.10, 10, 100) - 100.0) < 1e-8 def test_option_cost_is_zero(self): """SpreadSlippage models slippage separately, not via option_cost.""" m = SpreadSlippage(pct=0.5) assert m.option_cost(2.50, 10, 100) == 0.0 def test_stock_cost_is_zero(self): m = SpreadSlippage(pct=0.5) assert m.stock_cost(150.0, 100) == 0.0 class TestTieredCommissionEdgeCases: def test_qty_exceeds_all_tiers(self): """When quantity exceeds all tiers, remaining uses last tier rate.""" m = TieredCommission(tiers=[(10, 1.0), (20, 0.5)]) # 10 @ 1.0 + 10 @ 0.5 + 5 @ 0.5 (last tier rate) cost = m.option_cost(2.50, 25, 100) assert cost == 10 * 1.0 + 10 * 0.5 + 5 * 0.5 def test_stock_cost(self): m = TieredCommission(stock_rate=0.01) assert m.stock_cost(150.0, 100) == 1.00 def test_tier_boundary_exact(self): """Exact tier boundary: no remaining.""" m = TieredCommission(tiers=[(10, 1.0), (20, 0.5)]) cost = m.option_cost(2.50, 20, 100) assert cost == 10 * 1.0 + 10 * 0.5 class TestRustConfigs: def test_no_costs_rust_config(self): c = NoCosts().to_rust_config() assert c["type"] == "NoCosts" def test_per_contract_rust_config(self): c = PerContractCommission(rate=0.65, stock_rate=0.01).to_rust_config() assert c["type"] == "PerContract" assert c["rate"] == 0.65 assert c["stock_rate"] == 0.01 def test_tiered_rust_config(self): m = TieredCommission(tiers=[(100, 0.65), (500, 0.50)]) c = m.to_rust_config() assert c["type"] == "Tiered" assert len(c["tiers"]) == 2 ================================================ FILE: tests/execution/test_execution_deep.py ================================================ """Deep execution model tests — fill models, cost models, signal selectors, sizers. Tests edge cases, boundary conditions, and composition of execution components. """ import numpy as np import pandas as pd import pytest from options_portfolio_backtester.execution.cost_model import ( NoCosts, PerContractCommission, TieredCommission, SpreadSlippage, ) from options_portfolio_backtester.execution.fill_model import ( MarketAtBidAsk, MidPrice, VolumeAwareFill, ) from options_portfolio_backtester.execution.signal_selector import ( FirstMatch, NearestDelta, MaxOpenInterest, ) from options_portfolio_backtester.execution.sizer import ( CapitalBased, FixedQuantity, FixedDollar, PercentOfPortfolio, ) from options_portfolio_backtester.core.types import Direction # --------------------------------------------------------------------------- # Cost Models — deep tests # --------------------------------------------------------------------------- class TestNoCosts: def test_option_cost_always_zero(self): m = NoCosts() assert m.option_cost(100.0, 50, 100) == 0.0 assert m.option_cost(0.0, 0, 100) == 0.0 def test_stock_cost_always_zero(self): m = NoCosts() assert m.stock_cost(50.0, 1000.0) == 0.0 def test_rust_config(self): assert NoCosts().to_rust_config() == {"type": "NoCosts"} class TestPerContractCommission: def test_basic_cost(self): m = PerContractCommission(rate=0.65) assert m.option_cost(10.0, 10, 100) == 6.5 def test_negative_qty_uses_abs(self): m = PerContractCommission(rate=1.0) assert m.option_cost(5.0, -20, 100) == 20.0 def test_zero_qty(self): m = PerContractCommission(rate=0.65) assert m.option_cost(10.0, 0, 100) == 0.0 def test_stock_cost_per_share(self): m = PerContractCommission(rate=0.65, stock_rate=0.01) assert m.stock_cost(50.0, 100) == 1.0 def test_stock_cost_negative_qty(self): m = PerContractCommission(stock_rate=0.005) assert m.stock_cost(50.0, -200) == 1.0 def test_rust_config_roundtrip(self): m = PerContractCommission(rate=0.65, stock_rate=0.005) cfg = m.to_rust_config() assert cfg["type"] == "PerContract" assert cfg["rate"] == 0.65 assert cfg["stock_rate"] == 0.005 class TestTieredCommission: def test_default_tiers(self): m = TieredCommission() # First 10000 at $0.65 assert m.option_cost(1.0, 100, 100) == 100 * 0.65 def test_tier_boundary(self): """Exactly at first tier boundary.""" m = TieredCommission() cost = m.option_cost(1.0, 10_000, 100) assert cost == 10_000 * 0.65 def test_crosses_first_tier(self): """15000 contracts: 10000 at $0.65, 5000 at $0.50.""" m = TieredCommission() cost = m.option_cost(1.0, 15_000, 100) expected = 10_000 * 0.65 + 5_000 * 0.50 assert abs(cost - expected) < 0.01 def test_crosses_all_tiers(self): """200000 contracts: 10000*0.65 + 40000*0.50 + 50000*0.25 + 100000*0.25.""" m = TieredCommission() cost = m.option_cost(1.0, 200_000, 100) expected = 10_000 * 0.65 + 40_000 * 0.50 + 50_000 * 0.25 + 100_000 * 0.25 assert abs(cost - expected) < 0.01 def test_custom_tiers(self): m = TieredCommission(tiers=[(5, 1.0), (10, 0.5)]) # 7 contracts: 5*1.0 + 2*0.5 cost = m.option_cost(1.0, 7, 100) assert abs(cost - 6.0) < 0.01 def test_negative_qty(self): m = TieredCommission() cost = m.option_cost(1.0, -100, 100) assert cost == 100 * 0.65 def test_zero_qty(self): m = TieredCommission() assert m.option_cost(1.0, 0, 100) == 0.0 def test_rust_config(self): m = TieredCommission() cfg = m.to_rust_config() assert cfg["type"] == "Tiered" assert len(cfg["tiers"]) == 3 class TestSpreadSlippage: def test_option_cost_is_zero(self): m = SpreadSlippage(pct=0.5) assert m.option_cost(1.0, 10, 100) == 0.0 def test_slippage_computation(self): m = SpreadSlippage(pct=0.5) # spread = 1.0, qty=10, spc=100 slippage = m.slippage(bid=9.0, ask=10.0, quantity=10, shares_per_contract=100) assert slippage == 0.5 * 1.0 * 10 * 100 def test_slippage_zero_spread(self): m = SpreadSlippage(pct=0.5) assert m.slippage(10.0, 10.0, 10, 100) == 0.0 def test_slippage_full_pct(self): m = SpreadSlippage(pct=1.0) slippage = m.slippage(9.0, 10.0, 1, 100) assert slippage == 100.0 def test_pct_bounds(self): with pytest.raises(AssertionError): SpreadSlippage(pct=-0.1) with pytest.raises(AssertionError): SpreadSlippage(pct=1.1) # --------------------------------------------------------------------------- # Fill Models — deep tests # --------------------------------------------------------------------------- def _make_option_row(bid=9.0, ask=10.0, volume=100): return pd.Series({"bid": bid, "ask": ask, "volume": volume}) class TestMarketAtBidAsk: def test_buy_fills_at_ask(self): m = MarketAtBidAsk() assert m.get_fill_price(_make_option_row(bid=9, ask=10), Direction.BUY) == 10.0 def test_sell_fills_at_bid(self): m = MarketAtBidAsk() assert m.get_fill_price(_make_option_row(bid=9, ask=10), Direction.SELL) == 9.0 def test_zero_spread(self): m = MarketAtBidAsk() assert m.get_fill_price(_make_option_row(bid=10, ask=10), Direction.BUY) == 10.0 assert m.get_fill_price(_make_option_row(bid=10, ask=10), Direction.SELL) == 10.0 class TestMidPrice: def test_midpoint(self): m = MidPrice() assert m.get_fill_price(_make_option_row(bid=9, ask=11), Direction.BUY) == 10.0 def test_same_bid_ask(self): m = MidPrice() assert m.get_fill_price(_make_option_row(bid=10, ask=10), Direction.BUY) == 10.0 def test_direction_doesnt_matter(self): m = MidPrice() row = _make_option_row(bid=8, ask=12) assert m.get_fill_price(row, Direction.BUY) == m.get_fill_price(row, Direction.SELL) def test_wide_spread(self): m = MidPrice() assert m.get_fill_price(_make_option_row(bid=1, ask=100), Direction.BUY) == 50.5 class TestVolumeAwareFill: def test_high_volume_fills_at_target(self): m = VolumeAwareFill(full_volume_threshold=100) row = _make_option_row(bid=9, ask=10, volume=100) assert m.get_fill_price(row, Direction.BUY) == 10.0 assert m.get_fill_price(row, Direction.SELL) == 9.0 def test_zero_volume_fills_at_mid(self): m = VolumeAwareFill(full_volume_threshold=100) row = _make_option_row(bid=9, ask=11, volume=0) assert m.get_fill_price(row, Direction.BUY) == 10.0 assert m.get_fill_price(row, Direction.SELL) == 10.0 def test_half_volume_interpolates(self): m = VolumeAwareFill(full_volume_threshold=100) row = _make_option_row(bid=8, ask=12, volume=50) # mid=10, target_buy=12, ratio=0.5 → 10 + 0.5*(12-10) = 11 assert m.get_fill_price(row, Direction.BUY) == 11.0 # mid=10, target_sell=8, ratio=0.5 → 10 + 0.5*(8-10) = 9 assert m.get_fill_price(row, Direction.SELL) == 9.0 def test_above_threshold_same_as_market(self): m = VolumeAwareFill(full_volume_threshold=100) row = _make_option_row(bid=9, ask=10, volume=500) assert m.get_fill_price(row, Direction.BUY) == 10.0 def test_rust_config(self): m = VolumeAwareFill(full_volume_threshold=200) cfg = m.to_rust_config() assert cfg["type"] == "VolumeAware" assert cfg["full_volume_threshold"] == 200 # --------------------------------------------------------------------------- # Signal Selectors — deep tests # --------------------------------------------------------------------------- def _make_candidates(n=5, with_delta=True, with_oi=True): data = { "contract": [f"SPX{i}" for i in range(n)], "strike": [100 + i * 5 for i in range(n)], "bid": [1.0 + i * 0.1 for i in range(n)], "ask": [1.5 + i * 0.1 for i in range(n)], } if with_delta: data["delta"] = [-0.05 - i * 0.05 for i in range(n)] # -0.05, -0.10, ... if with_oi: data["openinterest"] = [100 * (i + 1) for i in range(n)] return pd.DataFrame(data) class TestFirstMatch: def test_selects_first_row(self): s = FirstMatch() df = _make_candidates() selected = s.select(df) assert selected["contract"] == "SPX0" def test_single_row(self): s = FirstMatch() df = _make_candidates(n=1) selected = s.select(df) assert selected["contract"] == "SPX0" class TestNearestDelta: def test_selects_closest_delta(self): s = NearestDelta(target_delta=-0.15) df = _make_candidates() # deltas: -0.05, -0.10, -0.15, -0.20, -0.25 selected = s.select(df) assert selected["contract"] == "SPX2" # delta=-0.15 def test_boundary_delta(self): s = NearestDelta(target_delta=-0.075) df = _make_candidates() # Closest to -0.075 is -0.05 (diff=0.025) or -0.10 (diff=0.025) selected = s.select(df) assert selected["contract"] in {"SPX0", "SPX1"} def test_missing_delta_column_fallback(self): s = NearestDelta(target_delta=-0.30) df = _make_candidates(with_delta=False) selected = s.select(df) # Falls back to iloc[0] assert selected["contract"] == "SPX0" def test_column_requirements(self): s = NearestDelta(delta_column="my_delta") assert "my_delta" in s.column_requirements def test_rust_config(self): s = NearestDelta(target_delta=-0.25, delta_column="delta") cfg = s.to_rust_config() assert cfg["target"] == -0.25 class TestMaxOpenInterest: def test_selects_highest_oi(self): s = MaxOpenInterest() df = _make_candidates() selected = s.select(df) assert selected["contract"] == "SPX4" # oi=500 def test_missing_oi_column_fallback(self): s = MaxOpenInterest() df = _make_candidates(with_oi=False) selected = s.select(df) assert selected["contract"] == "SPX0" # fallback def test_custom_oi_column(self): s = MaxOpenInterest(oi_column="my_oi") df = _make_candidates(with_oi=False) df["my_oi"] = [10, 50, 30, 20, 40] selected = s.select(df) assert selected["contract"] == "SPX1" # oi=50 # --------------------------------------------------------------------------- # Position Sizers — deep tests # --------------------------------------------------------------------------- class TestCapitalBasedSizer: def test_basic_sizing(self): s = CapitalBased() assert s.size(cost_per_contract=100, available_capital=1000, total_capital=10000) == 10 def test_fractional_truncated(self): s = CapitalBased() assert s.size(150, 1000, 10000) == 6 # 1000/150 = 6.66 → 6 def test_zero_cost(self): s = CapitalBased() assert s.size(0, 1000, 10000) == 0 def test_cost_exceeds_capital(self): s = CapitalBased() assert s.size(2000, 1000, 10000) == 0 def test_negative_cost_uses_abs(self): s = CapitalBased() assert s.size(-100, 1000, 10000) == 10 class TestFixedQuantitySizer: def test_within_budget(self): s = FixedQuantity(quantity=5) assert s.size(100, 1000, 10000) == 5 def test_exceeds_budget_scales_down(self): s = FixedQuantity(quantity=20) assert s.size(100, 1000, 10000) == 10 # 1000/100 = 10 def test_zero_cost_returns_fixed_qty(self): """Zero cost doesn't trigger the budget check, returns fixed qty.""" s = FixedQuantity(quantity=5) assert s.size(0, 1000, 10000) == 5 def test_one_contract(self): s = FixedQuantity(quantity=1) assert s.size(100, 1000, 10000) == 1 class TestFixedDollarSizer: def test_within_budget(self): s = FixedDollar(amount=500) assert s.size(100, 1000, 10000) == 5 def test_amount_exceeds_available(self): s = FixedDollar(amount=2000) assert s.size(100, 500, 10000) == 5 # uses min(2000, 500) def test_zero_cost(self): s = FixedDollar(amount=500) assert s.size(0, 1000, 10000) == 0 class TestPercentOfPortfolioSizer: def test_basic(self): s = PercentOfPortfolio(pct=0.01) # 0.01 * 10000 = 100; 100/50 = 2 assert s.size(50, 1000, 10000) == 2 def test_pct_exceeds_available(self): s = PercentOfPortfolio(pct=0.5) # 0.5 * 10000 = 5000, but available = 1000 → min(5000,1000) = 1000 assert s.size(100, 1000, 10000) == 10 def test_invalid_pct(self): with pytest.raises(AssertionError): PercentOfPortfolio(pct=0.0) with pytest.raises(AssertionError): PercentOfPortfolio(pct=1.5) def test_zero_cost(self): s = PercentOfPortfolio(pct=0.01) assert s.size(0, 1000, 10000) == 0 ================================================ FILE: tests/execution/test_execution_pbt.py ================================================ """Property-based tests for execution models — cost, fill, sizer, selector. Uses Hypothesis to fuzz all execution components with random inputs and verify mathematical invariants hold across the entire input space. """ import numpy as np import pandas as pd import pytest from hypothesis import given, settings, assume, HealthCheck from hypothesis import strategies as st from options_portfolio_backtester.execution.cost_model import ( NoCosts, PerContractCommission, TieredCommission, SpreadSlippage, ) from options_portfolio_backtester.execution.fill_model import ( MarketAtBidAsk, MidPrice, VolumeAwareFill, ) from options_portfolio_backtester.execution.signal_selector import ( FirstMatch, NearestDelta, MaxOpenInterest, ) from options_portfolio_backtester.execution.sizer import ( CapitalBased, FixedQuantity, FixedDollar, PercentOfPortfolio, ) from options_portfolio_backtester.core.types import Direction # --------------------------------------------------------------------------- # Hypothesis strategies # --------------------------------------------------------------------------- price = st.floats(min_value=0.01, max_value=10_000.0, allow_nan=False, allow_infinity=False) quantity_int = st.integers(min_value=0, max_value=100_000) signed_qty = st.integers(min_value=-100_000, max_value=100_000) spc = st.sampled_from([1, 10, 100, 1000]) rate = st.floats(min_value=0.001, max_value=10.0, allow_nan=False, allow_infinity=False) pct_01 = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False) capital = st.floats(min_value=1.0, max_value=1e9, allow_nan=False, allow_infinity=False) direction = st.sampled_from([Direction.BUY, Direction.SELL]) volume = st.integers(min_value=0, max_value=10_000) # --------------------------------------------------------------------------- # Cost models — property-based # --------------------------------------------------------------------------- class TestNoCostsPBT: @given(price, signed_qty, spc) @settings(max_examples=100) def test_always_zero(self, p, q, s): m = NoCosts() assert m.option_cost(p, q, s) == 0.0 assert m.stock_cost(p, q) == 0.0 class TestPerContractPBT: @given(rate, price, signed_qty, spc) @settings(max_examples=200) def test_non_negative(self, r, p, q, s): m = PerContractCommission(rate=r) assert m.option_cost(p, q, s) >= 0.0 @given(rate, price, quantity_int, spc) @settings(max_examples=200) def test_symmetric_buy_sell(self, r, p, q, s): """Commission is the same for +q and -q (direction-independent).""" m = PerContractCommission(rate=r) assert m.option_cost(p, q, s) == m.option_cost(p, -q, s) @given(rate, price, quantity_int, spc) @settings(max_examples=100) def test_linear_in_quantity(self, r, p, q, s): """Doubling quantity doubles cost.""" assume(q <= 50_000) m = PerContractCommission(rate=r) c1 = m.option_cost(p, q, s) c2 = m.option_cost(p, 2 * q, s) assert abs(c2 - 2 * c1) < 1e-8 @given(rate, price, quantity_int) @settings(max_examples=100) def test_independent_of_price_and_spc(self, r, p, q): """Per-contract commission doesn't depend on price or spc.""" m = PerContractCommission(rate=r) c1 = m.option_cost(p, q, 100) c2 = m.option_cost(p * 2, q, 1000) assert abs(c1 - c2) < 1e-8 @given(st.floats(min_value=0.001, max_value=1.0, allow_nan=False, allow_infinity=False), price, signed_qty) @settings(max_examples=100) def test_stock_cost_non_negative(self, sr, p, q): m = PerContractCommission(stock_rate=sr) assert m.stock_cost(p, q) >= 0.0 class TestTieredCommissionPBT: @given(price, quantity_int, spc) @settings(max_examples=200) def test_non_negative(self, p, q, s): m = TieredCommission() assert m.option_cost(p, q, s) >= 0.0 @given(price, quantity_int, spc) @settings(max_examples=200) def test_symmetric(self, p, q, s): m = TieredCommission() assert m.option_cost(p, q, s) == m.option_cost(p, -q, s) @given(st.integers(min_value=1, max_value=50_000)) @settings(max_examples=200) def test_monotone_in_quantity(self, q): """More contracts never costs less total.""" m = TieredCommission() c1 = m.option_cost(1.0, q, 100) c2 = m.option_cost(1.0, q + 1, 100) assert c2 >= c1 - 1e-10 @given(st.integers(min_value=1, max_value=100_000)) @settings(max_examples=100) def test_bounded_by_flat_rate(self, q): """Tiered cost <= flat rate at highest tier rate * quantity.""" m = TieredCommission() cost = m.option_cost(1.0, q, 100) max_rate = max(r for _, r in m.tiers) assert cost <= max_rate * q + 1e-8 @given(st.integers(min_value=1, max_value=100_000)) @settings(max_examples=100) def test_bounded_below_by_lowest_rate(self, q): """Tiered cost >= min_rate * quantity.""" m = TieredCommission() cost = m.option_cost(1.0, q, 100) min_rate = min(r for _, r in m.tiers) assert cost >= min_rate * q - 1e-8 @given(st.integers(min_value=1, max_value=100_000)) @settings(max_examples=100) def test_average_rate_decreasing(self, q): """Average cost per contract decreases (or stays same) with volume.""" assume(q < 100_000) m = TieredCommission() c1 = m.option_cost(1.0, q, 100) c2 = m.option_cost(1.0, q + 1000, 100) avg1 = c1 / q avg2 = c2 / (q + 1000) assert avg2 <= avg1 + 1e-10 class TestSpreadSlippagePBT: @given(pct_01, price, signed_qty, spc) @settings(max_examples=200) def test_option_cost_always_zero(self, pct, p, q, s): m = SpreadSlippage(pct=pct) assert m.option_cost(p, q, s) == 0.0 @given(pct_01, st.floats(min_value=0, max_value=100, allow_nan=False, allow_infinity=False), st.floats(min_value=0, max_value=100, allow_nan=False, allow_infinity=False), quantity_int, spc) @settings(max_examples=200) def test_slippage_non_negative(self, pct, bid, ask, q, s): m = SpreadSlippage(pct=pct) slip = m.slippage(bid, ask, q, s) assert slip >= -1e-10 @given(pct_01, price, quantity_int, spc) @settings(max_examples=100) def test_zero_spread_zero_slippage(self, pct, p, q, s): m = SpreadSlippage(pct=pct) assert m.slippage(p, p, q, s) == 0.0 @given(st.floats(min_value=0.01, max_value=0.99, allow_nan=False, allow_infinity=False), price, quantity_int, spc) @settings(max_examples=100) def test_slippage_monotone_in_pct(self, pct, p, q, s): """Higher pct means more slippage.""" assume(q > 0) bid = p * 0.95 ask = p * 1.05 m1 = SpreadSlippage(pct=pct) m2 = SpreadSlippage(pct=min(pct + 0.01, 1.0)) assert m2.slippage(bid, ask, q, s) >= m1.slippage(bid, ask, q, s) - 1e-10 @given(st.floats(min_value=0.01, max_value=0.99, allow_nan=False, allow_infinity=False), price, spc) @settings(max_examples=100) def test_slippage_linear_in_quantity(self, pct, p, s): """Double quantity → double slippage.""" m = SpreadSlippage(pct=pct) bid, ask = p * 0.95, p * 1.05 s1 = m.slippage(bid, ask, 10, s) s2 = m.slippage(bid, ask, 20, s) assert abs(s2 - 2 * s1) < 1e-6 # --------------------------------------------------------------------------- # Fill models — property-based # --------------------------------------------------------------------------- class TestMarketAtBidAskPBT: @given(st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False), st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False)) @settings(max_examples=200) def test_buy_at_ask_sell_at_bid(self, bid, ask): assume(bid <= ask) m = MarketAtBidAsk() row = pd.Series({"bid": bid, "ask": ask, "volume": 100}) assert m.get_fill_price(row, Direction.BUY) == ask assert m.get_fill_price(row, Direction.SELL) == bid @given(price) @settings(max_examples=100) def test_zero_spread_both_equal(self, p): m = MarketAtBidAsk() row = pd.Series({"bid": p, "ask": p, "volume": 100}) assert m.get_fill_price(row, Direction.BUY) == m.get_fill_price(row, Direction.SELL) @given(st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False), st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False)) @settings(max_examples=200) def test_buy_never_cheaper_than_sell(self, bid, ask): """Buy fill >= sell fill (you always pay more to buy).""" assume(bid <= ask) m = MarketAtBidAsk() row = pd.Series({"bid": bid, "ask": ask, "volume": 100}) assert m.get_fill_price(row, Direction.BUY) >= m.get_fill_price(row, Direction.SELL) class TestMidPricePBT: @given(st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False), st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False), direction) @settings(max_examples=200) def test_between_bid_and_ask(self, bid, ask, d): assume(bid <= ask) m = MidPrice() row = pd.Series({"bid": bid, "ask": ask, "volume": 100}) mid = m.get_fill_price(row, d) assert bid - 1e-10 <= mid <= ask + 1e-10 @given(st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False), st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False)) @settings(max_examples=200) def test_direction_independent(self, bid, ask): assume(bid <= ask) m = MidPrice() row = pd.Series({"bid": bid, "ask": ask, "volume": 100}) assert m.get_fill_price(row, Direction.BUY) == m.get_fill_price(row, Direction.SELL) @given(st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False), st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False)) @settings(max_examples=100) def test_midpoint_formula(self, bid, ask): assume(bid <= ask) m = MidPrice() row = pd.Series({"bid": bid, "ask": ask, "volume": 100}) expected = (bid + ask) / 2.0 assert abs(m.get_fill_price(row, Direction.BUY) - expected) < 1e-10 class TestVolumeAwareFillPBT: @given(st.floats(min_value=0.01, max_value=500, allow_nan=False, allow_infinity=False), st.floats(min_value=0.01, max_value=500, allow_nan=False, allow_infinity=False), volume, st.integers(min_value=1, max_value=10_000), direction) @settings(max_examples=300) def test_fill_between_mid_and_edge(self, bid, ask, vol, threshold, d): """Fill price is always between mid and bid/ask.""" assume(bid <= ask) m = VolumeAwareFill(full_volume_threshold=threshold) row = pd.Series({"bid": bid, "ask": ask, "volume": vol}) fill = m.get_fill_price(row, d) mid = (bid + ask) / 2.0 if d == Direction.BUY: assert mid - 1e-10 <= fill <= ask + 1e-10 else: assert bid - 1e-10 <= fill <= mid + 1e-10 @given(st.floats(min_value=0.01, max_value=500, allow_nan=False, allow_infinity=False), st.floats(min_value=0.01, max_value=500, allow_nan=False, allow_infinity=False), st.integers(min_value=1, max_value=1000)) @settings(max_examples=200) def test_zero_volume_fills_at_mid(self, bid, ask, threshold): assume(bid <= ask) m = VolumeAwareFill(full_volume_threshold=threshold) row = pd.Series({"bid": bid, "ask": ask, "volume": 0}) mid = (bid + ask) / 2.0 assert abs(m.get_fill_price(row, Direction.BUY) - mid) < 1e-10 assert abs(m.get_fill_price(row, Direction.SELL) - mid) < 1e-10 @given(st.floats(min_value=0.01, max_value=500, allow_nan=False, allow_infinity=False), st.floats(min_value=0.01, max_value=500, allow_nan=False, allow_infinity=False), st.integers(min_value=1, max_value=500), direction) @settings(max_examples=200) def test_higher_volume_moves_toward_edge(self, bid, ask, threshold, d): """More volume pushes fill toward bid/ask (worse for trader).""" assume(bid < ask) assume(threshold >= 4) m = VolumeAwareFill(full_volume_threshold=threshold) low_vol = pd.Series({"bid": bid, "ask": ask, "volume": threshold // 4}) high_vol = pd.Series({"bid": bid, "ask": ask, "volume": threshold // 2}) fill_low = m.get_fill_price(low_vol, d) fill_high = m.get_fill_price(high_vol, d) if d == Direction.BUY: assert fill_high >= fill_low - 1e-10 else: assert fill_high <= fill_low + 1e-10 @given(st.floats(min_value=0.01, max_value=500, allow_nan=False, allow_infinity=False), st.floats(min_value=0.01, max_value=500, allow_nan=False, allow_infinity=False), st.integers(min_value=1, max_value=500)) @settings(max_examples=200) def test_above_threshold_equals_market(self, bid, ask, threshold): assume(bid <= ask) m = VolumeAwareFill(full_volume_threshold=threshold) row = pd.Series({"bid": bid, "ask": ask, "volume": threshold * 2}) market = MarketAtBidAsk() assert abs(m.get_fill_price(row, Direction.BUY) - market.get_fill_price(row, Direction.BUY)) < 1e-10 assert abs(m.get_fill_price(row, Direction.SELL) - market.get_fill_price(row, Direction.SELL)) < 1e-10 # --------------------------------------------------------------------------- # Signal selectors — property-based # --------------------------------------------------------------------------- def _make_candidates(n, deltas=None, ois=None): data = { "contract": [f"SPX{i}" for i in range(n)], "strike": [100 + i * 5 for i in range(n)], "bid": [1.0 + i * 0.1 for i in range(n)], "ask": [1.5 + i * 0.1 for i in range(n)], } if deltas is not None: data["delta"] = deltas if ois is not None: data["openinterest"] = ois return pd.DataFrame(data) class TestFirstMatchPBT: @given(st.integers(min_value=1, max_value=50)) @settings(max_examples=50) def test_always_selects_row_from_dataframe(self, n): s = FirstMatch() df = _make_candidates(n) result = s.select(df) assert result["contract"] in df["contract"].values @given(st.integers(min_value=1, max_value=50)) @settings(max_examples=50) def test_always_selects_first(self, n): s = FirstMatch() df = _make_candidates(n) assert s.select(df)["contract"] == "SPX0" class TestNearestDeltaPBT: @given(st.floats(min_value=-1.0, max_value=0.0, allow_nan=False, allow_infinity=False), st.integers(min_value=2, max_value=30)) @settings(max_examples=200) def test_selected_is_closest_to_target(self, target, n): """Selected row has smallest |delta - target| of all rows.""" deltas = [-0.05 * (i + 1) for i in range(n)] s = NearestDelta(target_delta=target) df = _make_candidates(n, deltas=deltas) result = s.select(df) result_diff = abs(result["delta"] - target) min_diff = (df["delta"] - target).abs().min() assert result_diff <= min_diff + 1e-10 @given(st.floats(min_value=-1.0, max_value=0.0, allow_nan=False, allow_infinity=False), st.integers(min_value=1, max_value=20)) @settings(max_examples=100) def test_result_is_valid_row(self, target, n): deltas = [-0.05 * (i + 1) for i in range(n)] s = NearestDelta(target_delta=target) df = _make_candidates(n, deltas=deltas) result = s.select(df) assert result["contract"] in df["contract"].values @given(st.integers(min_value=1, max_value=20)) @settings(max_examples=50) def test_missing_delta_falls_back_to_first(self, n): s = NearestDelta(target_delta=-0.30) df = _make_candidates(n) # no delta column result = s.select(df) assert result["contract"] == "SPX0" class TestMaxOpenInterestPBT: @given(st.lists(st.integers(min_value=0, max_value=100_000), min_size=2, max_size=30)) @settings(max_examples=200) def test_selects_max_oi(self, ois): s = MaxOpenInterest() df = _make_candidates(len(ois), ois=ois) result = s.select(df) assert result["openinterest"] == max(ois) @given(st.integers(min_value=1, max_value=20)) @settings(max_examples=50) def test_missing_oi_falls_back(self, n): s = MaxOpenInterest() df = _make_candidates(n) # no OI column result = s.select(df) assert result["contract"] == "SPX0" @given(st.integers(min_value=2, max_value=20), st.integers(min_value=1, max_value=100_000)) @settings(max_examples=100) def test_uniform_oi_selects_some_row(self, n, oi): """When all OI equal, still returns a valid row.""" s = MaxOpenInterest() df = _make_candidates(n, ois=[oi] * n) result = s.select(df) assert result["contract"] in df["contract"].values # --------------------------------------------------------------------------- # Sizers — property-based # --------------------------------------------------------------------------- class TestCapitalBasedPBT: @given(st.floats(min_value=0.01, max_value=10_000, allow_nan=False, allow_infinity=False), capital, capital) @settings(max_examples=200) def test_non_negative_integer(self, cost, avail, total): s = CapitalBased() qty = s.size(cost, avail, total) assert isinstance(qty, int) assert qty >= 0 @given(st.floats(min_value=0.01, max_value=10_000, allow_nan=False, allow_infinity=False), capital, capital) @settings(max_examples=200) def test_total_cost_within_budget(self, cost, avail, total): """qty * cost <= available_capital.""" s = CapitalBased() qty = s.size(cost, avail, total) assert qty * cost <= avail + 1e-6 @given(capital, capital) @settings(max_examples=50) def test_zero_cost_returns_zero(self, avail, total): s = CapitalBased() assert s.size(0, avail, total) == 0 @given(st.floats(min_value=0.01, max_value=100, allow_nan=False, allow_infinity=False), st.floats(min_value=1.0, max_value=1e6, allow_nan=False, allow_infinity=False), capital) @settings(max_examples=200) def test_more_capital_more_contracts(self, cost, avail, total): """Doubling available capital never decreases contracts.""" assume(avail * 2 <= 1e9) s = CapitalBased() q1 = s.size(cost, avail, total) q2 = s.size(cost, avail * 2, total) assert q2 >= q1 @given(st.floats(min_value=0.01, max_value=10_000, allow_nan=False, allow_infinity=False), capital, capital) @settings(max_examples=100) def test_negative_cost_same_as_positive(self, cost, avail, total): s = CapitalBased() assert s.size(cost, avail, total) == s.size(-cost, avail, total) class TestFixedQuantityPBT: @given(st.integers(min_value=1, max_value=1000), st.floats(min_value=0.01, max_value=10_000, allow_nan=False, allow_infinity=False), capital, capital) @settings(max_examples=200) def test_never_exceeds_budget(self, qty, cost, avail, total): s = FixedQuantity(quantity=qty) result = s.size(cost, avail, total) assert result * cost <= avail + 1e-6 @given(st.integers(min_value=1, max_value=100), st.floats(min_value=0.01, max_value=100, allow_nan=False, allow_infinity=False), st.floats(min_value=1e6, max_value=1e9, allow_nan=False, allow_infinity=False), capital) @settings(max_examples=100) def test_large_budget_returns_fixed(self, qty, cost, avail, total): """With huge available capital, always returns the fixed quantity.""" s = FixedQuantity(quantity=qty) assert s.size(cost, avail, total) == qty @given(st.integers(min_value=1, max_value=1000), st.floats(min_value=0.01, max_value=10_000, allow_nan=False, allow_infinity=False), capital, capital) @settings(max_examples=100) def test_bounded_by_capital_based(self, qty, cost, avail, total): """FixedQuantity result <= CapitalBased result or == qty.""" s = FixedQuantity(quantity=qty) result = s.size(cost, avail, total) cap_result = CapitalBased().size(cost, avail, total) assert result <= max(qty, cap_result) + 1 class TestFixedDollarPBT: @given(st.floats(min_value=100, max_value=1e6, allow_nan=False, allow_infinity=False), st.floats(min_value=0.01, max_value=10_000, allow_nan=False, allow_infinity=False), capital, capital) @settings(max_examples=200) def test_non_negative_integer(self, amount, cost, avail, total): s = FixedDollar(amount=amount) qty = s.size(cost, avail, total) assert isinstance(qty, int) assert qty >= 0 @given(st.floats(min_value=100, max_value=1e6, allow_nan=False, allow_infinity=False), st.floats(min_value=0.01, max_value=10_000, allow_nan=False, allow_infinity=False), capital, capital) @settings(max_examples=200) def test_total_cost_within_min_amount_avail(self, amount, cost, avail, total): """qty * cost <= min(amount, avail).""" s = FixedDollar(amount=amount) qty = s.size(cost, avail, total) assert qty * cost <= min(amount, avail) + 1e-6 @given(st.floats(min_value=100, max_value=1e6, allow_nan=False, allow_infinity=False), capital, capital) @settings(max_examples=50) def test_zero_cost_returns_zero(self, amount, avail, total): s = FixedDollar(amount=amount) assert s.size(0, avail, total) == 0 class TestPercentOfPortfolioPBT: @given(st.floats(min_value=0.001, max_value=0.99, allow_nan=False, allow_infinity=False), st.floats(min_value=0.01, max_value=10_000, allow_nan=False, allow_infinity=False), capital, st.floats(min_value=1000, max_value=1e9, allow_nan=False, allow_infinity=False)) @settings(max_examples=200) def test_non_negative_integer(self, pct, cost, avail, total): s = PercentOfPortfolio(pct=pct) qty = s.size(cost, avail, total) assert isinstance(qty, int) assert qty >= 0 @given(st.floats(min_value=0.001, max_value=0.99, allow_nan=False, allow_infinity=False), st.floats(min_value=0.01, max_value=10_000, allow_nan=False, allow_infinity=False), capital, st.floats(min_value=1000, max_value=1e9, allow_nan=False, allow_infinity=False)) @settings(max_examples=200) def test_cost_bounded_by_pct_of_total(self, pct, cost, avail, total): """qty * cost <= pct * total (or available, whichever is smaller).""" s = PercentOfPortfolio(pct=pct) qty = s.size(cost, avail, total) assert qty * cost <= min(pct * total, avail) + 1e-6 @given(st.floats(min_value=0.001, max_value=0.99, allow_nan=False, allow_infinity=False), capital, st.floats(min_value=1000, max_value=1e9, allow_nan=False, allow_infinity=False)) @settings(max_examples=50) def test_zero_cost_returns_zero(self, pct, avail, total): s = PercentOfPortfolio(pct=pct) assert s.size(0, avail, total) == 0 @given(st.floats(min_value=0.01, max_value=0.49, allow_nan=False, allow_infinity=False), st.floats(min_value=0.01, max_value=100, allow_nan=False, allow_infinity=False), st.floats(min_value=1e6, max_value=1e9, allow_nan=False, allow_infinity=False), st.floats(min_value=1e6, max_value=1e9, allow_nan=False, allow_infinity=False)) @settings(max_examples=100) def test_higher_pct_more_contracts(self, pct, cost, avail, total): """Higher pct never gives fewer contracts.""" s1 = PercentOfPortfolio(pct=pct) s2 = PercentOfPortfolio(pct=min(pct + 0.01, 1.0)) assert s2.size(cost, avail, total) >= s1.size(cost, avail, total) ================================================ FILE: tests/execution/test_fill_model.py ================================================ """Tests for fill models.""" import pandas as pd from options_portfolio_backtester.core.types import Direction from options_portfolio_backtester.execution.fill_model import ( MarketAtBidAsk, MidPrice, VolumeAwareFill, ) def _make_row(bid: float = 1.00, ask: float = 1.10, volume: int = 100) -> pd.Series: return pd.Series({"bid": bid, "ask": ask, "volume": volume}) class TestMarketAtBidAsk: def test_buy_fills_at_ask(self): m = MarketAtBidAsk() assert m.get_fill_price(_make_row(), Direction.BUY) == 1.10 def test_sell_fills_at_bid(self): m = MarketAtBidAsk() assert m.get_fill_price(_make_row(), Direction.SELL) == 1.00 class TestMidPrice: def test_mid(self): m = MidPrice() assert m.get_fill_price(_make_row(), Direction.BUY) == 1.05 def test_mid_sell(self): m = MidPrice() assert m.get_fill_price(_make_row(), Direction.SELL) == 1.05 class TestVolumeAwareFill: def test_high_volume_fills_at_target(self): m = VolumeAwareFill(full_volume_threshold=100) assert m.get_fill_price(_make_row(volume=200), Direction.BUY) == 1.10 def test_zero_volume_fills_at_mid(self): m = VolumeAwareFill(full_volume_threshold=100) assert m.get_fill_price(_make_row(volume=0), Direction.BUY) == 1.05 def test_half_volume_interpolates(self): m = VolumeAwareFill(full_volume_threshold=100) price = m.get_fill_price(_make_row(volume=50), Direction.BUY) # mid=1.05, target=1.10, ratio=0.5 -> 1.05 + 0.5*0.05 = 1.075 assert abs(price - 1.075) < 1e-10 ================================================ FILE: tests/execution/test_rust_parity_execution.py ================================================ """Rust execution model tests: edge cases, invariants, PBT, integration. Covers: - Edge-case fuzzing (NaN, Inf, empty, zero, extreme values) - Property-based testing (Hypothesis) - Invariant tests (cost >= 0, fill between bid/ask, index in range, monotonicity) - Integration tests (Python classes delegating to Rust) """ import pandas as pd import pytest from hypothesis import given, settings from hypothesis import strategies as st from options_portfolio_backtester.core.types import Direction, Greeks from options_portfolio_backtester._ob_rust import ( rust_option_cost, rust_stock_cost, rust_fill_price, rust_nearest_delta_index, rust_max_value_index, rust_risk_check, ) # =================================================================== # EDGE-CASE / FUZZING TESTS # =================================================================== class TestCostModelEdgeCases: def test_per_contract_basic(self): assert rust_option_cost("PerContract", 0.65, 0.005, [], 10.0, 10.0, 100) == pytest.approx(6.5) def test_per_contract_negative_quantity(self): assert rust_option_cost("PerContract", 0.65, 0.005, [], 10.0, -10.0, 100) == pytest.approx(6.5) def test_per_contract_zero_quantity(self): assert rust_option_cost("PerContract", 0.65, 0.005, [], 10.0, 0.0, 100) == 0.0 def test_per_contract_stock(self): assert rust_stock_cost("PerContract", 0.65, 0.005, [], 150.0, 100.0) == pytest.approx(0.5) def test_zero_rate(self): assert rust_option_cost("PerContract", 0.0, 0.0, [], 10.0, 100.0, 100) == 0.0 assert rust_stock_cost("PerContract", 0.65, 0.0, [], 150.0, 100.0) == 0.0 def test_very_small_quantity(self): assert rust_option_cost("PerContract", 0.65, 0.005, [], 10.0, 0.001, 100) == pytest.approx(0.65 * 0.001) def test_tiered_within_first_tier(self): tiers = [(10_000, 0.65), (50_000, 0.50), (100_000, 0.25)] assert rust_option_cost("Tiered", 0.0, 0.005, tiers, 10.0, 100.0, 100) == pytest.approx(65.0) def test_tiered_spanning_tiers(self): tiers = [(10_000, 0.65), (50_000, 0.50), (100_000, 0.25)] expected = 10_000 * 0.65 + 5_000 * 0.50 assert rust_option_cost("Tiered", 0.0, 0.005, tiers, 10.0, 15_000.0, 100) == pytest.approx(expected) def test_tiered_beyond_all(self): tiers = [(10_000, 0.65), (50_000, 0.50), (100_000, 0.25)] expected = 10_000 * 0.65 + 40_000 * 0.50 + 50_000 * 0.25 + 20_000 * 0.25 assert rust_option_cost("Tiered", 0.0, 0.005, tiers, 10.0, 120_000.0, 100) == pytest.approx(expected) def test_tiered_exactly_at_boundary(self): tiers = [(10_000, 0.65), (50_000, 0.50), (100_000, 0.25)] assert rust_option_cost("Tiered", 0.0, 0.005, tiers, 10.0, 10_000.0, 100) == pytest.approx(6500.0) def test_tiered_negative_quantity(self): tiers = [(10_000, 0.65), (50_000, 0.50), (100_000, 0.25)] expected = 10_000 * 0.65 + 5_000 * 0.50 assert rust_option_cost("Tiered", 0.0, 0.005, tiers, 10.0, -15_000.0, 100) == pytest.approx(expected) def test_very_large_quantity(self): tiers = [(10_000, 0.65), (50_000, 0.50), (100_000, 0.25)] assert rust_option_cost("Tiered", 0.0, 0.005, tiers, 10.0, 1e9, 100) > 0 def test_invalid_model_type_raises(self): with pytest.raises(ValueError, match="Unknown cost model type"): rust_option_cost("Bogus", 0.65, 0.005, [], 10.0, 10.0, 100) class TestFillModelEdgeCases: def test_full_volume_buy(self): assert rust_fill_price("VolumeAware", 100, 9.0, 10.0, 100.0, True) == pytest.approx(10.0) def test_full_volume_sell(self): assert rust_fill_price("VolumeAware", 100, 9.0, 10.0, 200.0, False) == pytest.approx(9.0) def test_zero_volume(self): mid = (9.0 + 10.0) / 2.0 assert rust_fill_price("VolumeAware", 100, 9.0, 10.0, 0.0, True) == pytest.approx(mid) assert rust_fill_price("VolumeAware", 100, 9.0, 10.0, 0.0, False) == pytest.approx(mid) def test_half_volume(self): assert rust_fill_price("VolumeAware", 100, 9.0, 10.0, 50.0, True) == pytest.approx(9.75) assert rust_fill_price("VolumeAware", 100, 9.0, 10.0, 50.0, False) == pytest.approx(9.25) def test_missing_volume(self): assert rust_fill_price("VolumeAware", 100, 9.0, 10.0, None, True) == pytest.approx(10.0) def test_zero_bid_ask(self): assert rust_fill_price("VolumeAware", 100, 0.0, 0.0, 50.0, True) == 0.0 def test_bid_equals_ask(self): assert rust_fill_price("VolumeAware", 100, 5.0, 5.0, 50.0, True) == pytest.approx(5.0) def test_invalid_fill_type_raises(self): with pytest.raises(ValueError, match="Unknown fill model type"): rust_fill_price("Bogus", 100, 9.0, 10.0, 50.0, True) class TestSignalSelectorEdgeCases: def test_nearest_delta_exact(self): assert rust_nearest_delta_index([-0.20, -0.30, -0.45], -0.30) == 1 def test_nearest_delta_between(self): assert rust_nearest_delta_index([-0.20, -0.30, -0.45], -0.35) == 1 def test_empty_list(self): assert rust_nearest_delta_index([], -0.30) == 0 assert rust_max_value_index([]) == 0 def test_all_nan_nearest(self): assert rust_nearest_delta_index([float('nan'), float('nan')], -0.30) == 0 def test_all_nan_max(self): assert rust_max_value_index([float('nan'), float('nan')]) == 0 def test_single_element(self): assert rust_nearest_delta_index([0.5], 0.0) == 0 assert rust_max_value_index([42.0]) == 0 def test_max_value_basic(self): assert rust_max_value_index([500.0, 1200.0, 800.0]) == 1 def test_max_value_negative(self): assert rust_max_value_index([-10.0, -5.0, -20.0]) == 1 def test_max_value_ties_first_wins(self): assert rust_max_value_index([100.0, 100.0, 50.0]) == 0 def test_large_list(self): assert rust_max_value_index([float(v) for v in range(10_000)]) == 9_999 class TestRiskCheckEdgeCases: def test_max_delta_allows(self): assert rust_risk_check("MaxDelta", 100.0, [50, 0, 0, 0], [30, 0, 0, 0], 1e6, 1e6) is True def test_max_delta_rejects(self): assert rust_risk_check("MaxDelta", 100.0, [80, 0, 0, 0], [30, 0, 0, 0], 1e6, 1e6) is False def test_max_delta_exactly_at_limit(self): assert rust_risk_check("MaxDelta", 100.0, [50, 0, 0, 0], [50, 0, 0, 0], 1e6, 1e6) is True def test_max_delta_negative(self): assert rust_risk_check("MaxDelta", 100.0, [-80, 0, 0, 0], [-30, 0, 0, 0], 1e6, 1e6) is False def test_max_vega_allows(self): assert rust_risk_check("MaxVega", 50.0, [0, 0, 0, 20], [0, 0, 0, 10], 1e6, 1e6) is True def test_max_vega_rejects(self): assert rust_risk_check("MaxVega", 50.0, [0, 0, 0, 40], [0, 0, 0, 20], 1e6, 1e6) is False def test_max_drawdown_allows(self): g = [0, 0, 0, 0] assert rust_risk_check("MaxDrawdown", 0.20, g, g, 900_000, 1_000_000) is True def test_max_drawdown_rejects(self): g = [0, 0, 0, 0] assert rust_risk_check("MaxDrawdown", 0.20, g, g, 750_000, 1_000_000) is False def test_max_drawdown_zero_peak(self): g = [0, 0, 0, 0] assert rust_risk_check("MaxDrawdown", 0.20, g, g, 100, 0.0) is True def test_zero_greeks(self): g = [0, 0, 0, 0] assert rust_risk_check("MaxDelta", 100.0, g, g, 1e6, 1e6) is True assert rust_risk_check("MaxVega", 50.0, g, g, 1e6, 1e6) is True def test_negative_peak_value(self): g = [0, 0, 0, 0] assert rust_risk_check("MaxDrawdown", 0.20, g, g, 100, -1.0) is True def test_invalid_constraint_raises(self): with pytest.raises(ValueError, match="Unknown risk constraint type"): rust_risk_check("Bogus", 100.0, [0]*4, [0]*4, 1e6, 1e6) # =================================================================== # PROPERTY-BASED TESTS # =================================================================== safe_float = st.floats(min_value=0.01, max_value=1e6, allow_nan=False, allow_infinity=False) safe_qty = st.floats(min_value=1.0, max_value=1e6, allow_nan=False, allow_infinity=False) delta_float = st.floats(min_value=-1.0, max_value=1.0, allow_nan=False, allow_infinity=False) positive_float = st.floats(min_value=0.01, max_value=1e8, allow_nan=False, allow_infinity=False) class TestCostInvariants: @given(rate=safe_float, qty=safe_qty) @settings(max_examples=200) def test_cost_always_non_negative(self, rate, qty): assert rust_option_cost("PerContract", rate, 0.005, [], 10.0, qty, 100) >= 0.0 @given(qty=st.floats(min_value=0.0, max_value=200_000.0, allow_nan=False, allow_infinity=False)) @settings(max_examples=200) def test_tiered_cost_non_negative(self, qty): tiers = [(10_000, 0.65), (50_000, 0.50), (100_000, 0.25)] assert rust_option_cost("Tiered", 0.0, 0.005, tiers, 10.0, qty, 100) >= 0.0 @given(rate=safe_float, qty=safe_qty) @settings(max_examples=200) def test_sign_symmetry(self, rate, qty): pos = rust_option_cost("PerContract", rate, 0.005, [], 10.0, qty, 100) neg = rust_option_cost("PerContract", rate, 0.005, [], 10.0, -qty, 100) assert pos == pytest.approx(neg, rel=1e-12) class TestFillInvariants: @given( bid=st.floats(min_value=0.01, max_value=500.0, allow_nan=False, allow_infinity=False), spread=st.floats(min_value=0.0, max_value=50.0, allow_nan=False, allow_infinity=False), vol=st.floats(min_value=0.0, max_value=1000.0, allow_nan=False, allow_infinity=False), is_buy=st.booleans(), ) @settings(max_examples=200) def test_fill_between_bid_ask(self, bid, spread, vol, is_buy): ask = bid + spread price = rust_fill_price("VolumeAware", 100, bid, ask, vol, is_buy) assert price >= bid - 1e-10 assert price <= ask + 1e-10 class TestSelectorInvariants: @given( values=st.lists(delta_float, min_size=1, max_size=50), target=delta_float, ) @settings(max_examples=200) def test_index_in_range(self, values, target): idx = rust_nearest_delta_index(values, target) assert 0 <= idx < len(values) @given( values=st.lists( st.floats(min_value=-1e6, max_value=1e6, allow_nan=False, allow_infinity=False), min_size=1, max_size=50, ), ) @settings(max_examples=200) def test_max_index_in_range(self, values): idx = rust_max_value_index(values) assert 0 <= idx < len(values) class TestRiskInvariants: @given( limit_low=st.floats(min_value=0.0, max_value=500.0, allow_nan=False, allow_infinity=False), limit_high=st.floats(min_value=500.0, max_value=10000.0, allow_nan=False, allow_infinity=False), delta=st.floats(min_value=-1000.0, max_value=1000.0, allow_nan=False, allow_infinity=False), ) @settings(max_examples=200) def test_higher_limit_more_permissive(self, limit_low, limit_high, delta): cur = [delta, 0.0, 0.0, 0.0] prop = [0.0, 0.0, 0.0, 0.0] low_ok = rust_risk_check("MaxDelta", limit_low, cur, prop, 1e6, 1e6) high_ok = rust_risk_check("MaxDelta", limit_high, cur, prop, 1e6, 1e6) if low_ok: assert high_ok # =================================================================== # INTEGRATION: Python classes delegating to Rust # =================================================================== class TestPythonClassDelegation: def test_per_contract_via_class(self): from options_portfolio_backtester.execution.cost_model import PerContractCommission pc = PerContractCommission(0.65, 0.005) assert pc.option_cost(10.0, 10, 100) == pytest.approx(6.5) assert pc.stock_cost(150.0, 100) == pytest.approx(0.5) def test_tiered_via_class(self): from options_portfolio_backtester.execution.cost_model import TieredCommission tc = TieredCommission() assert tc.option_cost(10.0, 100, 100) == pytest.approx(65.0) assert tc.option_cost(10.0, 15_000, 100) == pytest.approx(10_000 * 0.65 + 5_000 * 0.50) def test_volume_aware_via_class(self): from options_portfolio_backtester.execution.fill_model import VolumeAwareFill vf = VolumeAwareFill(100) row = pd.Series({"bid": 9.0, "ask": 10.0, "volume": 50.0}) assert vf.get_fill_price(row, Direction.BUY) == pytest.approx(9.75) assert vf.get_fill_price(row, Direction.SELL) == pytest.approx(9.25) def test_nearest_delta_via_class(self): from options_portfolio_backtester.execution.signal_selector import NearestDelta df = pd.DataFrame({"delta": [-0.20, -0.30, -0.45], "price": [1.0, 2.0, 3.0]}) nd = NearestDelta(-0.30) assert nd.select(df)["delta"] == pytest.approx(-0.30) def test_max_oi_via_class(self): from options_portfolio_backtester.execution.signal_selector import MaxOpenInterest df = pd.DataFrame({"openinterest": [500, 1200, 800], "price": [1.0, 2.0, 3.0]}) assert MaxOpenInterest().select(df)["openinterest"] == 1200 def test_max_delta_via_class(self): from options_portfolio_backtester.portfolio.risk import MaxDelta md = MaxDelta(100.0) assert md.check(Greeks(50, 0, 0, 0), Greeks(30, 0, 0, 0), 1e6, 1e6) is True assert md.check(Greeks(80, 0, 0, 0), Greeks(30, 0, 0, 0), 1e6, 1e6) is False def test_max_vega_via_class(self): from options_portfolio_backtester.portfolio.risk import MaxVega mv = MaxVega(50.0) assert mv.check(Greeks(0, 0, 0, 20), Greeks(0, 0, 0, 10), 1e6, 1e6) is True assert mv.check(Greeks(0, 0, 0, 40), Greeks(0, 0, 0, 20), 1e6, 1e6) is False def test_max_drawdown_via_class(self): from options_portfolio_backtester.portfolio.risk import MaxDrawdown mdd = MaxDrawdown(0.20) assert mdd.check(Greeks(), Greeks(), 900_000, 1_000_000) is True assert mdd.check(Greeks(), Greeks(), 750_000, 1_000_000) is False assert mdd.check(Greeks(), Greeks(), 100, 0.0) is True ================================================ FILE: tests/execution/test_signal_selector.py ================================================ """Tests for signal selectors.""" import pandas as pd from options_portfolio_backtester.execution.signal_selector import ( FirstMatch, NearestDelta, MaxOpenInterest, ) def _make_candidates() -> pd.DataFrame: return pd.DataFrame({ "contract": ["A", "B", "C"], "delta": [-0.10, -0.30, -0.50], "openinterest": [100, 500, 200], "ask": [1.0, 2.0, 3.0], }) class TestFirstMatch: def test_picks_first(self): s = FirstMatch() result = s.select(_make_candidates()) assert result["contract"] == "A" class TestNearestDelta: def test_nearest_to_target(self): s = NearestDelta(target_delta=-0.30) result = s.select(_make_candidates()) assert result["contract"] == "B" def test_nearest_to_different_target(self): s = NearestDelta(target_delta=-0.45) result = s.select(_make_candidates()) assert result["contract"] == "C" def test_fallback_without_column(self): s = NearestDelta(target_delta=-0.30) df = _make_candidates().drop(columns=["delta"]) result = s.select(df) assert result["contract"] == "A" # falls back to first class TestMaxOpenInterest: def test_picks_max_oi(self): s = MaxOpenInterest() result = s.select(_make_candidates()) assert result["contract"] == "B" # OI=500 def test_fallback_without_column(self): s = MaxOpenInterest() df = _make_candidates().drop(columns=["openinterest"]) result = s.select(df) assert result["contract"] == "A" ================================================ FILE: tests/execution/test_sizer.py ================================================ """Tests for position sizing models.""" from options_portfolio_backtester.execution.sizer import ( CapitalBased, FixedQuantity, FixedDollar, PercentOfPortfolio, ) class TestCapitalBased: def test_basic_sizing(self): s = CapitalBased() assert s.size(250.0, 1000.0, 100_000.0) == 4 def test_zero_cost(self): s = CapitalBased() assert s.size(0.0, 1000.0, 100_000.0) == 0 def test_insufficient_capital(self): s = CapitalBased() assert s.size(500.0, 100.0, 100_000.0) == 0 class TestFixedQuantity: def test_fixed(self): s = FixedQuantity(quantity=5) assert s.size(100.0, 10_000.0, 100_000.0) == 5 def test_insufficient_capital_reduces(self): s = FixedQuantity(quantity=10) assert s.size(100.0, 500.0, 100_000.0) == 5 class TestFixedDollar: def test_fixed_amount(self): s = FixedDollar(amount=1000.0) assert s.size(250.0, 10_000.0, 100_000.0) == 4 def test_amount_capped_by_available(self): s = FixedDollar(amount=10_000.0) assert s.size(250.0, 500.0, 100_000.0) == 2 class TestPercentOfPortfolio: def test_one_percent(self): s = PercentOfPortfolio(pct=0.01) # 1% of 100k = 1000, 1000 // 250 = 4 assert s.size(250.0, 10_000.0, 100_000.0) == 4 def test_capped_by_available(self): s = PercentOfPortfolio(pct=0.10) # 10% of 100k = 10000, but only 500 available assert s.size(250.0, 500.0, 100_000.0) == 2 ================================================ FILE: tests/portfolio/__init__.py ================================================ ================================================ FILE: tests/portfolio/test_greeks_aggregation.py ================================================ """Tests for portfolio-level Greeks aggregation.""" from options_portfolio_backtester.core.types import ( Direction, OptionType, Order, Greeks, ) from options_portfolio_backtester.portfolio.position import ( OptionPosition, PositionLeg, ) from options_portfolio_backtester.portfolio.greeks import aggregate_greeks def _make_position(pid, direction=Direction.BUY, qty=10): pos = OptionPosition(position_id=pid, quantity=qty, entry_cost=100.0) pos.add_leg(PositionLeg( name="leg_1", contract_id=f"SPY_C_{pid}", underlying="SPY", expiration="2024-01-01", option_type=OptionType.CALL, strike=450.0, entry_price=5.0, direction=direction, order=Order.BTO if direction == Direction.BUY else Order.STO, )) return pos def test_aggregate_single_position(): pos = _make_position(1, Direction.BUY, qty=10) leg_greeks = {1: {"leg_1": Greeks(delta=0.5, gamma=0.05, theta=-0.1, vega=0.3)}} total = aggregate_greeks({1: pos}, leg_greeks) # BUY direction: sign=+1, qty=10 assert total.delta == 0.5 * 10 assert total.gamma == 0.05 * 10 assert total.theta == -0.1 * 10 assert total.vega == 0.3 * 10 def test_aggregate_sell_position(): pos = _make_position(1, Direction.SELL, qty=5) leg_greeks = {1: {"leg_1": Greeks(delta=0.5, gamma=0.05, theta=-0.1, vega=0.3)}} total = aggregate_greeks({1: pos}, leg_greeks) # SELL direction: sign=-1, qty=5 assert total.delta == 0.5 * (-1) * 5 assert total.vega == 0.3 * (-1) * 5 def test_aggregate_multiple_positions(): pos1 = _make_position(1, Direction.BUY, qty=10) pos2 = _make_position(2, Direction.SELL, qty=5) leg_greeks = { 1: {"leg_1": Greeks(delta=0.5, gamma=0.05, theta=-0.1, vega=0.3)}, 2: {"leg_1": Greeks(delta=0.4, gamma=0.04, theta=-0.08, vega=0.2)}, } total = aggregate_greeks({1: pos1, 2: pos2}, leg_greeks) expected_delta = 0.5 * 10 + 0.4 * (-1) * 5 assert abs(total.delta - expected_delta) < 1e-10 def test_aggregate_empty(): total = aggregate_greeks({}, {}) assert total.delta == 0.0 assert total.gamma == 0.0 assert total.theta == 0.0 assert total.vega == 0.0 def test_aggregate_missing_greeks_for_position(): pos = _make_position(1, Direction.BUY, qty=10) # No greeks provided for position 1 total = aggregate_greeks({1: pos}, {}) assert total.delta == 0.0 assert total.vega == 0.0 ================================================ FILE: tests/portfolio/test_portfolio.py ================================================ """Tests for Portfolio class.""" from options_portfolio_backtester.core.types import Direction, OptionType, Order, Greeks from options_portfolio_backtester.portfolio.portfolio import Portfolio, StockHolding from options_portfolio_backtester.portfolio.position import OptionPosition, PositionLeg def _make_portfolio() -> Portfolio: p = Portfolio(initial_cash=100_000.0) pos = OptionPosition(position_id=p.next_position_id(), quantity=10, entry_cost=-5000.0) pos.add_leg(PositionLeg( name="leg_1", contract_id="SPY_C_500", underlying="SPY", expiration="2024-01-19", option_type=OptionType.CALL, strike=500.0, entry_price=5.0, direction=Direction.BUY, order=Order.BTO, )) p.add_option_position(pos) p.set_stock_holding("SPY", 100, 480.0) return p class TestPortfolio: def test_initial_cash(self): p = Portfolio(initial_cash=50_000.0) assert p.cash == 50_000.0 def test_add_remove_position(self): p = Portfolio() pos = OptionPosition(position_id=0, quantity=1) p.add_option_position(pos) assert 0 in p.option_positions removed = p.remove_option_position(0) assert removed is pos assert 0 not in p.option_positions def test_remove_nonexistent(self): p = Portfolio() assert p.remove_option_position(999) is None def test_next_position_id_increments(self): p = Portfolio() assert p.next_position_id() == 0 assert p.next_position_id() == 1 assert p.next_position_id() == 2 def test_options_value(self): p = _make_portfolio() # BUY leg: +1 * 6.0 * 10 * 100 = 6000 val = p.options_value({0: {"leg_1": 6.0}}, 100) assert val == 6000.0 def test_stocks_value(self): p = _make_portfolio() val = p.stocks_value({"SPY": 490.0}) assert val == 100 * 490.0 def test_total_value(self): p = _make_portfolio() total = p.total_value( stock_prices={"SPY": 490.0}, option_prices={0: {"leg_1": 6.0}}, shares_per_contract=100, ) # cash=100000, stocks=49000, options=6000 assert total == 155_000.0 def test_clear_stock_holdings(self): p = _make_portfolio() p.clear_stock_holdings() assert len(p.stock_holdings) == 0 assert p.stocks_value({"SPY": 490.0}) == 0.0 def test_portfolio_greeks(self): p = _make_portfolio() greeks_map = {0: {"leg_1": Greeks(delta=0.5, gamma=0.01)}} result = p.portfolio_greeks(greeks_map) # BUY, qty=10: delta = 0.5 * 10 = 5.0 assert abs(result.delta - 5.0) < 1e-10 class TestPortfolioInvariant: """Test: cash + stocks + options == total on every operation.""" def test_invariant_after_stock_buy(self): p = Portfolio(initial_cash=100_000.0) price = 150.0 qty = 100 p.cash -= price * qty p.set_stock_holding("SPY", qty, price) total = p.total_value({"SPY": price}, {}, 100) assert abs(total - 100_000.0) < 1e-10 def test_invariant_after_option_buy(self): p = Portfolio(initial_cash=100_000.0) cost = 5.0 * 10 * 100 # 5000 p.cash -= cost pos = OptionPosition(position_id=0, quantity=10, entry_cost=-cost) pos.add_leg(PositionLeg( name="leg_1", contract_id="C1", underlying="SPY", expiration="2024-01-19", option_type=OptionType.CALL, strike=500.0, entry_price=5.0, direction=Direction.BUY, order=Order.BTO, )) p.add_option_position(pos) total = p.total_value({}, {0: {"leg_1": 5.0}}, 100) assert abs(total - 100_000.0) < 1e-10 ================================================ FILE: tests/portfolio/test_position.py ================================================ """Tests for option position and position leg.""" from options_portfolio_backtester.core.types import Direction, OptionType, Order, Greeks from options_portfolio_backtester.portfolio.position import PositionLeg, OptionPosition class TestPositionLeg: def test_exit_order_inverts(self): leg = PositionLeg( name="leg_1", contract_id="SPY_C", underlying="SPY", expiration="2024-01-19", option_type=OptionType.CALL, strike=500.0, entry_price=5.0, direction=Direction.BUY, order=Order.BTO, ) assert leg.exit_order == Order.STC def test_buy_leg_value(self): leg = PositionLeg( name="leg_1", contract_id="SPY_C", underlying="SPY", expiration="2024-01-19", option_type=OptionType.CALL, strike=500.0, entry_price=5.0, direction=Direction.BUY, order=Order.BTO, ) # BUY: +1 * 6.0 * 10 * 100 = 6000 assert leg.current_value(6.0, 10, 100) == 6000.0 def test_sell_leg_value(self): leg = PositionLeg( name="leg_1", contract_id="SPY_P", underlying="SPY", expiration="2024-01-19", option_type=OptionType.PUT, strike=400.0, entry_price=3.0, direction=Direction.SELL, order=Order.STO, ) # SELL: -1 * 4.0 * 10 * 100 = -4000 assert leg.current_value(4.0, 10, 100) == -4000.0 class TestOptionPosition: def _make_position(self) -> OptionPosition: pos = OptionPosition(position_id=0, quantity=5, entry_cost=-1500.0) pos.add_leg(PositionLeg( name="leg_1", contract_id="SPY_C_500", underlying="SPY", expiration="2024-01-19", option_type=OptionType.CALL, strike=500.0, entry_price=3.0, direction=Direction.BUY, order=Order.BTO, )) return pos def test_current_value(self): pos = self._make_position() # BUY leg: +1 * 4.0 * 5 * 100 = 2000 val = pos.current_value({"leg_1": 4.0}, 100) assert val == 2000.0 def test_current_value_missing_price(self): pos = self._make_position() # Missing price defaults to 0 assert pos.current_value({}, 100) == 0.0 def test_greeks(self): pos = self._make_position() leg_greeks = {"leg_1": Greeks(delta=0.5, gamma=0.01, theta=-0.02, vega=0.1)} result = pos.greeks(leg_greeks) # BUY direction, qty=5: delta = 0.5 * 5 = 2.5 assert abs(result.delta - 2.5) < 1e-10 assert abs(result.gamma - 0.05) < 1e-10 assert abs(result.theta - (-0.1)) < 1e-10 assert abs(result.vega - 0.5) < 1e-10 def test_multi_leg_greeks(self): pos = OptionPosition(position_id=1, quantity=10) pos.add_leg(PositionLeg( name="leg_1", contract_id="C1", underlying="SPY", expiration="2024-01-19", option_type=OptionType.CALL, strike=500.0, entry_price=3.0, direction=Direction.BUY, order=Order.BTO, )) pos.add_leg(PositionLeg( name="leg_2", contract_id="P1", underlying="SPY", expiration="2024-01-19", option_type=OptionType.PUT, strike=480.0, entry_price=2.0, direction=Direction.SELL, order=Order.STO, )) leg_greeks = { "leg_1": Greeks(delta=0.6, gamma=0.02, theta=-0.03, vega=0.15), "leg_2": Greeks(delta=-0.4, gamma=0.01, theta=-0.02, vega=0.10), } result = pos.greeks(leg_greeks) # leg_1 BUY: +1*10 * (0.6, 0.02, -0.03, 0.15) = (6, 0.2, -0.3, 1.5) # leg_2 SELL: -1*10 * (-0.4, 0.01, -0.02, 0.10) = (4, -0.1, 0.2, -1.0) # total: (10, 0.1, -0.1, 0.5) assert abs(result.delta - 10.0) < 1e-10 assert abs(result.gamma - 0.1) < 1e-10 assert abs(result.theta - (-0.1)) < 1e-10 assert abs(result.vega - 0.5) < 1e-10 ================================================ FILE: tests/portfolio/test_property_based.py ================================================ """Property-based tests for portfolio position and portfolio invariants.""" from hypothesis import given, settings, assume from hypothesis import strategies as st from options_portfolio_backtester.core.types import Direction, OptionType, Order from options_portfolio_backtester.portfolio.portfolio import Portfolio from options_portfolio_backtester.portfolio.position import PositionLeg, OptionPosition # --------------------------------------------------------------------------- # Strategies # --------------------------------------------------------------------------- price = st.floats(min_value=0.01, max_value=10000.0, allow_nan=False, allow_infinity=False) quantity_int = st.integers(min_value=0, max_value=10000) cash_amount = st.floats(min_value=0.0, max_value=1e8, allow_nan=False, allow_infinity=False) spc = st.sampled_from([1, 10, 100]) # --------------------------------------------------------------------------- # Position properties # --------------------------------------------------------------------------- class TestPositionProperties: @given(price, spc) @settings(max_examples=50) def test_zero_quantity_zero_value(self, p, shares_per_contract): """Position with quantity 0 has value 0.""" pos = OptionPosition(position_id=0, quantity=0) pos.add_leg(PositionLeg( name="leg_1", contract_id="C1", underlying="SPY", expiration="2024-01-19", option_type=OptionType.CALL, strike=500.0, entry_price=5.0, direction=Direction.BUY, order=Order.BTO, )) val = pos.current_value({"leg_1": p}, shares_per_contract) assert val == 0.0 @given(quantity_int, price, spc) @settings(max_examples=50) def test_buy_leg_value_formula(self, qty, p, shares_per_contract): """BUY leg value = +1 * price * quantity * shares_per_contract.""" pos = OptionPosition(position_id=0, quantity=qty) pos.add_leg(PositionLeg( name="leg_1", contract_id="C1", underlying="SPY", expiration="2024-01-19", option_type=OptionType.CALL, strike=500.0, entry_price=5.0, direction=Direction.BUY, order=Order.BTO, )) val = pos.current_value({"leg_1": p}, shares_per_contract) expected = p * qty * shares_per_contract assert abs(val - expected) < 1e-6 @given(quantity_int, price, spc) @settings(max_examples=50) def test_sell_leg_value_formula(self, qty, p, shares_per_contract): """SELL leg value = -1 * price * quantity * shares_per_contract.""" pos = OptionPosition(position_id=0, quantity=qty) pos.add_leg(PositionLeg( name="leg_1", contract_id="P1", underlying="SPY", expiration="2024-01-19", option_type=OptionType.PUT, strike=400.0, entry_price=3.0, direction=Direction.SELL, order=Order.STO, )) val = pos.current_value({"leg_1": p}, shares_per_contract) expected = -p * qty * shares_per_contract assert abs(val - expected) < 1e-6 # --------------------------------------------------------------------------- # Portfolio properties # --------------------------------------------------------------------------- class TestPortfolioProperties: @given(cash_amount, price, quantity_int) @settings(max_examples=50) def test_total_value_is_sum(self, cash, stock_price, qty): """Total value = cash + sum of stock values.""" p = Portfolio(initial_cash=cash) if qty > 0: p.set_stock_holding("SPY", qty, stock_price) total = p.total_value({"SPY": stock_price}, {}, 100) expected = cash + qty * stock_price assert abs(total - expected) < 1e-4 @given(cash_amount, quantity_int, price, spc) @settings(max_examples=50) def test_add_remove_position_roundtrip(self, cash, qty, p, shares_per_contract): """Adding then removing a position returns to original state.""" portfolio = Portfolio(initial_cash=cash) original_total = portfolio.total_value({}, {}, shares_per_contract) pos = OptionPosition(position_id=portfolio.next_position_id(), quantity=qty) pos.add_leg(PositionLeg( name="leg_1", contract_id="C1", underlying="SPY", expiration="2024-01-19", option_type=OptionType.CALL, strike=500.0, entry_price=5.0, direction=Direction.BUY, order=Order.BTO, )) portfolio.add_option_position(pos) portfolio.remove_option_position(pos.position_id) after_total = portfolio.total_value({}, {}, shares_per_contract) assert abs(after_total - original_total) < 1e-10 @given(cash_amount, price, quantity_int) @settings(max_examples=50) def test_portfolio_value_non_negative(self, cash, stock_price, qty): """Portfolio value >= 0 when all inputs are non-negative.""" p = Portfolio(initial_cash=cash) if qty > 0: p.set_stock_holding("SPY", qty, stock_price) total = p.total_value({"SPY": stock_price}, {}, 100) assert total >= -1e-10 ================================================ FILE: tests/portfolio/test_risk.py ================================================ """Tests for risk management.""" from options_portfolio_backtester.core.types import Greeks from options_portfolio_backtester.portfolio.risk import ( RiskManager, MaxDelta, MaxVega, MaxDrawdown, ) class TestMaxDelta: def test_within_limit(self): c = MaxDelta(limit=100.0) assert c.check(Greeks(delta=50.0), Greeks(delta=30.0), 1e6, 1e6) is True def test_exceeds_limit(self): c = MaxDelta(limit=100.0) assert c.check(Greeks(delta=80.0), Greeks(delta=30.0), 1e6, 1e6) is False def test_negative_delta(self): c = MaxDelta(limit=100.0) assert c.check(Greeks(delta=-80.0), Greeks(delta=-30.0), 1e6, 1e6) is False class TestMaxVega: def test_within_limit(self): c = MaxVega(limit=50.0) assert c.check(Greeks(vega=20.0), Greeks(vega=10.0), 1e6, 1e6) is True def test_exceeds_limit(self): c = MaxVega(limit=50.0) assert c.check(Greeks(vega=40.0), Greeks(vega=20.0), 1e6, 1e6) is False class TestMaxDrawdown: def test_no_drawdown(self): c = MaxDrawdown(max_dd_pct=0.20) assert c.check(Greeks(), Greeks(), 1e6, 1e6) is True def test_within_limit(self): c = MaxDrawdown(max_dd_pct=0.20) assert c.check(Greeks(), Greeks(), 900_000, 1_000_000) is True def test_exceeds_limit(self): c = MaxDrawdown(max_dd_pct=0.20) assert c.check(Greeks(), Greeks(), 790_000, 1_000_000) is False class TestRiskManager: def test_empty_allows(self): rm = RiskManager() allowed, reason = rm.is_allowed(Greeks(), Greeks(), 1e6, 1e6) assert allowed is True assert reason == "" def test_single_constraint_blocks(self): rm = RiskManager([MaxDelta(limit=10.0)]) allowed, reason = rm.is_allowed(Greeks(delta=8.0), Greeks(delta=5.0), 1e6, 1e6) assert allowed is False assert "MaxDelta" in reason def test_multiple_constraints_first_blocks(self): rm = RiskManager([MaxDelta(limit=100.0), MaxDrawdown(max_dd_pct=0.10)]) allowed, reason = rm.is_allowed(Greeks(delta=50.0), Greeks(delta=10.0), 850_000, 1_000_000) assert allowed is False assert "MaxDrawdown" in reason def test_add_constraint(self): rm = RiskManager() rm.add_constraint(MaxVega(limit=5.0)) allowed, _ = rm.is_allowed(Greeks(vega=3.0), Greeks(vega=3.0), 1e6, 1e6) assert allowed is False ================================================ FILE: tests/strategy/__init__.py ================================================ ================================================ FILE: tests/strategy/test_presets.py ================================================ """Tests for strategy preset constructors.""" import math from options_portfolio_backtester.core.types import Direction, OptionType from options_portfolio_backtester.data.schema import Schema from options_portfolio_backtester.strategy.presets import ( strangle, iron_condor, covered_call, cash_secured_put, collar, butterfly, ) def _options_schema(): s = Schema.options() s.update({ "contract": "optionroot", "date": "quotedate", "dte": "dte", "last": "last", "open_interest": "openinterest", "impliedvol": "impliedvol", "delta": "delta", "gamma": "gamma", "theta": "theta", "vega": "vega", }) return s class TestStrangleFunction: def test_creates_two_legs(self): s = strangle(_options_schema(), "SPY", Direction.SELL, dte_range=(30, 60), dte_exit=7) assert len(s.legs) == 2 def test_leg_types(self): s = strangle(_options_schema(), "SPY", Direction.BUY, dte_range=(30, 60), dte_exit=7) assert s.legs[0].type == OptionType.CALL assert s.legs[1].type == OptionType.PUT def test_leg_directions_match_input(self): s = strangle(_options_schema(), "SPY", Direction.SELL, dte_range=(30, 60), dte_exit=7) assert s.legs[0].direction == Direction.SELL assert s.legs[1].direction == Direction.SELL def test_exit_thresholds(self): s = strangle(_options_schema(), "SPY", Direction.SELL, dte_range=(30, 60), dte_exit=7, exit_thresholds=(0.5, 0.3)) assert s.exit_thresholds == (0.5, 0.3) def test_default_exit_thresholds_are_inf(self): s = strangle(_options_schema(), "SPY", Direction.SELL, dte_range=(30, 60), dte_exit=7) assert s.exit_thresholds == (math.inf, math.inf) class TestIronCondor: def test_creates_four_legs(self): s = iron_condor(_options_schema(), "SPY", dte_range=(30, 60), dte_exit=7) assert len(s.legs) == 4 def test_leg_directions(self): s = iron_condor(_options_schema(), "SPY", dte_range=(30, 60), dte_exit=7) # short call, long call, short put, long put assert s.legs[0].direction == Direction.SELL assert s.legs[1].direction == Direction.BUY assert s.legs[2].direction == Direction.SELL assert s.legs[3].direction == Direction.BUY def test_leg_types(self): s = iron_condor(_options_schema(), "SPY", dte_range=(30, 60), dte_exit=7) assert s.legs[0].type == OptionType.CALL assert s.legs[1].type == OptionType.CALL assert s.legs[2].type == OptionType.PUT assert s.legs[3].type == OptionType.PUT class TestCoveredCall: def test_creates_one_leg(self): s = covered_call(_options_schema(), "SPY", dte_range=(30, 60), dte_exit=7) assert len(s.legs) == 1 def test_leg_is_sell_call(self): s = covered_call(_options_schema(), "SPY", dte_range=(30, 60), dte_exit=7) assert s.legs[0].direction == Direction.SELL assert s.legs[0].type == OptionType.CALL class TestCashSecuredPut: def test_creates_one_leg(self): s = cash_secured_put(_options_schema(), "SPY", dte_range=(30, 60), dte_exit=7) assert len(s.legs) == 1 def test_leg_is_sell_put(self): s = cash_secured_put(_options_schema(), "SPY", dte_range=(30, 60), dte_exit=7) assert s.legs[0].direction == Direction.SELL assert s.legs[0].type == OptionType.PUT class TestCollar: def test_creates_two_legs(self): s = collar(_options_schema(), "SPY", dte_range=(30, 60), dte_exit=7) assert len(s.legs) == 2 def test_leg_types_and_directions(self): s = collar(_options_schema(), "SPY", dte_range=(30, 60), dte_exit=7) # short call + long put assert s.legs[0].direction == Direction.SELL assert s.legs[0].type == OptionType.CALL assert s.legs[1].direction == Direction.BUY assert s.legs[1].type == OptionType.PUT class TestButterfly: def test_creates_three_legs(self): s = butterfly(_options_schema(), "SPY", dte_range=(30, 60), dte_exit=7) assert len(s.legs) == 3 def test_default_type_is_call(self): s = butterfly(_options_schema(), "SPY", dte_range=(30, 60), dte_exit=7) for leg in s.legs: assert leg.type == OptionType.CALL def test_put_butterfly(self): s = butterfly(_options_schema(), "SPY", dte_range=(30, 60), dte_exit=7, option_type=OptionType.PUT) for leg in s.legs: assert leg.type == OptionType.PUT def test_directions(self): s = butterfly(_options_schema(), "SPY", dte_range=(30, 60), dte_exit=7) # buy lower, sell middle, buy upper assert s.legs[0].direction == Direction.BUY assert s.legs[1].direction == Direction.SELL assert s.legs[2].direction == Direction.BUY def test_entry_sort_on_wings(self): s = butterfly(_options_schema(), "SPY", dte_range=(30, 60), dte_exit=7) assert s.legs[0].entry_sort == ("strike", True) # ascending assert s.legs[2].entry_sort == ("strike", False) # descending ================================================ FILE: tests/strategy/test_strangle.py ================================================ """Tests for Strangle preset strategy.""" import pytest from options_portfolio_backtester.strategy.presets import Strangle from options_portfolio_backtester.data.schema import Schema from options_portfolio_backtester.core.types import OptionType as Type, Direction @pytest.fixture def schema(): s = Schema.options() s.update({'dte': 'dte'}) return s class TestStrangle: def test_long_strangle_creates_buy_call_and_buy_put(self, schema): s = Strangle(schema, "long", "SPX", (30, 60), 10) assert len(s.legs) == 2 assert s.legs[0].type == Type.CALL assert s.legs[0].direction == Direction.BUY assert s.legs[1].type == Type.PUT assert s.legs[1].direction == Direction.BUY def test_short_strangle_creates_sell_call_and_sell_put(self, schema): s = Strangle(schema, "short", "SPX", (30, 60), 10) assert len(s.legs) == 2 assert s.legs[0].type == Type.CALL assert s.legs[0].direction == Direction.SELL assert s.legs[1].type == Type.PUT assert s.legs[1].direction == Direction.SELL def test_invalid_name_asserts(self, schema): with pytest.raises(AssertionError): Strangle(schema, "invalid", "SPX", (30, 60), 10) def test_exit_thresholds_propagate(self, schema): s = Strangle(schema, "long", "SPX", (30, 60), 10, exit_thresholds=(0.5, 0.3)) assert s.exit_thresholds == (0.5, 0.3) def test_case_insensitive_name(self, schema): s = Strangle(schema, "Long", "SPX", (30, 60), 10) assert s.legs[0].direction == Direction.BUY s2 = Strangle(schema, "SHORT", "SPX", (30, 60), 10) assert s2.legs[0].direction == Direction.SELL ================================================ FILE: tests/strategy/test_strategy.py ================================================ """Tests for Strategy class: adding/removing legs, thresholds.""" import math import pytest import numpy as np import pandas as pd from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.data.schema import Schema from options_portfolio_backtester.core.types import OptionType as Type, Direction @pytest.fixture def schema(): return Schema.options() @pytest.fixture def strategy(schema): return Strategy(schema) @pytest.fixture def make_leg(schema): def _make(option_type=Type.CALL, direction=Direction.BUY): return StrategyLeg("leg", schema, option_type=option_type, direction=direction) return _make class TestAddLeg: def test_add_one_leg(self, strategy, make_leg): leg = make_leg() strategy.add_leg(leg) assert len(strategy.legs) == 1 assert strategy.legs[0].name == "leg_1" def test_add_two_legs(self, strategy, make_leg): strategy.add_leg(make_leg()) strategy.add_leg(make_leg(Type.PUT)) assert len(strategy.legs) == 2 assert strategy.legs[0].name == "leg_1" assert strategy.legs[1].name == "leg_2" def test_add_legs_batch(self, strategy, make_leg): legs = [make_leg(), make_leg(Type.PUT)] strategy.add_legs(legs) assert len(strategy.legs) == 2 def test_add_leg_schema_mismatch_asserts(self, strategy): other_schema = Schema.options() other_schema.update({"underlying": "different_col"}) leg = StrategyLeg("leg", other_schema) with pytest.raises(AssertionError): strategy.add_leg(leg) class TestRemoveLeg: def test_remove_leg(self, strategy, make_leg): strategy.add_legs([make_leg(), make_leg(Type.PUT)]) strategy.remove_leg(0) assert len(strategy.legs) == 1 def test_clear_legs(self, strategy, make_leg): strategy.add_legs([make_leg(), make_leg(Type.PUT)]) strategy.clear_legs() assert len(strategy.legs) == 0 class TestExitThresholds: def test_default_thresholds(self, strategy): assert strategy.exit_thresholds == (math.inf, math.inf) def test_set_valid_thresholds(self, strategy): strategy.add_exit_thresholds(0.5, 0.3) assert strategy.exit_thresholds == (0.5, 0.3) def test_negative_profit_asserts(self, strategy): with pytest.raises(AssertionError): strategy.add_exit_thresholds(-0.1, 0.3) def test_negative_loss_asserts(self, strategy): with pytest.raises(AssertionError): strategy.add_exit_thresholds(0.5, -0.1) class TestFilterThresholds: def test_within_bounds_no_exit(self, strategy): strategy.add_exit_thresholds(0.5, 0.5) entry_cost = pd.Series([100.0]) current_cost = pd.Series([-110.0]) result = strategy.filter_thresholds(entry_cost, current_cost) assert not result.any() def test_profit_exceeded(self, strategy): strategy.add_exit_thresholds(0.5, 0.5) entry_cost = pd.Series([100.0]) current_cost = pd.Series([-200.0]) result = strategy.filter_thresholds(entry_cost, current_cost) assert result.all() def test_loss_exceeded(self, strategy): strategy.add_exit_thresholds(0.5, 0.5) entry_cost = pd.Series([100.0]) current_cost = pd.Series([-10.0]) result = strategy.filter_thresholds(entry_cost, current_cost) assert result.all() ================================================ FILE: tests/strategy/test_strategy_deep.py ================================================ """Deep strategy & risk tests — presets, multi-leg construction, portfolio, positions, Greeks. Tests strategy construction edge cases, preset validation, risk constraint boundary conditions, and position/portfolio accounting. """ import math import os import numpy as np import pandas as pd import pytest from options_portfolio_backtester.core.types import ( Direction, OptionType, Order, Signal, Greeks, Fill, Stock, get_order, ) from options_portfolio_backtester.data.providers import HistoricalOptionsData from options_portfolio_backtester.data.schema import Schema, Field, Filter from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.strategy.presets import ( strangle, iron_condor, covered_call, cash_secured_put, collar, butterfly, Strangle, ) from options_portfolio_backtester.portfolio.risk import ( RiskManager, MaxDelta, MaxVega, MaxDrawdown, ) from options_portfolio_backtester.portfolio.portfolio import Portfolio, StockHolding from options_portfolio_backtester.portfolio.position import OptionPosition, PositionLeg from options_portfolio_backtester.portfolio.greeks import aggregate_greeks TEST_DIR = os.path.join(os.path.dirname(__file__), "..", "test_data") OPTIONS_FILE = os.path.join(TEST_DIR, "options_data.csv") @pytest.fixture def schema(): data = HistoricalOptionsData(OPTIONS_FILE) return data.schema # --------------------------------------------------------------------------- # Strategy preset construction # --------------------------------------------------------------------------- class TestStranglePreset: def test_has_two_legs(self, schema): s = strangle(schema, "SPX", Direction.BUY, (30, 60), 14) assert len(s.legs) == 2 def test_leg_types(self, schema): s = strangle(schema, "SPX", Direction.BUY, (30, 60), 14) types = {leg.type for leg in s.legs} assert types == {OptionType.CALL, OptionType.PUT} def test_leg_directions_long(self, schema): s = strangle(schema, "SPX", Direction.BUY, (30, 60), 14) for leg in s.legs: assert leg.direction == Direction.BUY def test_leg_directions_short(self, schema): s = strangle(schema, "SPX", Direction.SELL, (30, 60), 14) for leg in s.legs: assert leg.direction == Direction.SELL def test_exit_thresholds_applied(self, schema): s = strangle(schema, "SPX", Direction.BUY, (30, 60), 14, exit_thresholds=(2.0, 0.5)) assert s.exit_thresholds == (2.0, 0.5) def test_default_exit_thresholds(self, schema): s = strangle(schema, "SPX", Direction.BUY, (30, 60), 14) assert s.exit_thresholds == (float("inf"), float("inf")) class TestIronCondorPreset: def test_has_four_legs(self, schema): s = iron_condor(schema, "SPX", (30, 60), 14) assert len(s.legs) == 4 def test_two_sells_two_buys(self, schema): s = iron_condor(schema, "SPX", (30, 60), 14) sells = [l for l in s.legs if l.direction == Direction.SELL] buys = [l for l in s.legs if l.direction == Direction.BUY] assert len(sells) == 2 assert len(buys) == 2 def test_has_both_option_types(self, schema): s = iron_condor(schema, "SPX", (30, 60), 14) types = {l.type for l in s.legs} assert types == {OptionType.CALL, OptionType.PUT} class TestCoveredCallPreset: def test_one_sell_call_leg(self, schema): s = covered_call(schema, "SPX", (30, 60), 14) assert len(s.legs) == 1 assert s.legs[0].direction == Direction.SELL assert s.legs[0].type == OptionType.CALL def test_otm_pct_applied(self, schema): s1 = covered_call(schema, "SPX", (30, 60), 14, otm_pct=1.0) s2 = covered_call(schema, "SPX", (30, 60), 14, otm_pct=5.0) # Different OTM should produce different entry filters assert s1.legs[0].entry_filter.query != s2.legs[0].entry_filter.query class TestCashSecuredPutPreset: def test_one_sell_put_leg(self, schema): s = cash_secured_put(schema, "SPX", (30, 60), 14) assert len(s.legs) == 1 assert s.legs[0].direction == Direction.SELL assert s.legs[0].type == OptionType.PUT class TestCollarPreset: def test_two_legs(self, schema): s = collar(schema, "SPX", (30, 60), 14) assert len(s.legs) == 2 def test_short_call_long_put(self, schema): s = collar(schema, "SPX", (30, 60), 14) call_leg = [l for l in s.legs if l.type == OptionType.CALL][0] put_leg = [l for l in s.legs if l.type == OptionType.PUT][0] assert call_leg.direction == Direction.SELL assert put_leg.direction == Direction.BUY class TestButterflyPreset: def test_three_legs(self, schema): s = butterfly(schema, "SPX", (30, 60), 14) assert len(s.legs) == 3 def test_buy_sell_buy_pattern(self, schema): s = butterfly(schema, "SPX", (30, 60), 14) dirs = [l.direction for l in s.legs] assert dirs == [Direction.BUY, Direction.SELL, Direction.BUY] def test_call_butterfly(self, schema): s = butterfly(schema, "SPX", (30, 60), 14, option_type=OptionType.CALL) for leg in s.legs: assert leg.type == OptionType.CALL def test_put_butterfly(self, schema): s = butterfly(schema, "SPX", (30, 60), 14, option_type=OptionType.PUT) for leg in s.legs: assert leg.type == OptionType.PUT def test_lower_wing_has_asc_sort(self, schema): s = butterfly(schema, "SPX", (30, 60), 14) assert s.legs[0].entry_sort == ("strike", True) def test_upper_wing_has_desc_sort(self, schema): s = butterfly(schema, "SPX", (30, 60), 14) assert s.legs[2].entry_sort == ("strike", False) class TestStrangleClassBased: def test_long_strangle(self, schema): s = Strangle(schema, "long", "SPX", (30, 60), 14) assert len(s.legs) == 2 for leg in s.legs: assert leg.direction == Direction.BUY def test_short_strangle(self, schema): s = Strangle(schema, "short", "SPX", (30, 60), 14) for leg in s.legs: assert leg.direction == Direction.SELL def test_invalid_name_raises(self, schema): with pytest.raises(AssertionError): Strangle(schema, "neutral", "SPX", (30, 60), 14) # --------------------------------------------------------------------------- # Strategy operations # --------------------------------------------------------------------------- class TestStrategyOperations: def test_add_and_remove_leg(self, schema): s = Strategy(schema) leg = StrategyLeg("x", schema, option_type=OptionType.PUT, direction=Direction.BUY) leg.entry_filter = schema.underlying == "SPX" leg.exit_filter = schema.dte <= 30 s.add_leg(leg) assert len(s.legs) == 1 s.remove_leg(0) assert len(s.legs) == 0 def test_clear_legs(self, schema): s = strangle(schema, "SPX", Direction.BUY, (30, 60), 14) assert len(s.legs) == 2 s.clear_legs() assert len(s.legs) == 0 def test_exit_thresholds_validation(self, schema): s = Strategy(schema) with pytest.raises(AssertionError): s.add_exit_thresholds(profit_pct=-1.0) with pytest.raises(AssertionError): s.add_exit_thresholds(loss_pct=-0.5) def test_filter_thresholds_series(self, schema): s = Strategy(schema) s.add_exit_thresholds(profit_pct=0.5, loss_pct=0.3) entry = pd.Series([-100.0, -200.0, -50.0]) current = pd.Series([-50.0, -300.0, -25.0]) result = s.filter_thresholds(entry, current) assert isinstance(result, pd.Series) assert result.dtype == bool # --------------------------------------------------------------------------- # Strategy leg entry/exit filters # --------------------------------------------------------------------------- class TestStrategyLegFilters: def test_base_entry_filter_buy_requires_ask_gt_zero(self, schema): leg = StrategyLeg("x", schema, option_type=OptionType.PUT, direction=Direction.BUY) assert "ask > 0" in leg.entry_filter.query def test_base_entry_filter_sell_requires_bid_gt_zero(self, schema): leg = StrategyLeg("x", schema, option_type=OptionType.PUT, direction=Direction.SELL) assert "bid > 0" in leg.entry_filter.query def test_custom_entry_filter_combines_with_base(self, schema): leg = StrategyLeg("x", schema, option_type=OptionType.CALL, direction=Direction.BUY) leg.entry_filter = schema.dte >= 30 assert "ask > 0" in leg.entry_filter.query assert "dte >= 30" in leg.entry_filter.query def test_exit_filter_includes_type(self, schema): leg = StrategyLeg("x", schema, option_type=OptionType.PUT, direction=Direction.BUY) assert "put" in leg.exit_filter.query # --------------------------------------------------------------------------- # Risk constraints — boundary conditions # --------------------------------------------------------------------------- class TestMaxDeltaConstraint: def test_within_limit_allowed(self): c = MaxDelta(limit=100.0) assert c.check(Greeks(delta=50), Greeks(delta=30), 1e6, 1e6) is True def test_at_limit_allowed(self): c = MaxDelta(limit=100.0) assert c.check(Greeks(delta=50), Greeks(delta=50), 1e6, 1e6) is True def test_exceeds_limit_blocked(self): c = MaxDelta(limit=100.0) assert c.check(Greeks(delta=90), Greeks(delta=20), 1e6, 1e6) is False def test_negative_delta(self): c = MaxDelta(limit=50.0) # -30 + -30 = -60, abs = 60 > 50 assert c.check(Greeks(delta=-30), Greeks(delta=-30), 1e6, 1e6) is False def test_describe(self): c = MaxDelta(limit=50.0) assert "50.0" in c.describe() class TestMaxVegaConstraint: def test_within_limit(self): c = MaxVega(limit=100.0) assert c.check(Greeks(vega=40), Greeks(vega=40), 1e6, 1e6) is True def test_exceeds_limit(self): c = MaxVega(limit=50.0) assert c.check(Greeks(vega=30), Greeks(vega=30), 1e6, 1e6) is False class TestMaxDrawdownConstraint: def test_no_drawdown_allowed(self): c = MaxDrawdown(max_dd_pct=0.20) assert c.check(Greeks(), Greeks(), 1e6, 1e6) is True def test_at_drawdown_limit(self): c = MaxDrawdown(max_dd_pct=0.20) # dd = (1e6 - 800000) / 1e6 = 0.20 → NOT blocked (< not <=) assert c.check(Greeks(), Greeks(), 800_000, 1e6) is False def test_beyond_drawdown(self): c = MaxDrawdown(max_dd_pct=0.20) assert c.check(Greeks(), Greeks(), 700_000, 1e6) is False def test_peak_is_zero(self): c = MaxDrawdown(max_dd_pct=0.20) assert c.check(Greeks(), Greeks(), 100, 0) is True class TestRiskManagerComposite: def test_empty_constraints_allows_all(self): rm = RiskManager() ok, reason = rm.is_allowed(Greeks(), Greeks(), 1e6, 1e6) assert ok is True assert reason == "" def test_single_violation_blocks(self): rm = RiskManager([MaxDelta(limit=10)]) ok, reason = rm.is_allowed(Greeks(delta=50), Greeks(delta=50), 1e6, 1e6) assert ok is False assert "MaxDelta" in reason def test_first_failure_reported(self): rm = RiskManager([MaxDelta(limit=10), MaxVega(limit=10)]) ok, reason = rm.is_allowed( Greeks(delta=50, vega=50), Greeks(delta=50, vega=50), 1e6, 1e6 ) assert ok is False assert "MaxDelta" in reason # first constraint to fail def test_all_pass(self): rm = RiskManager([MaxDelta(limit=1000), MaxVega(limit=1000)]) ok, _ = rm.is_allowed(Greeks(delta=1, vega=1), Greeks(delta=1, vega=1), 1e6, 1e6) assert ok is True # --------------------------------------------------------------------------- # Greeks algebra # --------------------------------------------------------------------------- class TestGreeksAlgebra: def test_addition(self): g1 = Greeks(delta=1, gamma=2, theta=3, vega=4) g2 = Greeks(delta=10, gamma=20, theta=30, vega=40) result = g1 + g2 assert result.delta == 11 assert result.gamma == 22 assert result.theta == 33 assert result.vega == 44 def test_scalar_multiplication(self): g = Greeks(delta=1, gamma=2, theta=3, vega=4) result = g * 3 assert result.delta == 3 assert result.vega == 12 def test_rmul(self): g = Greeks(delta=1, gamma=2, theta=3, vega=4) result = 3 * g assert result == g * 3 def test_negation(self): g = Greeks(delta=10, gamma=5, theta=-3, vega=1) neg = -g assert neg.delta == -10 assert neg.theta == 3 def test_as_dict(self): g = Greeks(delta=1, gamma=2, theta=3, vega=4) d = g.as_dict assert d["delta"] == 1 assert len(d) == 4 # --------------------------------------------------------------------------- # Order mapping # --------------------------------------------------------------------------- class TestOrderMapping: def test_buy_entry_bto(self): assert get_order(Direction.BUY, Signal.ENTRY) == Order.BTO def test_buy_exit_stc(self): assert get_order(Direction.BUY, Signal.EXIT) == Order.STC def test_sell_entry_sto(self): assert get_order(Direction.SELL, Signal.ENTRY) == Order.STO def test_sell_exit_btc(self): assert get_order(Direction.SELL, Signal.EXIT) == Order.BTC def test_order_inversion(self): assert ~Order.BTO == Order.STC assert ~Order.STC == Order.BTO assert ~Order.STO == Order.BTC assert ~Order.BTC == Order.STO class TestDirectionInversion: def test_buy_inverts_to_sell(self): assert ~Direction.BUY == Direction.SELL def test_sell_inverts_to_buy(self): assert ~Direction.SELL == Direction.BUY class TestOptionTypeInversion: def test_call_inverts_to_put(self): assert ~OptionType.CALL == OptionType.PUT def test_put_inverts_to_call(self): assert ~OptionType.PUT == OptionType.CALL # --------------------------------------------------------------------------- # Fill dataclass # --------------------------------------------------------------------------- class TestFillNotional: def test_buy_fill_negative_notional(self): f = Fill(price=5.0, quantity=10, direction=Direction.BUY) # BUY → sign=-1, notional = -1 * 5 * 10 * 100 = -5000 assert f.notional == -5000.0 def test_sell_fill_positive_notional(self): f = Fill(price=5.0, quantity=10, direction=Direction.SELL) assert f.notional == 5000.0 def test_commission_deducted(self): f = Fill(price=5.0, quantity=10, direction=Direction.BUY, commission=50.0) assert f.notional == -5050.0 def test_slippage_deducted(self): f = Fill(price=5.0, quantity=10, direction=Direction.SELL, slippage=100.0) assert f.notional == 4900.0 # --------------------------------------------------------------------------- # Portfolio and Position # --------------------------------------------------------------------------- class TestPortfolio: def test_initial_cash(self): p = Portfolio(initial_cash=100_000) assert p.cash == 100_000 def test_add_remove_option_position(self): p = Portfolio() pos = OptionPosition(position_id=0, quantity=10) p.add_option_position(pos) assert 0 in p.option_positions removed = p.remove_option_position(0) assert removed is pos assert 0 not in p.option_positions def test_remove_nonexistent_returns_none(self): p = Portfolio() assert p.remove_option_position(999) is None def test_stock_holdings(self): p = Portfolio() p.set_stock_holding("AAPL", 100, 150.0) assert p.stock_holdings["AAPL"].quantity == 100 assert p.stocks_value({"AAPL": 160.0}) == 16_000.0 def test_clear_stock_holdings(self): p = Portfolio() p.set_stock_holding("AAPL", 100, 150.0) p.clear_stock_holdings() assert len(p.stock_holdings) == 0 def test_total_value(self): p = Portfolio(initial_cash=10_000) p.set_stock_holding("AAPL", 100, 150.0) total = p.total_value( stock_prices={"AAPL": 160.0}, option_prices={}, shares_per_contract=100, ) assert total == 10_000 + 16_000 def test_next_position_id_increments(self): p = Portfolio() assert p.next_position_id() == 0 assert p.next_position_id() == 1 assert p.next_position_id() == 2 class TestPositionLeg: def test_buy_leg_positive_value(self): leg = PositionLeg( name="leg_1", contract_id="SPX1", underlying="SPX", expiration=pd.Timestamp("2025-01-01"), option_type=OptionType.PUT, strike=100.0, entry_price=5.0, direction=Direction.BUY, order=Order.BTO, ) # BUY: value = +1 * current_price * qty * spc value = leg.current_value(current_price=6.0, quantity=10, shares_per_contract=100) assert value == 6000.0 def test_sell_leg_negative_value(self): leg = PositionLeg( name="leg_1", contract_id="SPX1", underlying="SPX", expiration=pd.Timestamp("2025-01-01"), option_type=OptionType.PUT, strike=100.0, entry_price=5.0, direction=Direction.SELL, order=Order.STO, ) value = leg.current_value(current_price=6.0, quantity=10, shares_per_contract=100) assert value == -6000.0 def test_exit_order(self): leg = PositionLeg( name="x", contract_id="X", underlying="SPX", expiration=pd.Timestamp("2025-01-01"), option_type=OptionType.CALL, strike=100.0, entry_price=5.0, direction=Direction.BUY, order=Order.BTO, ) assert leg.exit_order == Order.STC class TestOptionPosition: def test_multi_leg_value(self): pos = OptionPosition(position_id=0, quantity=10) pos.add_leg(PositionLeg( "call", "C1", "SPX", pd.Timestamp("2025-01-01"), OptionType.CALL, 100.0, 3.0, Direction.BUY, Order.BTO, )) pos.add_leg(PositionLeg( "put", "P1", "SPX", pd.Timestamp("2025-01-01"), OptionType.PUT, 100.0, 2.0, Direction.SELL, Order.STO, )) value = pos.current_value({"call": 4.0, "put": 3.0}, shares_per_contract=100) # call: +4*10*100=4000, put: -3*10*100=-3000 assert value == 1000.0 def test_greeks_aggregation(self): pos = OptionPosition(position_id=0, quantity=5) pos.add_leg(PositionLeg( "call", "C1", "SPX", pd.Timestamp("2025-01-01"), OptionType.CALL, 100.0, 3.0, Direction.BUY, Order.BTO, )) pos.add_leg(PositionLeg( "put", "P1", "SPX", pd.Timestamp("2025-01-01"), OptionType.PUT, 100.0, 2.0, Direction.SELL, Order.STO, )) greeks = pos.greeks({ "call": Greeks(delta=0.5, gamma=0.02, theta=-0.01, vega=0.1), "put": Greeks(delta=-0.3, gamma=0.01, theta=-0.02, vega=0.05), }) # call: BUY → sign=+1, qty=5: delta=0.5*5=2.5 # put: SELL → sign=-1, qty=5: delta=-0.3*(-1)*5=1.5 assert abs(greeks.delta - 4.0) < 1e-10 # --------------------------------------------------------------------------- # Portfolio-level Greeks aggregation # --------------------------------------------------------------------------- class TestAggregateGreeks: def test_empty_portfolio(self): g = aggregate_greeks({}, {}) assert g.delta == 0.0 def test_single_position(self): pos = OptionPosition(position_id=0, quantity=1) pos.add_leg(PositionLeg( "leg_1", "C1", "SPX", pd.Timestamp("2025-01-01"), OptionType.CALL, 100.0, 5.0, Direction.BUY, Order.BTO, )) greeks_map = {0: {"leg_1": Greeks(delta=0.5, gamma=0.02, theta=-0.01, vega=0.1)}} result = aggregate_greeks({0: pos}, greeks_map) assert abs(result.delta - 0.5) < 1e-10 def test_multiple_positions(self): p1 = OptionPosition(position_id=0, quantity=10) p1.add_leg(PositionLeg( "leg_1", "C1", "SPX", pd.Timestamp("2025-01-01"), OptionType.CALL, 100.0, 5.0, Direction.BUY, Order.BTO, )) p2 = OptionPosition(position_id=1, quantity=5) p2.add_leg(PositionLeg( "leg_1", "P1", "SPX", pd.Timestamp("2025-01-01"), OptionType.PUT, 100.0, 3.0, Direction.BUY, Order.BTO, )) greeks_map = { 0: {"leg_1": Greeks(delta=0.5)}, 1: {"leg_1": Greeks(delta=-0.3)}, } result = aggregate_greeks({0: p1, 1: p2}, greeks_map) # p1: BUY, qty=10, delta=0.5*1*10 = 5.0 # p2: BUY, qty=5, delta=-0.3*1*5 = -1.5 assert abs(result.delta - 3.5) < 1e-10 ================================================ FILE: tests/strategy/test_strategy_leg.py ================================================ """Tests for StrategyLeg: entry/exit filters, custom filters.""" import pandas as pd from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.data.schema import Schema from options_portfolio_backtester.core.types import OptionType as Type, Direction def make_options_df(): """Minimal options DataFrame for testing filters.""" return pd.DataFrame({ 'type': ['call', 'put', 'call', 'put'], 'ask': [1.5, 2.0, 0.0, 1.0], 'bid': [1.0, 1.5, 0.5, 0.0], }) class TestDefaultEntryFilter: def test_buy_call_filters_calls_with_positive_ask(self): schema = Schema.options() leg = StrategyLeg("leg_1", schema, option_type=Type.CALL, direction=Direction.BUY) df = make_options_df() result = df[leg.entry_filter(df)] # Should match rows where type=='call' AND ask > 0 => row 0 only (row 2 has ask=0) assert len(result) == 1 assert result.iloc[0]['ask'] == 1.5 def test_sell_put_filters_puts_with_positive_bid(self): schema = Schema.options() leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.SELL) df = make_options_df() result = df[leg.entry_filter(df)] # Should match rows where type=='put' AND bid > 0 => row 1 only (row 3 has bid=0) assert len(result) == 1 assert result.iloc[0]['bid'] == 1.5 def test_buy_put_filters_puts_with_positive_ask(self): schema = Schema.options() leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) df = make_options_df() result = df[leg.entry_filter(df)] # type=='put' AND ask > 0 => rows 1, 3 assert len(result) == 2 def test_sell_call_filters_calls_with_positive_bid(self): schema = Schema.options() leg = StrategyLeg("leg_1", schema, option_type=Type.CALL, direction=Direction.SELL) df = make_options_df() result = df[leg.entry_filter(df)] # type=='call' AND bid > 0 => rows 0, 2 assert len(result) == 2 class TestDefaultExitFilter: def test_exit_filter_matches_type(self): schema = Schema.options() leg = StrategyLeg("leg_1", schema, option_type=Type.CALL, direction=Direction.BUY) df = make_options_df() result = df[leg.exit_filter(df)] # Should match all calls (rows 0, 2) assert len(result) == 2 assert (result['type'] == 'call').all() class TestCustomFilter: def test_custom_entry_filter_is_anded_with_base(self): schema = Schema.options() leg = StrategyLeg("leg_1", schema, option_type=Type.CALL, direction=Direction.BUY) # Add a custom filter: ask > 1.0 leg.entry_filter = schema.ask > 1.0 df = make_options_df() result = df[leg.entry_filter(df)] # Base: type=='call' AND ask > 0. Custom AND'd: ask > 1.0 # Row 0: type=call, ask=1.5 => matches (1.5 > 0 and 1.5 > 1.0) # Row 2: type=call, ask=0.0 => no (ask not > 0) assert len(result) == 1 def test_custom_exit_filter_is_anded_with_base(self): schema = Schema.options() leg = StrategyLeg("leg_1", schema, option_type=Type.PUT, direction=Direction.BUY) # Custom exit: bid > 1.0 leg.exit_filter = schema.bid > 1.0 df = make_options_df() result = df[leg.exit_filter(df)] # Base: type=='put'. Custom AND'd: bid > 1.0 # Row 1: type=put, bid=1.5 => matches # Row 3: type=put, bid=0.0 => no assert len(result) == 1 ================================================ FILE: tests/strategy/test_strategy_pbt.py ================================================ """Property-based tests for strategies, risk constraints, and Greeks algebra. Fuzzes strategy preset construction, risk constraint monotonicity and composition, and Greeks vector-space properties with Hypothesis. """ import numpy as np import pandas as pd import pytest from hypothesis import given, settings, assume, HealthCheck from hypothesis import strategies as st from options_portfolio_backtester.core.types import ( Direction, OptionType, Order, Signal, Greeks, Fill, get_order, ) from options_portfolio_backtester.portfolio.risk import ( MaxDelta, MaxVega, MaxDrawdown, RiskConstraint, RiskManager, ) from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg from options_portfolio_backtester.strategy.presets import ( strangle, iron_condor, covered_call, cash_secured_put, collar, butterfly, Strangle, ) from options_portfolio_backtester.data.schema import Schema # --------------------------------------------------------------------------- # Hypothesis strategies # --------------------------------------------------------------------------- greek_float = st.floats(min_value=-1000, max_value=1000, allow_nan=False, allow_infinity=False) scalar = st.floats(min_value=-100, max_value=100, allow_nan=False, allow_infinity=False) positive_float = st.floats(min_value=0.01, max_value=1e6, allow_nan=False, allow_infinity=False) limit_float = st.floats(min_value=0.01, max_value=1e4, allow_nan=False, allow_infinity=False) dd_pct = st.floats(min_value=0.01, max_value=0.99, allow_nan=False, allow_infinity=False) direction = st.sampled_from([Direction.BUY, Direction.SELL]) option_type = st.sampled_from([OptionType.CALL, OptionType.PUT]) signal = st.sampled_from([Signal.ENTRY, Signal.EXIT]) dte_min = st.integers(min_value=1, max_value=90) dte_exit = st.integers(min_value=0, max_value=30) otm_pct = st.floats(min_value=0.0, max_value=20.0, allow_nan=False, allow_infinity=False) pct_tol = st.floats(min_value=0.1, max_value=10.0, allow_nan=False, allow_infinity=False) greeks_strat = st.builds( Greeks, delta=greek_float, gamma=greek_float, theta=greek_float, vega=greek_float, ) def _options_schema(): s = Schema.options() s.update({ "contract": "optionroot", "date": "quotedate", "dte": "dte", "last": "last", "open_interest": "openinterest", "impliedvol": "impliedvol", "delta": "delta", "gamma": "gamma", "theta": "theta", "vega": "vega", }) return s # --------------------------------------------------------------------------- # Greeks algebra — vector space properties # --------------------------------------------------------------------------- class TestGreeksAlgebraPBT: @given(greeks_strat, greeks_strat) @settings(max_examples=200) def test_addition_commutative(self, a, b): r1 = a + b r2 = b + a assert abs(r1.delta - r2.delta) < 1e-10 assert abs(r1.gamma - r2.gamma) < 1e-10 assert abs(r1.theta - r2.theta) < 1e-10 assert abs(r1.vega - r2.vega) < 1e-10 @given(greeks_strat, greeks_strat, greeks_strat) @settings(max_examples=200) def test_addition_associative(self, a, b, c): r1 = (a + b) + c r2 = a + (b + c) assert abs(r1.delta - r2.delta) < 1e-8 assert abs(r1.gamma - r2.gamma) < 1e-8 assert abs(r1.theta - r2.theta) < 1e-8 assert abs(r1.vega - r2.vega) < 1e-8 @given(greeks_strat) @settings(max_examples=100) def test_additive_identity(self, g): zero = Greeks() r = g + zero assert abs(r.delta - g.delta) < 1e-10 assert abs(r.gamma - g.gamma) < 1e-10 assert abs(r.theta - g.theta) < 1e-10 assert abs(r.vega - g.vega) < 1e-10 @given(greeks_strat) @settings(max_examples=100) def test_additive_inverse(self, g): """g + (-g) == zero.""" r = g + (-g) assert abs(r.delta) < 1e-10 assert abs(r.gamma) < 1e-10 assert abs(r.theta) < 1e-10 assert abs(r.vega) < 1e-10 @given(greeks_strat, scalar) @settings(max_examples=200) def test_scalar_mul_distributes_over_components(self, g, s): r = g * s assert abs(r.delta - g.delta * s) < 1e-6 assert abs(r.gamma - g.gamma * s) < 1e-6 assert abs(r.theta - g.theta * s) < 1e-6 assert abs(r.vega - g.vega * s) < 1e-6 @given(greeks_strat, scalar, scalar) @settings(max_examples=200) def test_scalar_mul_composition(self, g, a, b): """(a * b) * g == a * (b * g) within tolerance.""" assume(abs(a * b) < 1e6) r1 = g * (a * b) r2 = (g * b) * a assert abs(r1.delta - r2.delta) < max(abs(r1.delta), 1) * 1e-6 + 1e-10 assert abs(r1.vega - r2.vega) < max(abs(r1.vega), 1) * 1e-6 + 1e-10 @given(greeks_strat, greeks_strat, scalar) @settings(max_examples=200) def test_scalar_mul_distributes_over_addition(self, a, b, s): """s * (a + b) == s*a + s*b.""" r1 = (a + b) * s r2 = (a * s) + (b * s) assert abs(r1.delta - r2.delta) < max(abs(r1.delta), 1) * 1e-6 + 1e-10 assert abs(r1.gamma - r2.gamma) < max(abs(r1.gamma), 1) * 1e-6 + 1e-10 @given(greeks_strat, scalar) @settings(max_examples=100) def test_rmul_equals_mul(self, g, s): """s * g == g * s.""" r1 = s * g r2 = g * s assert abs(r1.delta - r2.delta) < 1e-10 assert abs(r1.vega - r2.vega) < 1e-10 @given(greeks_strat) @settings(max_examples=100) def test_mul_by_one_is_identity(self, g): r = g * 1.0 assert abs(r.delta - g.delta) < 1e-10 assert abs(r.vega - g.vega) < 1e-10 @given(greeks_strat) @settings(max_examples=100) def test_mul_by_zero_is_zero(self, g): r = g * 0.0 assert abs(r.delta) < 1e-10 assert abs(r.gamma) < 1e-10 assert abs(r.theta) < 1e-10 assert abs(r.vega) < 1e-10 @given(greeks_strat) @settings(max_examples=100) def test_neg_is_mul_minus_one(self, g): r1 = -g r2 = g * -1.0 assert abs(r1.delta - r2.delta) < 1e-10 assert abs(r1.vega - r2.vega) < 1e-10 @given(greeks_strat) @settings(max_examples=100) def test_as_dict_roundtrip(self, g): d = g.as_dict reconstructed = Greeks(**d) assert abs(reconstructed.delta - g.delta) < 1e-10 assert abs(reconstructed.gamma - g.gamma) < 1e-10 assert abs(reconstructed.theta - g.theta) < 1e-10 assert abs(reconstructed.vega - g.vega) < 1e-10 # --------------------------------------------------------------------------- # Direction / Order / OptionType inversions # --------------------------------------------------------------------------- class TestEnumInversionsPBT: @given(direction) @settings(max_examples=10) def test_direction_double_invert(self, d): assert ~~d == d @given(option_type) @settings(max_examples=10) def test_option_type_double_invert(self, ot): assert ~~ot == ot @given(direction, signal) @settings(max_examples=10) def test_order_double_invert(self, d, s): order = get_order(d, s) assert ~~order == order @given(direction) @settings(max_examples=10) def test_direction_invert_differs(self, d): assert ~d != d @given(option_type) @settings(max_examples=10) def test_option_type_invert_differs(self, ot): assert ~ot != ot # --------------------------------------------------------------------------- # Fill value properties # --------------------------------------------------------------------------- class TestFillPBT: @given(st.floats(min_value=0.01, max_value=10_000, allow_nan=False, allow_infinity=False), st.integers(min_value=1, max_value=10_000), direction, st.sampled_from([1, 10, 100, 1000])) @settings(max_examples=200) def test_buy_negative_sell_positive_notional(self, price, qty, d, spc): """BUY direction_sign = -1 (cash out), SELL direction_sign = +1 (cash in).""" f = Fill(price=price, quantity=qty, direction=d, shares_per_contract=spc) if d == Direction.BUY: assert f.direction_sign == -1 else: assert f.direction_sign == 1 @given(st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False), st.integers(min_value=1, max_value=1000), st.sampled_from([1, 10, 100])) @settings(max_examples=200) def test_sell_notional_exceeds_buy_notional(self, price, qty, spc): """With zero costs, sell notional > buy notional (opposite signs).""" buy = Fill(price=price, quantity=qty, direction=Direction.BUY, shares_per_contract=spc) sell = Fill(price=price, quantity=qty, direction=Direction.SELL, shares_per_contract=spc) assert sell.notional > buy.notional @given(st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False), st.integers(min_value=1, max_value=1000), direction, st.sampled_from([1, 10, 100]), st.floats(min_value=0.0, max_value=100, allow_nan=False, allow_infinity=False), st.floats(min_value=0.0, max_value=100, allow_nan=False, allow_infinity=False)) @settings(max_examples=200) def test_commission_slippage_reduce_notional(self, price, qty, d, spc, comm, slip): """Adding commission/slippage always reduces notional.""" f_clean = Fill(price=price, quantity=qty, direction=d, shares_per_contract=spc) f_costs = Fill(price=price, quantity=qty, direction=d, shares_per_contract=spc, commission=comm, slippage=slip) assert f_costs.notional <= f_clean.notional + 1e-10 @given(st.floats(min_value=0.01, max_value=1000, allow_nan=False, allow_infinity=False), st.integers(min_value=1, max_value=1000), direction, st.sampled_from([100])) @settings(max_examples=100) def test_notional_formula(self, price, qty, d, spc): """Verify notional = direction_sign * price * qty * spc - commission - slippage.""" comm, slip = 5.0, 2.0 f = Fill(price=price, quantity=qty, direction=d, shares_per_contract=spc, commission=comm, slippage=slip) expected = f.direction_sign * price * qty * spc - comm - slip assert abs(f.notional - expected) < 1e-6 # --------------------------------------------------------------------------- # Risk constraints — property-based # --------------------------------------------------------------------------- class TestMaxDeltaPBT: @given(limit_float, greeks_strat, greeks_strat, positive_float, positive_float) @settings(max_examples=200) def test_within_limit_passes(self, limit, current, proposed, pv, peak): """If |current.delta + proposed.delta| <= limit, check returns True.""" new_delta = current.delta + proposed.delta m = MaxDelta(limit=limit) result = m.check(current, proposed, pv, peak) if abs(new_delta) <= limit: assert result is True else: assert result is False @given(limit_float) @settings(max_examples=50) def test_zero_greeks_always_pass(self, limit): m = MaxDelta(limit=limit) zero = Greeks() assert m.check(zero, zero, 100.0, 100.0) is True @given(st.floats(min_value=0.01, max_value=100, allow_nan=False, allow_infinity=False), st.floats(min_value=0.01, max_value=100, allow_nan=False, allow_infinity=False)) @settings(max_examples=100) def test_tighter_limit_blocks_more(self, tight, loose): """A tighter limit blocks at least as many trades as a looser one.""" assume(tight < loose) m_tight = MaxDelta(limit=tight) m_loose = MaxDelta(limit=loose) g = Greeks(delta=tight + 0.01) proposed = Greeks() # If tight blocks it, check that it makes sense if not m_tight.check(g, proposed, 100, 100): # loose may or may not block — but tight blocks pass if m_loose.check(g, proposed, 100, 100): # loose passes → tight may or may not pass class TestMaxVegaPBT: @given(limit_float, greeks_strat, greeks_strat, positive_float, positive_float) @settings(max_examples=200) def test_correctness(self, limit, current, proposed, pv, peak): new_vega = current.vega + proposed.vega m = MaxVega(limit=limit) result = m.check(current, proposed, pv, peak) if abs(new_vega) <= limit: assert result is True else: assert result is False @given(limit_float) @settings(max_examples=50) def test_zero_greeks_always_pass(self, limit): m = MaxVega(limit=limit) zero = Greeks() assert m.check(zero, zero, 100.0, 100.0) is True class TestMaxDrawdownPBT: @given(dd_pct, st.floats(min_value=1.0, max_value=1e6, allow_nan=False, allow_infinity=False), st.floats(min_value=1.0, max_value=1e6, allow_nan=False, allow_infinity=False)) @settings(max_examples=200) def test_correctness(self, max_dd, pv, peak): assume(peak > 0) m = MaxDrawdown(max_dd_pct=max_dd) dd = (peak - pv) / peak result = m.check(Greeks(), Greeks(), pv, peak) if dd < max_dd: assert result is True else: assert result is False @given(dd_pct, positive_float) @settings(max_examples=100) def test_at_peak_always_passes(self, max_dd, peak): """No drawdown at peak → always allowed.""" m = MaxDrawdown(max_dd_pct=max_dd) assert m.check(Greeks(), Greeks(), peak, peak) is True @given(dd_pct) @settings(max_examples=50) def test_zero_peak_always_passes(self, max_dd): m = MaxDrawdown(max_dd_pct=max_dd) assert m.check(Greeks(), Greeks(), 50.0, 0.0) is True @given(st.floats(min_value=0.01, max_value=0.49, allow_nan=False, allow_infinity=False), st.floats(min_value=100, max_value=1e6, allow_nan=False, allow_infinity=False)) @settings(max_examples=100) def test_tighter_limit_blocks_more(self, tight, peak): """A tighter drawdown limit blocks at a higher portfolio value.""" loose = tight + 0.1 pv = peak * (1 - (tight + 0.05)) # dd = tight + 0.05 m_tight = MaxDrawdown(max_dd_pct=tight) m_loose = MaxDrawdown(max_dd_pct=loose) assert m_tight.check(Greeks(), Greeks(), pv, peak) is False assert m_loose.check(Greeks(), Greeks(), pv, peak) is True class TestRiskManagerPBT: @given(greeks_strat, greeks_strat, positive_float, positive_float) @settings(max_examples=100) def test_no_constraints_always_passes(self, current, proposed, pv, peak): rm = RiskManager() allowed, reason = rm.is_allowed(current, proposed, pv, peak) assert allowed is True assert reason == "" @given(limit_float, limit_float, greeks_strat, greeks_strat, positive_float, positive_float) @settings(max_examples=200) def test_composite_is_conjunction(self, delta_limit, vega_limit, current, proposed, pv, peak): """RiskManager passes iff ALL individual constraints pass.""" rm = RiskManager([MaxDelta(delta_limit), MaxVega(vega_limit)]) allowed, _ = rm.is_allowed(current, proposed, pv, peak) delta_ok = MaxDelta(delta_limit).check(current, proposed, pv, peak) vega_ok = MaxVega(vega_limit).check(current, proposed, pv, peak) assert allowed == (delta_ok and vega_ok) @given(limit_float, limit_float, dd_pct, greeks_strat, greeks_strat, st.floats(min_value=1.0, max_value=1e6, allow_nan=False, allow_infinity=False), st.floats(min_value=1.0, max_value=1e6, allow_nan=False, allow_infinity=False)) @settings(max_examples=200) def test_triple_constraint_conjunction(self, dl, vl, ddp, curr, prop, pv, peak): assume(peak > 0) rm = RiskManager([MaxDelta(dl), MaxVega(vl), MaxDrawdown(ddp)]) allowed, _ = rm.is_allowed(curr, prop, pv, peak) d_ok = MaxDelta(dl).check(curr, prop, pv, peak) v_ok = MaxVega(vl).check(curr, prop, pv, peak) dd_ok = MaxDrawdown(ddp).check(curr, prop, pv, peak) assert allowed == (d_ok and v_ok and dd_ok) @given(greeks_strat, greeks_strat, positive_float, positive_float) @settings(max_examples=50) def test_adding_constraints_only_restricts(self, current, proposed, pv, peak): """Adding a constraint can block but never unblock.""" rm1 = RiskManager([MaxDelta(50)]) rm2 = RiskManager([MaxDelta(50), MaxVega(50)]) a1, _ = rm1.is_allowed(current, proposed, pv, peak) a2, _ = rm2.is_allowed(current, proposed, pv, peak) if a2: assert a1 # if composite passes, each individual must pass too # --------------------------------------------------------------------------- # Strategy presets — property-based # --------------------------------------------------------------------------- class TestStrategyPresetsPBT: @given(direction, dte_min, dte_exit, otm_pct, pct_tol) @settings(max_examples=100, suppress_health_check=[HealthCheck.too_slow]) def test_strangle_always_two_legs(self, d, dte_lo, dte_ex, otm, tol): assume(dte_lo > dte_ex) schema = _options_schema() s = strangle(schema, "SPY", d, (dte_lo, dte_lo + 30), dte_ex, otm, tol) assert len(s.legs) == 2 types = {leg.type for leg in s.legs} assert types == {OptionType.CALL, OptionType.PUT} @given(dte_min, dte_exit) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_iron_condor_four_legs(self, dte_lo, dte_ex): assume(dte_lo > dte_ex) schema = _options_schema() s = iron_condor(schema, "SPY", (dte_lo, dte_lo + 30), dte_ex) assert len(s.legs) == 4 @given(dte_min, dte_exit, otm_pct, pct_tol) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_covered_call_one_leg(self, dte_lo, dte_ex, otm, tol): assume(dte_lo > dte_ex) schema = _options_schema() s = covered_call(schema, "SPY", (dte_lo, dte_lo + 30), dte_ex, otm, tol) assert len(s.legs) == 1 assert s.legs[0].type == OptionType.CALL assert s.legs[0].direction == Direction.SELL @given(dte_min, dte_exit, otm_pct, pct_tol) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_cash_secured_put_one_leg(self, dte_lo, dte_ex, otm, tol): assume(dte_lo > dte_ex) schema = _options_schema() s = cash_secured_put(schema, "SPY", (dte_lo, dte_lo + 30), dte_ex, otm, tol) assert len(s.legs) == 1 assert s.legs[0].type == OptionType.PUT assert s.legs[0].direction == Direction.SELL @given(dte_min, dte_exit, otm_pct, otm_pct, pct_tol) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_collar_two_legs(self, dte_lo, dte_ex, call_otm, put_otm, tol): assume(dte_lo > dte_ex) schema = _options_schema() s = collar(schema, "SPY", (dte_lo, dte_lo + 30), dte_ex, call_otm, put_otm, tol) assert len(s.legs) == 2 directions = {leg.direction for leg in s.legs} assert directions == {Direction.BUY, Direction.SELL} @given(dte_min, dte_exit, option_type) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_butterfly_three_legs(self, dte_lo, dte_ex, ot): assume(dte_lo > dte_ex) schema = _options_schema() s = butterfly(schema, "SPY", (dte_lo, dte_lo + 30), dte_ex, option_type=ot) assert len(s.legs) == 3 @given(st.sampled_from(["long", "short"]), dte_min, dte_exit, otm_pct, pct_tol) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_strangle_class_matches_function(self, name, dte_lo, dte_ex, otm, tol): """Strangle class produces same leg count and types as strangle() function.""" assume(dte_lo > dte_ex) schema = _options_schema() d = Direction.BUY if name == "long" else Direction.SELL s_func = strangle(schema, "SPY", d, (dte_lo, dte_lo + 30), dte_ex, otm, tol) s_cls = Strangle(schema, name, "SPY", (dte_lo, dte_lo + 30), dte_ex, otm, tol) assert len(s_func.legs) == len(s_cls.legs) for fl, cl in zip(s_func.legs, s_cls.legs): assert fl.type == cl.type assert fl.direction == cl.direction class TestStrategyOperationsPBT: @given(st.integers(min_value=1, max_value=8), direction, option_type) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_add_remove_legs_preserves_length(self, n, d, ot): """Adding n legs then removing last gives n-1 legs.""" schema = _options_schema() s = Strategy(schema) for i in range(n): leg = StrategyLeg(f"leg_{i}", schema, option_type=ot, direction=d) s.add_leg(leg) assert len(s.legs) == n s.legs.pop() assert len(s.legs) == n - 1 @given(st.floats(min_value=0.01, max_value=100, allow_nan=False, allow_infinity=False), st.floats(min_value=0.01, max_value=100, allow_nan=False, allow_infinity=False)) @settings(max_examples=50) def test_exit_thresholds_stored(self, profit, loss): schema = _options_schema() s = Strategy(schema) s.add_exit_thresholds(profit, loss) assert s.exit_thresholds == (profit, loss) @given(direction, option_type) @settings(max_examples=20) def test_clear_legs(self, d, ot): schema = _options_schema() s = Strategy(schema) leg = StrategyLeg("leg_1", schema, option_type=ot, direction=d) s.add_leg(leg) s.legs.clear() assert len(s.legs) == 0 ================================================ FILE: tests/test_cleanup.py ================================================ """Tests for post-refactor cleanup — verify dead code removed, imports correct.""" import importlib def test_top_level_exports_trimmed(): """__init__.py exports only core types, not pipeline bulk.""" import options_portfolio_backtester as pkg # Should be present assert hasattr(pkg, "BacktestEngine") assert hasattr(pkg, "Stock") assert hasattr(pkg, "Direction") assert hasattr(pkg, "BacktestStats") assert hasattr(pkg, "TradingClock") assert hasattr(pkg, "summary") # Pipeline algos should NOT be in top-level assert not hasattr(pkg, "AlgoPipelineBacktester") assert not hasattr(pkg, "RunMonthly") assert not hasattr(pkg, "SelectAll") assert not hasattr(pkg, "WeighEqually") assert not hasattr(pkg, "Rebalance") assert not hasattr(pkg, "EngineRunMonthly") assert not hasattr(pkg, "StrategyTreeNode") def test_pipeline_importable_from_submodule(): """Pipeline algos still importable from engine.pipeline.""" from options_portfolio_backtester.engine.pipeline import ( AlgoPipelineBacktester, RunMonthly, RunWeekly, RunDaily, SelectAll, SelectThese, WeighEqually, WeighInvVol, LimitWeights, Rebalance, ) assert callable(RunMonthly) assert callable(SelectAll) assert callable(WeighEqually) assert AlgoPipelineBacktester is not None def test_algo_adapters_importable_from_submodule(): """Algo adapters still importable from engine.algo_adapters.""" from options_portfolio_backtester.engine.algo_adapters import ( EngineAlgo, EngineStepDecision, EnginePipelineContext, EngineRunMonthly, BudgetPercent, RangeFilter, SelectByDelta, SelectByDTE, IVRankFilter, MaxGreekExposure, ExitOnThreshold, ) assert EngineAlgo is not None assert EngineRunMonthly is not None def test_strategy_tree_importable_from_submodule(): """Strategy tree still importable from engine.strategy_tree.""" from options_portfolio_backtester.engine.strategy_tree import ( StrategyTreeNode, StrategyTreeEngine, ) assert StrategyTreeNode is not None assert StrategyTreeEngine is not None def test_compat_directory_removed(): """compat/ directory should not exist.""" import pytest with pytest.raises(ModuleNotFoundError): importlib.import_module("options_portfolio_backtester.compat") with pytest.raises(ModuleNotFoundError): importlib.import_module("options_portfolio_backtester.compat.v0") def test_no_duplicate_import_in_engine(): """engine.py should have Stock in the first import block, no duplicate.""" from options_portfolio_backtester.engine.engine import BacktestEngine, Stock assert Stock is not None assert BacktestEngine is not None def test_safe_ratio_removed(): """_safe_ratio should not exist in stats module.""" from options_portfolio_backtester.analytics import stats assert not hasattr(stats, "_safe_ratio") def test_rust_extension_importable(): """Rust extension is importable.""" from options_portfolio_backtester import _ob_rust assert _ob_rust is not None ================================================ FILE: tests/test_data/ivy_5assets_data.csv ================================================ ,symbol,date,close,high,low,open,volume,adjClose,adjHigh,adjLow,adjOpen,adjVolume,divCash,splitFactor 1246,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 1247,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 1248,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 1249,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 1250,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 1251,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 1252,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 1253,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 1254,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 1255,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 1256,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 1257,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 1258,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 1259,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 1260,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 1261,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 1262,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 1263,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 1264,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 1265,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 1266,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 1267,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 1268,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 1269,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 1270,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 1271,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 1272,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 1273,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 1274,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 1275,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 1276,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 1277,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 1278,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 1279,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 1280,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 1281,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 1282,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 1283,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 1284,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 1285,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 1286,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 1287,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 1288,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 1289,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 1290,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 1291,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 1292,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 1293,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 1294,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 1295,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 1296,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 1297,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 1298,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 1299,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 1300,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 1301,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 1302,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 1303,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 1304,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 1305,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 1306,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 3762,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 3763,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 3764,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 3765,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 3766,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 3767,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 3768,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 3769,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 3770,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 3771,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 3772,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 3773,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 3774,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 3775,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 3776,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 3777,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 3778,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 3779,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 3780,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 3781,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 3782,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 3783,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 3784,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 3785,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 3786,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 3787,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 3788,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 3789,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 3790,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 3791,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 3792,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 3793,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 3794,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 3795,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 3796,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 3797,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 3798,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 3799,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 3800,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 3801,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 3802,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 3803,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 3804,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 3805,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 3806,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 3807,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 3808,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 3809,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 3810,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 3811,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 3812,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 3813,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 3814,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 3815,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 3816,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 3817,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 3818,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 3819,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 3820,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 3821,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 3822,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 6278,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 6279,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 6280,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 6281,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 6282,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 6283,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 6284,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 6285,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 6286,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 6287,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 6288,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 6289,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 6290,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 6291,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 6292,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 6293,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 6294,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 6295,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 6296,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 6297,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 6298,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 6299,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 6300,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 6301,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 6302,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 6303,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 6304,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 6305,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 6306,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 6307,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 6308,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 6309,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 6310,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 6311,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 6312,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 6313,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 6314,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 6315,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 6316,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 6317,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 6318,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 6319,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 6320,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 6321,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 6322,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 6323,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 6324,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 6325,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 6326,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 6327,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 6328,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 6329,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 6330,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 6331,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 6332,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 6333,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 6334,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 6335,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 6336,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 6337,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 6338,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 8794,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 8795,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 8796,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 8797,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 8798,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 8799,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 8800,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 8801,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 8802,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 8803,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 8804,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 8805,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 8806,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 8807,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 8808,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 8809,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 8810,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 8811,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 8812,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 8813,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 8814,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 8815,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 8816,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 8817,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 8818,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 8819,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 8820,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 8821,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 8822,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 8823,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 8824,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 8825,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 8826,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 8827,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 8828,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 8829,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 8830,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 8831,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 8832,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 8833,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 8834,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 8835,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 8836,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 8837,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 8838,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 8839,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 8840,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 8841,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 8842,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 8843,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 8844,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 8845,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 8846,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 8847,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 8848,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 8849,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 8850,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 8851,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 8852,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 8853,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 8854,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 11310,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 11311,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 11312,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 11313,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 11314,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 11315,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 11316,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 11317,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 11318,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 11319,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 11320,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 11321,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 11322,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 11323,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 11324,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 11325,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 11326,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 11327,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 11328,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 11329,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 11330,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 11331,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 11332,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 11333,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 11334,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 11335,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 11336,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 11337,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 11338,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 11339,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 11340,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 11341,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 11342,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 11343,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 11344,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 11345,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 11346,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 11347,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 11348,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 11349,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 11350,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 11351,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 11352,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 11353,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 11354,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 11355,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 11356,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 11357,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 11358,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 11359,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 11360,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 11361,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 11362,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 11363,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 11364,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 11365,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 11366,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 11367,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 11368,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 11369,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 11370,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 ================================================ FILE: tests/test_data/ivy_portfolio.csv ================================================ symbol,date,close,high,low,open,volume,adjClose,adjHigh,adjLow,adjOpen,adjVolume,divCash,splitFactor VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VTI,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 VEU,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 BND,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 VNQ,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 DBC,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 IEF,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 GLD,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 ================================================ FILE: tests/test_data/options_data.csv ================================================ ,underlying,underlying_last,optionroot,type,expiration,quotedate,strike,last,bid,ask,volume,openinterest,impliedvol,delta,gamma,theta,vega,optionalias,dte 5857589,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 5857590,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 5857662,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 5857663,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 5863595,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 5863596,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 5863668,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 5863669,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 5869635,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 5869636,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 5869708,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 5869709,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 5875706,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 5875707,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 5875779,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 5875780,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 5881779,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 5881780,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 5881852,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 5881853,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 5887606,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 5887607,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 5887679,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 5887680,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 5893338,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 5893339,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 5893411,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 5893412,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 5899200,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 5899201,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 5899273,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 5899274,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 5905573,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 5905574,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 5905646,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 5905647,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 5911946,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 5911947,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 5912019,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 5912020,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 5918049,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 5918050,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 5918122,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 5918123,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 5924152,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 5924153,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 5924225,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 5924226,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 5930478,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 5930479,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 5930551,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 5930552,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 5936502,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 5936503,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 5936575,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 5936576,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 5942261,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 5942262,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 5942334,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 5942335,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 5948020,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 5948021,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 5948093,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 5948094,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 5953858,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 5953859,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 5953931,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 5953932,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 5959920,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 5959921,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 5959993,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 5959994,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 5965982,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 5965983,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 5966055,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 5966056,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 5971781,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 5971782,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 5971854,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 5971855,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 5977580,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 5977581,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 5977653,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 5977654,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 5983381,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 5983382,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 5983454,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 5983455,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 5989264,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 5989265,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 5989337,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 5989338,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 5994999,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 5995000,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 5995072,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 5995073,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 6000439,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 6000440,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 6000512,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 6000513,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 6005879,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 6005880,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 6005952,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 6005953,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 6011565,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 6011566,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 6011638,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 6011639,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 6017292,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 6017293,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 6017365,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 6017366,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 6022775,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 6022776,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 6022849,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 6022850,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 6028274,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 6028275,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 6028348,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 6028349,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 6033773,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 6033774,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 6033847,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 6033848,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 6039416,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 6039417,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 6039490,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 6039491,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 6045059,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 6045060,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 6045133,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 6045134,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 6050522,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 6050523,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 6050596,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 6050597,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 6055987,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 6055988,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 6056061,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 6056062,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 6061452,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 6061453,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 6061526,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 6061527,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 6067047,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 6067048,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 6067121,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 6067122,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 6072642,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 6072643,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 6072716,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 6072717,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 6078019,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 6078020,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 6078093,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 6078094,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 6083396,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 6083397,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 6083470,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 6083471,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 6088966,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 6088967,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 6089040,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 6089041,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 6094842,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 6094843,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 6094916,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 6094917,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 6100724,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 6100725,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 6100798,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 6100799,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 6106418,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 6106419,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 6106492,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 6106493,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 6112210,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 6112211,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 6112284,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 6112285,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 6118088,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 6118089,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 6118162,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 6118163,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 6123726,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 6123727,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 6123800,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 6123801,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 6129119,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 6129120,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 6129193,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 6129194,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 6134666,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 6134667,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 6134740,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 6134741,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 6140215,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 6140216,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 6140289,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 6140290,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 6145942,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 6145943,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 6146016,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 6146017,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 6151669,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 6151670,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 6151743,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 6151744,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 6157215,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 6157216,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 6157289,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 6157290,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 6162761,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 6162762,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 6162835,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 6162836,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 6168588,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 6168589,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 6168662,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 6168663,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 6174739,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 6174740,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 6174813,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 6174814,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 6180890,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 6180891,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 6180964,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 6180965,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 6186831,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 6186832,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 6186905,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 6186906,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 6192772,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 6192773,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 6192846,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 6192847,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 6198721,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 6198722,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 6198795,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 6198796,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 6204764,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 6204765,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 6204838,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 6204839,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 ================================================ FILE: tests/test_data/test_data_options.csv ================================================ underlying,underlying_last, exchange,optionroot,optionext,type,expiration,quotedate,strike,last,bid,ask,volume,openinterest,impliedvol,delta,gamma,theta,vega,optionalias,dte SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 SPX,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 ================================================ FILE: tests/test_data/test_data_stocks.csv ================================================ symbol,date,close,high,low,open,volume,adjClose,adjHigh,adjLow,adjOpen,adjVolume,divCash,splitFactor VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 VOO,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 TUR,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 RSX,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWY,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 EWS,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 VTIP,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 TLT,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 BWX,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 PDBC,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 IAU,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 VNQI,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 ================================================ FILE: tests/test_deep_analytics_convexity.py ================================================ """Deep analytics, convexity, dispatch, and data provider tests. Covers: - BacktestStats edge cases (empty, single-row, lookback, period stats) - Convexity scoring internals (_find_target_put, _convexity_ratio) - Convexity backtest helpers (_monthly_rebalance_dates, _stock_price_on, _find_date_range) - Convexity allocator strategies - Dispatch layer - Data schema and filter DSL """ import math import os import numpy as np import pandas as pd import pytest from options_portfolio_backtester.analytics.stats import ( BacktestStats, PeriodStats, LookbackReturns, ) from options_portfolio_backtester.convexity.scoring import ( _find_target_put, _convexity_ratio, ) from options_portfolio_backtester.convexity.backtest import ( _monthly_rebalance_dates, _stock_price_on, _find_date_range, _close_position, run_unhedged, BacktestResult, ) from options_portfolio_backtester.convexity.config import ( BacktestConfig, InstrumentConfig, default_config, ) from options_portfolio_backtester.convexity.allocator import ( pick_cheapest, allocate_equal_weight, allocate_inverse_vol, ) from options_portfolio_backtester import _ob_rust from options_portfolio_backtester.data.schema import Schema, Field, Filter # =========================================================================== # BacktestStats edge cases # =========================================================================== def _make_balance(n_days=252, start_capital=100_000, daily_return=0.0004): """Create a synthetic balance DataFrame with realistic structure.""" dates = pd.bdate_range("2020-01-01", periods=n_days, freq="B") capital = [start_capital] for i in range(1, n_days): capital.append(capital[-1] * (1 + daily_return + np.random.normal(0, 0.005))) df = pd.DataFrame({"total capital": capital}, index=dates) df["% change"] = df["total capital"].pct_change() return df class TestStatsEmpty: def test_empty_balance(self): stats = BacktestStats.from_balance(pd.DataFrame()) assert stats.total_return == 0.0 assert stats.sharpe_ratio == 0.0 def test_single_row_balance(self): df = pd.DataFrame( {"total capital": [100_000], "% change": [np.nan]}, index=[pd.Timestamp("2020-01-01")], ) stats = BacktestStats.from_balance(df) assert stats.total_return == 0.0 class TestStatsAccuracy: def test_total_return_positive_market(self): np.random.seed(42) balance = _make_balance(n_days=252, daily_return=0.001) stats = BacktestStats.from_balance(balance) # With strong positive daily return and fixed seed, total return should be positive assert stats.total_return > 0 def test_total_return_negative_market(self): np.random.seed(42) balance = _make_balance(n_days=252, daily_return=-0.002) stats = BacktestStats.from_balance(balance) assert stats.total_return < 0 def test_sharpe_positive_for_good_market(self): np.random.seed(42) balance = _make_balance(n_days=252, daily_return=0.001) stats = BacktestStats.from_balance(balance) assert stats.sharpe_ratio > 0 def test_max_drawdown_non_negative(self): np.random.seed(42) balance = _make_balance(n_days=252) stats = BacktestStats.from_balance(balance) assert stats.max_drawdown >= 0 def test_volatility_non_negative(self): np.random.seed(42) balance = _make_balance() stats = BacktestStats.from_balance(balance) assert stats.volatility >= 0 def test_calmar_ratio_computed(self): np.random.seed(42) balance = _make_balance(n_days=252, daily_return=0.001) stats = BacktestStats.from_balance(balance) if stats.max_drawdown > 0: assert abs(stats.calmar_ratio - stats.annualized_return / stats.max_drawdown) < 0.01 class TestPeriodStatsDetail: def test_daily_stats_populated(self): np.random.seed(42) balance = _make_balance(n_days=252) stats = BacktestStats.from_balance(balance) assert stats.daily.vol > 0 assert stats.daily.best > stats.daily.worst def test_monthly_stats_populated(self): np.random.seed(42) balance = _make_balance(n_days=504) # ~2 years stats = BacktestStats.from_balance(balance) assert stats.monthly.vol > 0 def test_yearly_stats_with_enough_data(self): np.random.seed(42) balance = _make_balance(n_days=756) # ~3 years stats = BacktestStats.from_balance(balance) assert stats.yearly.mean != 0.0 or stats.yearly.vol != 0.0 def test_skew_kurtosis_need_8_samples(self): # Only 5 daily returns → skew/kurtosis should be 0 balance = _make_balance(n_days=6) stats = BacktestStats.from_balance(balance) assert stats.daily.skew == 0.0 assert stats.daily.kurtosis == 0.0 class TestLookbackReturns: def test_lookback_mtd(self): balance = _make_balance(n_days=252) stats = BacktestStats.from_balance(balance) assert stats.lookback.mtd is not None def test_lookback_ytd(self): balance = _make_balance(n_days=252) stats = BacktestStats.from_balance(balance) assert stats.lookback.ytd is not None def test_lookback_one_year(self): balance = _make_balance(n_days=300) stats = BacktestStats.from_balance(balance) assert stats.lookback.one_year is not None def test_lookback_short_data_still_computes(self): """Even short data computes lookback by finding closest available date.""" np.random.seed(42) balance = _make_balance(n_days=30) stats = BacktestStats.from_balance(balance) # With 30 days of data, the 10yr lookback uses the earliest available date # so it returns the total return (not None) assert stats.lookback.mtd is not None class TestTradeStats: def test_with_pnls(self): balance = _make_balance(n_days=252) pnls = np.array([100, -50, 200, -30, 150, -80, 50]) stats = BacktestStats.from_balance(balance, trade_pnls=pnls) assert stats.total_trades == 7 assert stats.wins == 4 assert stats.losses == 3 assert stats.win_pct > 0 assert stats.profit_factor > 0 assert stats.largest_win == 200 assert stats.largest_loss == -80 assert stats.avg_trade > 0 def test_all_winners(self): balance = _make_balance(n_days=252) pnls = np.array([10, 20, 30]) stats = BacktestStats.from_balance(balance, trade_pnls=pnls) assert stats.wins == 3 assert stats.losses == 0 assert stats.profit_factor == float("inf") def test_all_losers(self): balance = _make_balance(n_days=252) pnls = np.array([-10, -20, -30]) stats = BacktestStats.from_balance(balance, trade_pnls=pnls) assert stats.wins == 0 assert stats.losses == 3 def test_no_pnls(self): balance = _make_balance(n_days=252) stats = BacktestStats.from_balance(balance, trade_pnls=None) assert stats.total_trades == 0 class TestBalanceRange: def test_slice_by_date(self): balance = _make_balance(n_days=252) stats = BacktestStats.from_balance_range( balance, start="2020-03-01", end="2020-06-30" ) assert stats.total_return != 0.0 or len(balance) < 10 def test_empty_slice(self): balance = _make_balance(n_days=10) stats = BacktestStats.from_balance_range(balance, start="2025-01-01") assert stats.total_return == 0.0 class TestToDataframe: def test_output_is_dataframe(self): balance = _make_balance() stats = BacktestStats.from_balance(balance) df = stats.to_dataframe() assert isinstance(df, pd.DataFrame) assert "Total return" in df.index def test_summary_string(self): balance = _make_balance() stats = BacktestStats.from_balance(balance) s = stats.summary() assert "Total Return" in s assert "Sharpe" in s class TestTurnoverAndHerfindahl: def test_turnover_with_stocks(self): dates = pd.bdate_range("2020-01-01", periods=10) df = pd.DataFrame({ "total capital": np.linspace(100_000, 110_000, 10), "AAPL": np.linspace(50_000, 55_000, 10), "AAPL qty": [100] * 10, "GOOG": np.linspace(50_000, 55_000, 10), "GOOG qty": [50] * 10, }, index=dates) df["% change"] = df["total capital"].pct_change() stats = BacktestStats.from_balance(df) assert stats.turnover >= 0 def test_turnover_no_stocks(self): dates = pd.bdate_range("2020-01-01", periods=5) df = pd.DataFrame({"total capital": [100_000] * 5}, index=dates) df["% change"] = df["total capital"].pct_change() stats = BacktestStats.from_balance(df) assert stats.turnover == 0.0 def test_herfindahl_single_stock(self): dates = pd.bdate_range("2020-01-01", periods=5) df = pd.DataFrame({ "total capital": [100_000] * 5, "AAPL": [100_000] * 5, "AAPL qty": [100] * 5, }, index=dates) df["% change"] = df["total capital"].pct_change() stats = BacktestStats.from_balance(df) assert abs(stats.herfindahl - 1.0) < 0.01 def test_herfindahl_two_equal_stocks(self): dates = pd.bdate_range("2020-01-01", periods=5) df = pd.DataFrame({ "total capital": [100_000] * 5, "AAPL": [50_000] * 5, "AAPL qty": [100] * 5, "GOOG": [50_000] * 5, "GOOG qty": [50] * 5, }, index=dates) df["% change"] = df["total capital"].pct_change() stats = BacktestStats.from_balance(df) assert abs(stats.herfindahl - 0.5) < 0.01 # =========================================================================== # Sharpe / Sortino helpers # =========================================================================== class TestSharpeViaStats: def test_positive_returns(self): dates = pd.bdate_range("2020-01-01", periods=251) rets = [0.01, 0.02, 0.015, 0.01, 0.005] * 50 capital = [100_000.0] for r in rets: capital.append(capital[-1] * (1 + r)) df = pd.DataFrame({"total capital": capital}, index=dates) df["% change"] = df["total capital"].pct_change() stats = BacktestStats.from_balance(df) assert stats.sharpe_ratio > 0 def test_single_value_finite(self): dates = pd.bdate_range("2020-01-01", periods=2) df = pd.DataFrame({"total capital": [100_000, 101_000]}, index=dates) df["% change"] = df["total capital"].pct_change() stats = BacktestStats.from_balance(df) assert np.isfinite(stats.sharpe_ratio) class TestSortinoViaStats: def test_no_downside_returns_zero(self): rets = [0.01, 0.02, 0.03] * 10 dates = pd.bdate_range("2020-01-01", periods=len(rets) + 1) capital = [100_000.0] for r in rets: capital.append(capital[-1] * (1 + r)) df = pd.DataFrame({"total capital": capital}, index=dates) df["% change"] = df["total capital"].pct_change() stats = BacktestStats.from_balance(df) assert stats.sortino_ratio == 0.0 def test_with_downside(self): rets = [0.01, -0.02, 0.015, -0.01, 0.005] * 50 dates = pd.bdate_range("2020-01-01", periods=len(rets) + 1) capital = [100_000.0] for r in rets: capital.append(capital[-1] * (1 + r)) df = pd.DataFrame({"total capital": capital}, index=dates) df["% change"] = df["total capital"].pct_change() stats = BacktestStats.from_balance(df) assert isinstance(stats.sortino_ratio, float) # =========================================================================== # Convexity scoring internals # =========================================================================== class TestFindTargetPut: def test_exact_match(self): deltas = np.array([-0.05, -0.10, -0.15, -0.20, -0.25]) dtes = np.array([30, 30, 30, 30, 30], dtype=np.int32) asks = np.array([1.0, 1.5, 2.0, 2.5, 3.0]) idx = _find_target_put(deltas, dtes, asks, -0.15, 14, 60) assert idx == 2 def test_closest_match(self): deltas = np.array([-0.05, -0.10, -0.20, -0.25]) dtes = np.array([30, 30, 30, 30], dtype=np.int32) asks = np.array([1.0, 1.5, 2.5, 3.0]) idx = _find_target_put(deltas, dtes, asks, -0.15, 14, 60) # -0.10 and -0.20 are equidistant; should pick one assert idx in {1, 2} def test_dte_filter_excludes_short(self): deltas = np.array([-0.15]) dtes = np.array([10], dtype=np.int32) asks = np.array([1.0]) idx = _find_target_put(deltas, dtes, asks, -0.15, 14, 60) assert idx is None def test_dte_filter_excludes_long(self): deltas = np.array([-0.15]) dtes = np.array([90], dtype=np.int32) asks = np.array([1.0]) idx = _find_target_put(deltas, dtes, asks, -0.15, 14, 60) assert idx is None def test_zero_ask_excluded(self): deltas = np.array([-0.15]) dtes = np.array([30], dtype=np.int32) asks = np.array([0.0]) assert _find_target_put(deltas, dtes, asks, -0.15, 14, 60) is None def test_nan_delta_excluded(self): deltas = np.array([np.nan, -0.20]) dtes = np.array([30, 30], dtype=np.int32) asks = np.array([1.0, 2.0]) idx = _find_target_put(deltas, dtes, asks, -0.15, 14, 60) assert idx == 1 def test_all_excluded(self): deltas = np.array([np.nan]) dtes = np.array([5], dtype=np.int32) asks = np.array([0.0]) assert _find_target_put(deltas, dtes, asks, -0.15, 14, 60) is None def test_empty_arrays(self): idx = _find_target_put( np.array([]), np.array([], dtype=np.int32), np.array([]), -0.15, 14, 60, ) assert idx is None class TestConvexityRatio: def test_basic_computation(self): # strike=100, underlying=110, ask=2, tail_drop=0.20 # tail_price = 110 * 0.8 = 88 # tail_payoff = max(100 - 88, 0) * 100 = 1200 # annual_cost = 2 * 100 * 12 = 2400 # ratio = 1200 / 2400 = 0.5 ratio, payoff, cost = _convexity_ratio(100, 110, 2, 0.20) assert abs(ratio - 0.5) < 0.001 assert abs(payoff - 1200.0) < 0.01 assert abs(cost - 2400.0) < 0.01 def test_otm_put_zero_payoff(self): # strike=80, underlying=110, tail_price=88 → payoff=max(80-88,0)=0 ratio, payoff, cost = _convexity_ratio(80, 110, 2, 0.20) assert ratio == 0.0 assert payoff == 0.0 def test_zero_ask(self): ratio, _, cost = _convexity_ratio(100, 110, 0, 0.20) assert ratio == 0.0 assert cost == 0.0 def test_deep_itm(self): ratio, payoff, cost = _convexity_ratio(200, 110, 1, 0.20) # tail_price=88, payoff=(200-88)*100=11200 assert payoff == 11200.0 assert ratio > 0 # =========================================================================== # Convexity backtest helpers # =========================================================================== class TestMonthlyRebalanceDates: def test_basic(self): dates = pd.DatetimeIndex(["2020-01-02", "2020-01-03", "2020-02-03", "2020-02-04"]) indices = _monthly_rebalance_dates(dates) assert indices == [0, 2] def test_single_month(self): dates = pd.DatetimeIndex(["2020-01-02", "2020-01-03", "2020-01-06"]) indices = _monthly_rebalance_dates(dates) assert indices == [0] def test_empty(self): assert _monthly_rebalance_dates(pd.DatetimeIndex([])) == [] class TestStockPriceOn: def test_exact_match(self): dates_ns = np.array([100, 200, 300], dtype=np.int64) prices = np.array([10.0, 20.0, 30.0]) assert _stock_price_on(dates_ns, prices, 200) == 20.0 def test_between_dates(self): dates_ns = np.array([100, 300], dtype=np.int64) prices = np.array([10.0, 30.0]) # 200 is between 100 and 300, should return price at 100 assert _stock_price_on(dates_ns, prices, 200) == 10.0 def test_before_first_date(self): dates_ns = np.array([100, 200], dtype=np.int64) prices = np.array([10.0, 20.0]) assert _stock_price_on(dates_ns, prices, 50) is None class TestFindDateRange: def test_exact_match(self): dates_ns = np.array([100, 100, 100, 200, 200, 300], dtype=np.int64) start, end = _find_date_range(dates_ns, 100) assert start == 0 assert end == 3 def test_no_match(self): dates_ns = np.array([100, 200, 300], dtype=np.int64) start, end = _find_date_range(dates_ns, 150) assert start == end # =========================================================================== # Convexity config # =========================================================================== class TestConvexityConfig: def test_instrument_config_defaults(self): ic = InstrumentConfig(symbol="SPY", options_file="x", stocks_file="y") assert ic.target_delta == -0.10 assert ic.dte_min == 14 assert ic.dte_max == 60 assert ic.tail_drop == 0.20 def test_backtest_config_defaults(self): bc = BacktestConfig() assert bc.initial_capital == 1_000_000.0 assert bc.budget_pct == 0.005 def test_default_config(self): cfg = default_config() assert len(cfg.instruments) == 1 assert cfg.instruments[0].symbol == "SPY" def test_frozen(self): ic = InstrumentConfig(symbol="SPY", options_file="x", stocks_file="y") with pytest.raises(AttributeError): ic.symbol = "QQQ" # =========================================================================== # Convexity allocator # =========================================================================== class TestAllocator: def test_pick_cheapest(self): scores = {"SPY": 1.5, "QQQ": 2.0, "IWM": 0.8} assert pick_cheapest(scores) == "QQQ" def test_pick_cheapest_empty_raises(self): with pytest.raises(ValueError): pick_cheapest({}) def test_equal_weight(self): alloc = allocate_equal_weight(["SPY", "QQQ"], 10_000) assert abs(alloc["SPY"] - 5_000) < 0.01 assert abs(alloc["QQQ"] - 5_000) < 0.01 def test_equal_weight_empty(self): assert allocate_equal_weight([], 10_000) == {} def test_inverse_vol(self): alloc = allocate_inverse_vol({"SPY": 0.15, "QQQ": 0.30}, 10_000) # SPY has lower vol → gets more budget assert alloc["SPY"] > alloc["QQQ"] assert abs(sum(alloc.values()) - 10_000) < 0.01 def test_inverse_vol_zero_vol(self): """Zero vol should fall back to equal weight.""" alloc = allocate_inverse_vol({"SPY": 0.0, "QQQ": 0.0}, 10_000) assert abs(alloc["SPY"] - 5_000) < 0.01 def test_inverse_vol_mixed_zero(self): alloc = allocate_inverse_vol({"SPY": 0.15, "QQQ": 0.0}, 10_000) # QQQ has zero vol → excluded from inv-vol, only SPY gets budget assert alloc["SPY"] == 10_000 # =========================================================================== # Dispatch layer # =========================================================================== class TestRustExtension: def test_rust_extension_importable(self): assert _ob_rust is not None # =========================================================================== # Schema / Filter DSL # =========================================================================== class TestSchemaBasic: def test_stocks_schema(self): s = Schema.stocks() assert s["symbol"] == "symbol" assert s["date"] == "date" def test_options_schema(self): s = Schema.options() assert s["type"] == "type" assert s["strike"] == "strike" def test_update(self): s = Schema.stocks() s.update({"custom": "custom_col"}) assert s["custom"] == "custom_col" def test_contains(self): s = Schema.stocks() assert "symbol" in s assert "nonexistent" not in s def test_equality(self): s1 = Schema.stocks() s2 = Schema.stocks() assert s1 == s2 def test_inequality_different_mappings(self): s1 = Schema.stocks() s2 = Schema.stocks() s2.update({"symbol": "ticker"}) assert s1 != s2 class TestFilterDSL: def test_field_comparison(self): s = Schema.options() f = s.strike > 100 assert isinstance(f, Filter) assert "strike > 100" in f.query def test_field_equality_string(self): s = Schema.options() f = s.type == "put" assert "'put'" in f.query def test_filter_and(self): s = Schema.options() f = (s.strike > 100) & (s.type == "put") assert "&" in f.query def test_filter_or(self): s = Schema.options() f = (s.strike > 100) | (s.strike < 50) assert "|" in f.query def test_filter_invert(self): s = Schema.options() f = ~(s.strike > 100) assert "!" in f.query def test_filter_call_on_dataframe(self): s = Schema.options() f = s.strike > 100 df = pd.DataFrame({"strike": [50, 100, 150, 200]}) result = f(df) assert result.sum() == 2 def test_field_arithmetic(self): s = Schema.options() f = s.strike * 1.1 assert isinstance(f, Field) assert "1.1" in f.mapping def test_field_subtraction(self): s = Schema.options() f = s.strike - 10 assert "- 10" in f.mapping def test_field_comparison_between_fields(self): s = Schema.options() f = s.strike > s.underlying_last assert "strike" in f.query and "underlying_last" in f.query ================================================ FILE: tests/test_intrinsic_sign.py ================================================ """Tests that intrinsic-value fallback produces correct sign in _current_options_capital. BUY legs are assets → positive capital SELL legs are liabilities → negative capital OTM options (zero intrinsic) → zero capital """ import numpy as np import pandas as pd import pytest from options_portfolio_backtester.core.types import Direction, OptionType from options_portfolio_backtester.data.schema import Schema from options_portfolio_backtester.engine.engine import BacktestEngine from options_portfolio_backtester.strategy.strategy import Strategy from options_portfolio_backtester.strategy.strategy_leg import StrategyLeg def _make_engine_with_position(direction: Direction, option_type: OptionType, strike: float, spot: float, qty: int = 1): """Build a minimal engine with one inventory position and NaN option quotes. Returns (engine, empty_options_df, stocks_df) ready for _current_options_capital(). """ opt_schema = Schema.options() stk_schema = Schema.stocks() leg = StrategyLeg("leg_1", opt_schema, option_type=option_type, direction=direction) strategy = Strategy(opt_schema) strategy.legs.append(leg) engine = BacktestEngine( allocation={"stocks": 0.9, "options": 0.1, "cash": 0.0}, initial_capital=1_000_000, shares_per_contract=100, ) engine.options_strategy = strategy engine._stocks_schema = stk_schema._mappings engine._options_schema = opt_schema._mappings # Build a one-row inventory with the position inventory = pd.DataFrame({ ("leg_1", "contract"): ["SPY_TEST_001"], ("leg_1", "type"): [option_type.value], ("leg_1", "strike"): [strike], ("leg_1", "underlying"): ["SPY"], ("totals", "qty"): [qty], }) inventory.columns = pd.MultiIndex.from_tuples(inventory.columns) engine._options_inventory = inventory # Empty options frame → all quotes will be NaN after left-merge options = pd.DataFrame(columns=[ "underlying", "underlying_last", "date", "contract", "type", "expiration", "strike", "bid", "ask", "volume", "open_interest", ]) # Stock frame with known spot stocks = pd.DataFrame({"symbol": ["SPY"], "adjClose": [spot]}) return engine, options, stocks class TestBuyPutItmPositiveCapital: """BUY put ITM: the position is an asset → capital > 0.""" def test_buy_put_itm_positive_capital(self): engine, options, stocks = _make_engine_with_position( Direction.BUY, OptionType.PUT, strike=400.0, spot=380.0, ) capital = engine._current_options_capital(options, stocks) # intrinsic = 400 - 380 = 20, scaled by 100 spc, qty=1, BUY = asset assert capital > 0 assert capital == pytest.approx(20.0 * 100) class TestSellPutItmNegativeCapital: """SELL put ITM: the position is a liability → capital < 0.""" def test_sell_put_itm_negative_capital(self): engine, options, stocks = _make_engine_with_position( Direction.SELL, OptionType.PUT, strike=400.0, spot=380.0, ) capital = engine._current_options_capital(options, stocks) # intrinsic = 20 * 100, SELL = liability → negative assert capital < 0 assert capital == pytest.approx(-20.0 * 100) class TestBuyCallItmPositiveCapital: """BUY call ITM: asset → capital > 0.""" def test_buy_call_itm_positive_capital(self): engine, options, stocks = _make_engine_with_position( Direction.BUY, OptionType.CALL, strike=380.0, spot=400.0, ) capital = engine._current_options_capital(options, stocks) assert capital > 0 assert capital == pytest.approx(20.0 * 100) class TestSellCallItmNegativeCapital: """SELL call ITM: liability → capital < 0.""" def test_sell_call_itm_negative_capital(self): engine, options, stocks = _make_engine_with_position( Direction.SELL, OptionType.CALL, strike=380.0, spot=400.0, ) capital = engine._current_options_capital(options, stocks) assert capital < 0 assert capital == pytest.approx(-20.0 * 100) class TestOtmCapitalIsZero: """OTM options have zero intrinsic → capital = 0.""" def test_otm_put(self): engine, options, stocks = _make_engine_with_position( Direction.SELL, OptionType.PUT, strike=380.0, spot=400.0, ) capital = engine._current_options_capital(options, stocks) assert capital == pytest.approx(0.0) def test_otm_call(self): engine, options, stocks = _make_engine_with_position( Direction.BUY, OptionType.CALL, strike=400.0, spot=380.0, ) capital = engine._current_options_capital(options, stocks) assert capital == pytest.approx(0.0) ================================================ FILE: tests/test_intrinsic_value.py ================================================ """Tests for intrinsic value fallback when options expire/go missing.""" import numpy as np import pandas as pd import pytest from options_portfolio_backtester.engine.engine import _intrinsic_value from options_portfolio_backtester.core.types import OptionType # --------------------------------------------------------------------------- # Unit tests for _intrinsic_value helper # --------------------------------------------------------------------------- class TestIntrinsicValue: def test_put_itm(self): """Put with strike > spot → intrinsic = strike - spot.""" assert _intrinsic_value("put", 400.0, 380.0) == pytest.approx(20.0) def test_put_otm(self): """Put with strike < spot → intrinsic = 0.""" assert _intrinsic_value("put", 400.0, 420.0) == pytest.approx(0.0) def test_call_itm(self): """Call with spot > strike → intrinsic = spot - strike.""" assert _intrinsic_value("call", 400.0, 420.0) == pytest.approx(20.0) def test_call_otm(self): """Call with spot < strike → intrinsic = 0.""" assert _intrinsic_value("call", 400.0, 380.0) == pytest.approx(0.0) def test_put_atm(self): """ATM put → intrinsic = 0.""" assert _intrinsic_value("put", 400.0, 400.0) == pytest.approx(0.0) def test_call_atm(self): """ATM call → intrinsic = 0.""" assert _intrinsic_value("call", 400.0, 400.0) == pytest.approx(0.0) def test_deep_itm_put(self): """Deep ITM put — large intrinsic.""" assert _intrinsic_value("put", 500.0, 300.0) == pytest.approx(200.0) def test_uses_option_type_enum_values(self): """Works with OptionType enum .value strings.""" assert _intrinsic_value(OptionType.PUT.value, 400.0, 380.0) == pytest.approx(20.0) assert _intrinsic_value(OptionType.CALL.value, 400.0, 420.0) == pytest.approx(20.0) ================================================ FILE: tests/test_property_based.py ================================================ """Property-based and fuzz tests for core components. Uses hypothesis to generate random inputs and verify invariants hold. """ import math import numpy as np import pandas as pd import pytest from hypothesis import given, settings, assume, HealthCheck from hypothesis import strategies as st from options_portfolio_backtester.analytics.stats import BacktestStats, PeriodStats from options_portfolio_backtester.engine.pipeline import ( AlgoPipelineBacktester, PipelineContext, Rebalance, RunDaily, RunMonthly, RunWeekly, SelectAll, SelectThese, SelectRegex, WeighEqually, WeighSpecified, LimitWeights, ScaleWeights, StepDecision, ) from options_portfolio_backtester.execution.cost_model import ( NoCosts, PerContractCommission, TieredCommission, ) from options_portfolio_backtester.execution.fill_model import ( MarketAtBidAsk, MidPrice, VolumeAwareFill, ) from options_portfolio_backtester.execution.signal_selector import ( FirstMatch, NearestDelta, MaxOpenInterest, ) # --------------------------------------------------------------------------- # Strategies (hypothesis) # --------------------------------------------------------------------------- daily_return = st.floats(min_value=-0.20, max_value=0.20, allow_nan=False, allow_infinity=False) positive_float = st.floats(min_value=0.01, max_value=1e8, allow_nan=False, allow_infinity=False) price = st.floats(min_value=0.01, max_value=10000.0, allow_nan=False, allow_infinity=False) quantity = st.floats(min_value=0.0, max_value=100000.0, allow_nan=False, allow_infinity=False) rate = st.floats(min_value=0.0, max_value=10.0, allow_nan=False, allow_infinity=False) weight = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False) def _make_balance(returns: list[float], initial: float = 100_000.0) -> pd.DataFrame: dates = pd.date_range("2020-01-01", periods=len(returns) + 1, freq="B") capital = [initial] for r in returns: capital.append(capital[-1] * (1 + r)) df = pd.DataFrame({"total capital": capital}, index=dates) df["% change"] = df["total capital"].pct_change() return df # --------------------------------------------------------------------------- # BacktestStats invariants # --------------------------------------------------------------------------- class TestStatsInvariants: @given(st.lists(daily_return, min_size=10, max_size=500)) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_max_drawdown_non_negative(self, returns): balance = _make_balance(returns) stats = BacktestStats.from_balance(balance) assert stats.max_drawdown >= 0 @given(st.lists(daily_return, min_size=10, max_size=500)) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_max_drawdown_at_most_one(self, returns): balance = _make_balance(returns) stats = BacktestStats.from_balance(balance) assert stats.max_drawdown <= 1.0 + 1e-10 @given(st.lists(daily_return, min_size=10, max_size=500)) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_avg_drawdown_leq_max(self, returns): balance = _make_balance(returns) stats = BacktestStats.from_balance(balance) assert stats.avg_drawdown <= stats.max_drawdown + 1e-10 @given(st.lists(daily_return, min_size=10, max_size=500)) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_volatility_non_negative(self, returns): balance = _make_balance(returns) stats = BacktestStats.from_balance(balance) assert stats.volatility >= 0 @given(st.lists(daily_return, min_size=10, max_size=500)) @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) def test_total_return_consistent(self, returns): balance = _make_balance(returns) stats = BacktestStats.from_balance(balance) # total_return = (final / initial) - 1 expected = balance["total capital"].iloc[-1] / balance["total capital"].iloc[0] - 1 assert abs(stats.total_return - expected) < 1e-10 @given( st.lists(daily_return, min_size=10, max_size=100), st.lists(st.floats(min_value=-1000, max_value=1000, allow_nan=False, allow_infinity=False), min_size=1, max_size=50), ) @settings(max_examples=30, suppress_health_check=[HealthCheck.too_slow]) def test_trade_stats_consistent(self, returns, pnls): balance = _make_balance(returns) trade_pnls = np.array(pnls) stats = BacktestStats.from_balance(balance, trade_pnls) assert stats.wins + stats.losses == stats.total_trades if stats.total_trades > 0: assert 0 <= stats.win_pct <= 100 assert stats.profit_factor >= 0 @given(st.lists(daily_return, min_size=10, max_size=500)) @settings(max_examples=30, suppress_health_check=[HealthCheck.too_slow]) def test_dataframe_has_all_rows(self, returns): balance = _make_balance(returns) stats = BacktestStats.from_balance(balance) df = stats.to_dataframe() assert df.shape[0] >= 30 assert df.shape[1] == 1 # --------------------------------------------------------------------------- # Cost model invariants # --------------------------------------------------------------------------- class TestCostModelInvariants: @given(price, quantity) @settings(max_examples=100) def test_no_costs_always_zero(self, p, q): assert NoCosts().option_cost(p, q, 100) == 0.0 assert NoCosts().stock_cost(p, q) == 0.0 @given(price, quantity, rate) @settings(max_examples=100) def test_per_contract_non_negative(self, p, q, r): model = PerContractCommission(rate=r) assert model.option_cost(p, q, 100) >= 0 assert model.stock_cost(p, q) >= 0 @given(price, quantity, rate) @settings(max_examples=100) def test_per_contract_proportional(self, p, q, r): model = PerContractCommission(rate=r) cost = model.option_cost(p, q, 100) expected = r * abs(q) assert abs(cost - expected) < 1e-8 @given(price, quantity) @settings(max_examples=100) def test_per_contract_symmetric(self, p, q): """Commission should be same for buy (+q) and sell (-q).""" model = PerContractCommission(rate=0.65) assert model.option_cost(p, q, 100) == model.option_cost(p, -q, 100) # --------------------------------------------------------------------------- # Fill model invariants # --------------------------------------------------------------------------- class TestFillModelInvariants: @given( st.floats(min_value=0.01, max_value=100, allow_nan=False), st.floats(min_value=0.01, max_value=100, allow_nan=False), ) @settings(max_examples=100) def test_mid_price_between_bid_ask(self, bid, ask): assume(bid <= ask) from options_portfolio_backtester.core.types import Direction row = pd.Series({"bid": bid, "ask": ask, "volume": 100}) model = MidPrice() mid = model.get_fill_price(row, Direction.BUY) assert bid - 1e-10 <= mid <= ask + 1e-10 @given( st.floats(min_value=0.01, max_value=100, allow_nan=False), st.floats(min_value=0.01, max_value=100, allow_nan=False), ) @settings(max_examples=100) def test_market_bid_ask_buy_at_ask(self, bid, ask): assume(bid <= ask) from options_portfolio_backtester.core.types import Direction row = pd.Series({"bid": bid, "ask": ask, "volume": 100}) model = MarketAtBidAsk() assert model.get_fill_price(row, Direction.BUY) == ask assert model.get_fill_price(row, Direction.SELL) == bid # --------------------------------------------------------------------------- # Pipeline algo invariants # --------------------------------------------------------------------------- class TestWeightInvariants: @given(st.integers(min_value=2, max_value=20)) @settings(max_examples=30) def test_weigh_equally_sums_to_one(self, n): symbols = [f"S{i}" for i in range(n)] prices = pd.Series({s: 100.0 for s in symbols}) ctx = PipelineContext( date=pd.Timestamp("2024-01-01"), prices=prices, total_capital=1_000_000.0, cash=1_000_000.0, positions={}, ) ctx.selected_symbols = symbols WeighEqually()(ctx) total = sum(ctx.target_weights.values()) assert abs(total - 1.0) < 1e-10 def test_limit_weights_caps_with_many_assets(self): """With enough assets, LimitWeights caps each at the limit.""" ctx = PipelineContext( date=pd.Timestamp("2024-01-01"), prices=pd.Series({f"S{i}": 100 for i in range(10)}), total_capital=1_000_000.0, cash=1_000_000.0, positions={}, ) # One huge weight, rest small ctx.target_weights = {"S0": 0.91} for i in range(1, 10): ctx.target_weights[f"S{i}"] = 0.01 LimitWeights(0.20)(ctx) # After clip+renormalize, S0 should be capped at 0.20 assert ctx.target_weights["S0"] <= 0.20 + 1e-10 @given(st.floats(min_value=0.01, max_value=5.0, allow_nan=False)) @settings(max_examples=30) def test_scale_weights_multiplies(self, scale): ctx = PipelineContext( date=pd.Timestamp("2024-01-01"), prices=pd.Series({"A": 100, "B": 100}), total_capital=1_000_000.0, cash=1_000_000.0, positions={}, ) ctx.target_weights = {"A": 0.3, "B": 0.2} ScaleWeights(scale)(ctx) assert abs(ctx.target_weights["A"] - 0.3 * scale) < 1e-10 assert abs(ctx.target_weights["B"] - 0.2 * scale) < 1e-10 # --------------------------------------------------------------------------- # Pipeline end-to-end fuzz: random prices, verify no crashes + capital > 0 # --------------------------------------------------------------------------- class TestPipelineFuzz: @given( st.lists( st.floats(min_value=10.0, max_value=500.0, allow_nan=False), min_size=5, max_size=50, ), st.floats(min_value=1000.0, max_value=1e7, allow_nan=False, allow_infinity=False), ) @settings(max_examples=30, suppress_health_check=[HealthCheck.too_slow]) def test_pipeline_no_crash_on_random_prices(self, spy_prices, capital): dates = pd.date_range("2024-01-01", periods=len(spy_prices), freq="B") prices = pd.DataFrame({"SPY": spy_prices}, index=dates) bt = AlgoPipelineBacktester( prices=prices, initial_capital=capital, algos=[RunDaily(), SelectAll(), WeighEqually(), Rebalance()], ) bal = bt.run() assert len(bal) > 0 assert bal["total capital"].iloc[-1] > 0 @given( st.lists( st.tuples( st.floats(min_value=10, max_value=500, allow_nan=False), st.floats(min_value=10, max_value=500, allow_nan=False), ), min_size=5, max_size=30, ), ) @settings(max_examples=20, suppress_health_check=[HealthCheck.too_slow]) def test_pipeline_multi_asset_no_crash(self, price_pairs): spy_prices = [p[0] for p in price_pairs] qqq_prices = [p[1] for p in price_pairs] dates = pd.date_range("2024-01-01", periods=len(price_pairs), freq="B") prices = pd.DataFrame({"SPY": spy_prices, "QQQ": qqq_prices}, index=dates) bt = AlgoPipelineBacktester( prices=prices, initial_capital=100_000.0, algos=[RunDaily(), SelectAll(), WeighEqually(), Rebalance()], ) bal = bt.run() assert len(bal) > 0 assert bal["total capital"].iloc[-1] > 0 @given( st.lists( st.floats(min_value=10.0, max_value=500.0, allow_nan=False), min_size=10, max_size=50, ), ) @settings(max_examples=20, suppress_health_check=[HealthCheck.too_slow]) def test_stats_from_pipeline_no_crash(self, spy_prices): dates = pd.date_range("2024-01-01", periods=len(spy_prices), freq="B") prices = pd.DataFrame({"SPY": spy_prices}, index=dates) bt = AlgoPipelineBacktester( prices=prices, initial_capital=100_000.0, algos=[RunDaily(), SelectAll(), WeighEqually(), Rebalance()], ) bal = bt.run() stats = BacktestStats.from_balance(bal) assert stats.max_drawdown >= 0 assert stats.volatility >= 0 df = stats.to_dataframe() assert df.shape[0] >= 30 # --------------------------------------------------------------------------- # to_rust_config round-trip invariants # --------------------------------------------------------------------------- class TestRustConfigRoundTrip: def test_cost_models_have_rust_config(self): for model in [NoCosts(), PerContractCommission(0.65), TieredCommission([(10000, 0.65)])]: cfg = model.to_rust_config() assert isinstance(cfg, dict) assert "type" in cfg def test_fill_models_have_rust_config(self): for model in [MarketAtBidAsk(), MidPrice(), VolumeAwareFill(full_volume_threshold=100)]: cfg = model.to_rust_config() assert isinstance(cfg, dict) assert "type" in cfg def test_signal_selectors_have_rust_config(self): for model in [FirstMatch(), NearestDelta(-0.30), MaxOpenInterest()]: cfg = model.to_rust_config() assert isinstance(cfg, dict) assert "type" in cfg ================================================ FILE: tests/test_smoke.py ================================================ """Smoke tests — verify all public imports work.""" def test_top_level_imports(): """All public symbols importable from options_portfolio_backtester.""" from options_portfolio_backtester import ( # Core Direction, OptionType, Type, Order, Signal, Fill, Greeks, OptionContract, StockAllocation, Stock, get_order, # Data Schema, Field, Filter, CsvOptionsProvider, CsvStocksProvider, TiingoData, HistoricalOptionsData, # Strategy Strategy, StrategyLeg, Strangle, # Execution NoCosts, PerContractCommission, TieredCommission, SpreadSlippage, MarketAtBidAsk, MidPrice, VolumeAwareFill, CapitalBased, FixedQuantity, FixedDollar, PercentOfPortfolio, FirstMatch, NearestDelta, MaxOpenInterest, # Portfolio Portfolio, OptionPosition, aggregate_greeks, RiskManager, MaxDelta, MaxVega, MaxDrawdown, # Engine BacktestEngine, TradingClock, # Analytics BacktestStats, PeriodStats, LookbackReturns, TradeLog, TearsheetReport, build_tearsheet, summary, ) # Quick sanity: verify some aren't None assert Direction is not None assert BacktestEngine is not None assert BacktestStats is not None def test_strategy_presets_import(): """Strategy presets importable.""" from options_portfolio_backtester.strategy.presets import ( strangle, iron_condor, covered_call, cash_secured_put, collar, butterfly, ) assert callable(strangle) assert callable(iron_condor) assert callable(covered_call) assert callable(cash_secured_put) assert callable(collar) assert callable(butterfly)