From b14b5884d62978f5a25f116228f887be76bdbfd2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Jan 2026 17:29:16 +0000 Subject: [PATCH 1/3] Add comprehensive .github/copilot-instructions.md file Co-authored-by: nix1 <1424680+nix1@users.noreply.github.com> --- .github/copilot-instructions.md | 186 ++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..61cbbec7 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,186 @@ +# Copilot Instructions for BYE (Backtesting Yield Estimator) + +## Repository Overview + +**BYE (Backtesting Yield Estimator)** is a Python-based tool for backtesting option trading strategies on historical data, specifically focused on SPY (S&P 500 ETF) put-writing strategies. This is an early-stage POC/exploration project (toddler stage) designed for long-term investment strategies. + +**Repository Size**: Small (~112KB in src/) +**Languages**: Python 3 +**Target Runtime**: Python 3.10 (CI), Python 3.12+ compatible +**Key Dependencies**: pandas, numpy, scipy, statsmodels, pyarrow, tqdm, pytest, flake8, black, jupyter + +## Build and Validation Instructions + +### Environment Setup + +**IMPORTANT**: Always install setuptools and wheel BEFORE installing other dependencies to avoid numpy build issues: + +```bash +python3 -m venv venv +source venv/bin/activate +pip install --upgrade pip +pip install setuptools wheel +pip install -r requirements.txt +``` + +**Note**: The requirements.txt pins old package versions (e.g., numpy==1.23.4) that are incompatible with Python 3.12+. The CI uses Python 3.10. For Python 3.12+, you may need to install dependencies without version pins to get compatible versions. + +### Linting + +**ALWAYS run flake8 before committing**. The CI runs two flake8 checks: + +1. Critical errors (will fail CI): +```bash +flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics +``` + +2. Style warnings (won't fail CI): +```bash +flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics +``` + +**Expected warnings**: You may see `SyntaxWarning: invalid escape sequence '\.'` - this is expected and does not fail the build. + +### Testing + +**ALWAYS run pytest after making code changes**: + +```bash +pytest +``` + +Or for verbose output: +```bash +pytest -v +``` + +**Test location**: All tests are in `src/tests/` directory. +**Test files**: `test_markets.py`, `test_strategies.py`, `test_wallet.py` +**Expected behavior**: All 5 tests should pass in under 1 second. + +### Running the Application + +The application requires historical option data from OptionsDX (not included in repo). The workflow is: + +1. Place SPY EOD data in `data/spy_eod_raw/` (organized by year subdirectories, monthly .txt files) +2. Run data pipeline: +```bash +python 1_load.py # Loads and joins data -> data/interim/spy_eod.parquet +python 2_select.py # Filters columns -> data/processed/spy_eod_put.parquet +python 3_main.py # Runs backtesting strategies +``` + +**Note**: The data directory does not exist in the repo and must be created by users. + +## Project Layout and Architecture + +### Directory Structure + +``` +/home/runner/work/bye/bye/ +├── .github/ +│ └── workflows/ +│ └── python-app.yml # CI workflow (flake8 + pytest) +├── src/ # Main package directory +│ ├── __init__.py +│ ├── markets.py # HistoricalMarket class (iterator over quotes) +│ ├── options.py # Option/Put classes +│ ├── strategies.py # Strategy base class, SellWeeklyPuts, SellMonthlyPuts +│ ├── wallet.py # Wallet and Position classes +│ └── tests/ # All unit tests +│ ├── test_markets.py +│ ├── test_strategies.py +│ └── test_wallet.py +├── 1_load.py # Step 1: Load raw data +├── 2_select.py # Step 2: Filter data +├── 3_main.py # Step 3: Run backtesting +├── requirements.txt # Python dependencies (pinned versions) +├── README.md # User documentation +└── LICENSE # MIT-style license +``` + +### Key Architectural Components + +1. **HistoricalMarket** (`src/markets.py`): Iterator that provides daily market data, handles trade execution (sell_to_open, buy, sell, close positions). Groups quotes by date and underlying price. + +2. **Option Classes** (`src/options.py`): Abstract Option base class with Put implementation. Handles ITM/OTM logic, expiration checks, intrinsic value calculations. + +3. **Strategy Pattern** (`src/strategies.py`): + - Abstract `Strategy` base class with `handle_no_open_positions()` method to implement + - `SellWeeklyPuts`: Sells ~weekly ATM/OTM puts (Mondays to Fridays) + - `SellMonthlyPuts`: Sells monthly puts (30 DTE) + - Strategies track capital via Wallet + +4. **Wallet/Position** (`src/wallet.py`): Tracks cash and open/closed positions. Position tracks option, quantity (negative for short), cost, and closing details. + +### CI/CD Pipeline + +**GitHub Actions Workflow**: `.github/workflows/python-app.yml` +- **Trigger**: Push/PR to main branch +- **Python Version**: 3.10 +- **Steps**: + 1. Install dependencies (pip install flake8 pytest + requirements.txt) + 2. Lint with flake8 (critical errors will fail build) + 3. Test with pytest (all tests must pass) + +**Badge**: Shows build status on README.md + +### Code Conventions and Known Issues + +**TODOs in codebase**: +- `src/markets.py:90, 110`: Handle cases where option quotes are not found (currently may raise IndexError) + +**Code Style**: +- Max line length: 127 characters +- Max complexity: 10 +- Use of pandas DataFrames throughout +- Type hints not consistently used + +**Testing Approach**: +- Fixtures for sample quote DataFrames +- Focus on core functionality (market iteration, position opening) +- Limited test coverage (only 5 tests total) + +### Key Facts for Making Changes + +1. **Data Flow**: Raw data (txt) -> interim (parquet) -> processed (parquet) -> backtesting results +2. **No .gitignore**: Repository has no root .gitignore file. Be careful not to commit venv/, data/, or cache files. +3. **Date Handling**: Uses pandas timestamps throughout. Market quotes grouped by `[QUOTE_DATE]` and `[UNDERLYING_LAST]`. +4. **Column Naming**: OptionsDX data uses bracketed column names like `[STRIKE]`, `[P_BID]`, `[EXPIRE_DATE]`. +5. **Position Convention**: Negative quantity = short position, positive = long position. +6. **Strategy Extension**: To add strategies, inherit from `Strategy` and implement `handle_no_open_positions()`. + +### Dependency Notes + +- **Critical**: Install setuptools/wheel before other packages +- **numpy**: Version 1.23.4 in requirements.txt only works with Python 3.10, not 3.12+ +- **pandas**: Requires pyarrow for parquet support +- **jupyter**: Full stack included for notebook development + +### Common Pitfalls + +1. **Forgetting virtual environment**: Always activate venv before installing/running +2. **Python version mismatch**: CI uses 3.10; local development may use 3.12+ +3. **Missing data directory**: Scripts will fail if data/ directory structure doesn't exist +4. **Flake8 warnings**: The SyntaxWarning about escape sequences is normal, doesn't fail CI +5. **Index errors**: Market methods (buy/sell) can raise IndexError if option not found in quotes + +### Validation Steps + +Before submitting changes: +1. ✅ Activate virtual environment +2. ✅ Run `flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics` (must have zero errors) +3. ✅ Run `pytest -v` (all 5 tests must pass) +4. ✅ If modifying core logic, test with sample data if available +5. ✅ Ensure no venv/, data/, or cache files are staged for commit + +### Trust These Instructions + +These instructions have been validated by: +- Creating a fresh virtual environment +- Installing all dependencies (with setuptools workaround) +- Running both flake8 checks (0 errors, expected warnings) +- Running pytest (5/5 tests passed in 0.67s) +- Exploring all source files and configuration + +**Only search for additional information if these instructions are incomplete or found to be incorrect.** From 8032de98f8df1ecd50f8e7790d0f7851c3765c95 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Jan 2026 12:41:30 +0000 Subject: [PATCH 2/3] Clean up code and add comprehensive test suite (55 tests total) Co-authored-by: nix1 <1424680+nix1@users.noreply.github.com> --- .gitignore | 36 ++++++++ src/markets.py | 2 - src/tests/test_markets.py | 132 +++++++++++++++++++++++++-- src/tests/test_options.py | 66 ++++++++++++++ src/tests/test_strategies.py | 169 +++++++++++++++++++++++++++++++++-- src/tests/test_wallet.py | 168 +++++++++++++++++++++++++++++++++- 6 files changed, 552 insertions(+), 21 deletions(-) create mode 100644 .gitignore create mode 100644 src/tests/test_options.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..652bd744 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# Virtual environments +venv/ +env/ +ENV/ +.venv + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +*.cover + +# Data directories +data/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Distribution / packaging +dist/ +build/ +*.egg-info/ + +# Jupyter +.ipynb_checkpoints/ diff --git a/src/markets.py b/src/markets.py index f1bd3159..614e1f2e 100644 --- a/src/markets.py +++ b/src/markets.py @@ -1,6 +1,4 @@ -from abc import abstractmethod from collections.abc import Iterator -from datetime import timedelta from src.options import Put from src.wallet import Position diff --git a/src/tests/test_markets.py b/src/tests/test_markets.py index c4fff191..251ed51d 100644 --- a/src/tests/test_markets.py +++ b/src/tests/test_markets.py @@ -3,6 +3,8 @@ from pytest import fixture from src.markets import HistoricalMarket +from src.options import Put +from src.wallet import Position @fixture @@ -15,6 +17,7 @@ def quotes_1d_df(): "[P_ASK]": [1.1, 2.1, 3.1], "[QUOTE_DATE]": pd.to_datetime("2020-01-01"), "[EXPIRE_DATE]": pd.to_datetime("2020-01-08"), + "[DTE]": [7, 7, 7], } ) @@ -23,14 +26,15 @@ def quotes_1d_df(): def quotes_8w_df(quotes_1d_df): df = pd.DataFrame() for i in range(8): - quotes_1d_df["[QUOTE_DATE]"] = pd.to_datetime("2020-01-01") + pd.Timedelta( + quotes_copy = quotes_1d_df.copy() + quotes_copy["[QUOTE_DATE]"] = pd.to_datetime("2020-01-01") + pd.Timedelta( days=i * 7 ) - quotes_1d_df["[EXPIRE_DATE]"] = pd.to_datetime("2020-01-01") + pd.Timedelta( + quotes_copy["[EXPIRE_DATE]"] = pd.to_datetime("2020-01-01") + pd.Timedelta( days=i * 7 + 7 ) - quotes_1d_df["[UNDERLYING_LAST]"] = 99 + i - df = pd.concat([df, quotes_1d_df]) + quotes_copy["[UNDERLYING_LAST]"] = 99 + i + df = pd.concat([df, quotes_copy]) return df @@ -48,10 +52,6 @@ def test_first_date(self, quotes_1d_df): quotes_df=quotes_1d_df, ) date, price, quotes = next(market) - print("Quotes:") - print(quotes) - print("Current quotes:") - print(market.current_quotes) assert date == quotes_1d_df["[QUOTE_DATE]"].iloc[0] assert market.current_date == quotes_1d_df["[QUOTE_DATE]"].iloc[0] assert_frame_equal( @@ -72,3 +72,119 @@ def test_iteration(self, quotes_8w_df): i += 1 assert i == 8 + + def test_sell_to_open_finds_closest_strike(self, quotes_1d_df): + """Test sell_to_open finds the option with closest strike""" + market = HistoricalMarket(quotes_df=quotes_1d_df) + next(market) # Advance to first date + + # Request strike of 101 (closest is 100) + position = market.sell_to_open(ideal_strike=101, ideal_dte=7) + assert position.option.strike == 100 + assert position.quantity == -1 + assert position.cost == 2 # P_BID for strike 100 + + def test_sell_to_open_finds_closest_dte(self, quotes_1d_df): + """Test sell_to_open finds the option with closest DTE""" + market = HistoricalMarket(quotes_df=quotes_1d_df) + next(market) + + # All have DTE=7, so should still work + position = market.sell_to_open(ideal_strike=100, ideal_dte=5) + assert position.option.strike == 100 + assert position.option.expiration == pd.to_datetime("2020-01-08") + + def test_sell_to_open_creates_short_position(self, quotes_1d_df): + """Test sell_to_open creates a short position""" + market = HistoricalMarket(quotes_df=quotes_1d_df) + next(market) + + position = market.sell_to_open(ideal_strike=100, ideal_dte=7) + assert position.quantity == -1 # Short position + assert position.cost > 0 # Premium received + + def test_buy_returns_ask_price(self, quotes_1d_df): + """Test buy method returns the ask price""" + market = HistoricalMarket(quotes_df=quotes_1d_df) + next(market) + + put = Put(strike=100, expiration=pd.to_datetime("2020-01-08")) + cost = market.buy(put) + assert cost == 2.1 # P_ASK for strike 100 + + def test_sell_returns_bid_price(self, quotes_1d_df): + """Test sell method returns the bid price""" + market = HistoricalMarket(quotes_df=quotes_1d_df) + next(market) + + put = Put(strike=100, expiration=pd.to_datetime("2020-01-08")) + price = market.sell(put) + assert price == 2 # P_BID for strike 100 + + def test_close_long_position_sells_at_bid(self, quotes_1d_df): + """Test closing a long position sells at bid price""" + market = HistoricalMarket(quotes_df=quotes_1d_df) + next(market) + + put = Put(strike=100, expiration=pd.to_datetime("2020-01-08")) + position = Position(option=put, quantity=1, cost=2.1) + close_value = market.close(position) + assert close_value == 2 # quantity * bid = 1 * 2 + + def test_close_short_position_buys_at_ask(self, quotes_1d_df): + """Test closing a short position buys at ask price""" + market = HistoricalMarket(quotes_df=quotes_1d_df) + next(market) + + put = Put(strike=100, expiration=pd.to_datetime("2020-01-08")) + position = Position(option=put, quantity=-1, cost=2.0) + close_value = market.close(position) + assert close_value == -2.1 # quantity * ask = -1 * 2.1 + + def test_close_expires_worthless_otm(self, quotes_1d_df): + """Test that OTM option expiring today has zero close value""" + market = HistoricalMarket(quotes_df=quotes_1d_df) + next(market) + market.current_date = pd.to_datetime("2020-01-08") # Expiration date + + # OTM put (strike 90, underlying 99) + put = Put(strike=90, expiration=pd.to_datetime("2020-01-08")) + position = Position(option=put, quantity=-1, cost=0.1) + close_value = market.close(position) + assert close_value == 0 # Expires worthless + + def test_close_dry_run_doesnt_close_position(self, quotes_1d_df): + """Test that dry_run=True doesn't actually close the position""" + market = HistoricalMarket(quotes_df=quotes_1d_df) + next(market) + + put = Put(strike=100, expiration=pd.to_datetime("2020-01-08")) + position = Position(option=put, quantity=-1, cost=2.0) + + # Dry run should return value but not close + close_value = market.close(position, dry_run=True) + assert close_value == -2.1 + assert position.close_value is None # Not actually closed + assert position.closed_at is None + + def test_close_not_dry_run_closes_position(self, quotes_1d_df): + """Test that dry_run=False actually closes the position""" + market = HistoricalMarket(quotes_df=quotes_1d_df) + next(market) + + put = Put(strike=100, expiration=pd.to_datetime("2020-01-08")) + position = Position(option=put, quantity=-1, cost=2.0) + + close_value = market.close(position, dry_run=False) + assert close_value == -2.1 + assert position.close_value == -2.1 # Actually closed + assert position.closed_at == market.current_date + + def test_get_quotes_returns_current_quotes(self, quotes_1d_df): + """Test get_quotes returns current market quotes""" + market = HistoricalMarket(quotes_df=quotes_1d_df) + next(market) + + quotes = market.get_quotes() + assert quotes is not None + assert len(quotes) == 3 # Three strikes diff --git a/src/tests/test_options.py b/src/tests/test_options.py new file mode 100644 index 00000000..de91da9f --- /dev/null +++ b/src/tests/test_options.py @@ -0,0 +1,66 @@ +import pandas as pd + +from src.options import Put + + +class TestPut: + """Test the Put option class""" + + def test_put_init(self): + """Test Put initialization""" + put = Put(strike=100, expiration=pd.to_datetime("2020-01-08")) + assert put.strike == 100 + assert put.expiration == pd.to_datetime("2020-01-08") + + def test_put_is_itm_when_underlying_below_strike(self): + """Test that Put is ITM when underlying is below strike""" + put = Put(strike=100, expiration=pd.to_datetime("2020-01-08")) + assert put.is_itm(99) is True + assert put.is_itm(95) is True + assert put.is_itm(50) is True + + def test_put_is_otm_when_underlying_above_strike(self): + """Test that Put is OTM when underlying is above strike""" + put = Put(strike=100, expiration=pd.to_datetime("2020-01-08")) + assert put.is_itm(101) is False + assert put.is_itm(105) is False + assert put.is_itm(150) is False + + def test_put_is_atm_when_underlying_equals_strike(self): + """Test that Put is OTM when underlying equals strike""" + put = Put(strike=100, expiration=pd.to_datetime("2020-01-08")) + assert put.is_itm(100) is False + + def test_put_is_expiring(self): + """Test is_expiring method""" + put = Put(strike=100, expiration=pd.to_datetime("2020-01-08")) + assert put.is_expiring(pd.to_datetime("2020-01-08")) is True + assert put.is_expiring(pd.to_datetime("2020-01-07")) is False + assert put.is_expiring(pd.to_datetime("2020-01-09")) is False + + def test_put_is_expired(self): + """Test is_expired method""" + put = Put(strike=100, expiration=pd.to_datetime("2020-01-08")) + assert put.is_expired(pd.to_datetime("2020-01-09")) is True + assert put.is_expired(pd.to_datetime("2020-01-10")) is True + assert put.is_expired(pd.to_datetime("2020-01-08")) is False + assert put.is_expired(pd.to_datetime("2020-01-07")) is False + + def test_put_intrinsic_value_when_itm(self): + """Test intrinsic value when Put is ITM""" + put = Put(strike=100, expiration=pd.to_datetime("2020-01-08")) + assert put.intrinsic_value(95) == 5 + assert put.intrinsic_value(90) == 10 + assert put.intrinsic_value(99) == 1 + + def test_put_intrinsic_value_when_otm(self): + """Test intrinsic value when Put is OTM""" + put = Put(strike=100, expiration=pd.to_datetime("2020-01-08")) + assert put.intrinsic_value(105) == 0 + assert put.intrinsic_value(110) == 0 + assert put.intrinsic_value(100) == 0 + + def test_put_repr(self): + """Test Put string representation""" + put = Put(strike=100, expiration=pd.to_datetime("2020-01-08")) + assert repr(put) == "Put(100, 2020-01-08 00:00:00)" diff --git a/src/tests/test_strategies.py b/src/tests/test_strategies.py index 1fb21270..8987c678 100644 --- a/src/tests/test_strategies.py +++ b/src/tests/test_strategies.py @@ -3,7 +3,7 @@ from pytest import fixture from src.markets import HistoricalMarket -from src.strategies import SellWeeklyPuts +from src.strategies import SellWeeklyPuts, SellMonthlyPuts @fixture @@ -16,33 +16,184 @@ def quotes_1d_df(): "[P_ASK]": [0.2, 1.1, 10.1], "[QUOTE_DATE]": pd.to_datetime("2020-01-01"), "[EXPIRE_DATE]": pd.to_datetime("2020-01-08"), - "[DTE]": 7, + "[DTE]": [7, 7, 7], } ) +@fixture +def quotes_multi_day_df(): + """Create quotes for multiple days""" + df = pd.DataFrame() + for i in range(3): + day_quotes = pd.DataFrame( + { + "[STRIKE]": [90, 100, 110], + "[UNDERLYING_LAST]": [99.5, 99.5, 99.5], + "[P_BID]": [0.1, 1.0, 10.0], + "[P_ASK]": [0.2, 1.1, 10.1], + "[QUOTE_DATE]": pd.to_datetime("2020-01-01") + pd.Timedelta(days=i), + "[EXPIRE_DATE]": pd.to_datetime("2020-01-08"), + "[DTE]": [7 - i, 7 - i, 7 - i], + } + ) + df = pd.concat([df, day_quotes]) + return df + + class TestSellWeeklyPuts: - def _check_strategy(self, cash, value, market_value, open_positions): - assert self.strategy.wallet.cash == cash - assert self.strategy.get_current_value() == pytest.approx(value) - assert self.strategy.get_current_market_value() == pytest.approx(market_value) - assert len(self.strategy.get_open_positions()) == open_positions + def _check_strategy(self, strategy, cash, value, market_value, open_positions): + assert strategy.wallet.cash == cash + assert strategy.get_current_value() == pytest.approx(value) + assert strategy.get_current_market_value() == pytest.approx(market_value) + assert len(strategy.get_open_positions()) == open_positions def test_opening_new_positions(self, quotes_1d_df): market = HistoricalMarket( quotes_df=quotes_1d_df, ) - self.strategy = SellWeeklyPuts(market) + strategy = SellWeeklyPuts(market) market.__next__() - self.strategy.run() + strategy.run() # Expectation: the strategy should sell 1 ~ATM put for 1.0 # - Starting cash = 0.0 # - Ideal DTE is 3, but no choice here, the closest one is 7 # - Ideal strike = 99.5, the closest one is 100 self._check_strategy( + strategy, cash=1.0, # ends up with 1.0 cash from the premium value=0.5, # 1.0 (cash) -0.5 (simplified value of the short OOM put) market_value=-0.1, # 1.0 (cash) -1.1 (market ask value of the short OOM put) open_positions=1, ) + + def test_ideal_strike_atm(self, quotes_1d_df): + """Test that ATM strike is selected by default""" + market = HistoricalMarket(quotes_df=quotes_1d_df) + strategy = SellWeeklyPuts(market, ideal_strike=1.0) + next(market) + strategy.run() + + positions = strategy.get_open_positions() + assert len(positions) == 1 + # Should select strike closest to 99.5 * 1.0 = 99.5, which is 100 + assert positions[0].option.strike == 100 + + def test_ideal_strike_otm(self, quotes_1d_df): + """Test that OTM strike is selected when ideal_strike < 1.0""" + market = HistoricalMarket(quotes_df=quotes_1d_df) + strategy = SellWeeklyPuts(market, ideal_strike=0.9) + next(market) + strategy.run() + + positions = strategy.get_open_positions() + assert len(positions) == 1 + # Should select strike closest to 99.5 * 0.9 = 89.55, which is 90 + assert positions[0].option.strike == 90 + + def test_ideal_strike_itm(self, quotes_1d_df): + """Test that ITM strike is selected when ideal_strike > 1.0""" + market = HistoricalMarket(quotes_df=quotes_1d_df) + strategy = SellWeeklyPuts(market, ideal_strike=1.1) + next(market) + strategy.run() + + positions = strategy.get_open_positions() + assert len(positions) == 1 + # Should select strike closest to 99.5 * 1.1 = 109.45, which is 110 + assert positions[0].option.strike == 110 + + def test_hold_the_strike_false(self, quotes_multi_day_df): + """Test that strike adjusts with market when hold_the_strike=False""" + market = HistoricalMarket(quotes_df=quotes_multi_day_df) + strategy = SellWeeklyPuts(market, ideal_strike=1.0, hold_the_strike=False) + + # Just test that it initializes + assert strategy.hold_the_strike is False + assert strategy.last_ideal_strike is None + + def test_write_put_adds_position(self, quotes_1d_df): + """Test that write_put adds a position to the wallet""" + market = HistoricalMarket(quotes_df=quotes_1d_df) + strategy = SellWeeklyPuts(market) + next(market) + + initial_cash = strategy.wallet.cash + strategy.write_put(ideal_strike=100, ideal_dte=7) + + assert len(strategy.wallet.positions) == 1 + assert strategy.wallet.cash > initial_cash # Received premium + + def test_get_ideal_dte_monday(self, quotes_1d_df): + """Test ideal DTE calculation on Monday""" + market = HistoricalMarket(quotes_df=quotes_1d_df) + strategy = SellWeeklyPuts(market) + next(market) + + # 2020-01-01 is a Wednesday (weekday=2), so ideal_dte = 5-2 = 3 + market.current_date = pd.to_datetime("2020-01-01") + ideal_dte = strategy._get_ideal_dte() + assert ideal_dte == 3 + + def test_get_ideal_dte_friday(self, quotes_1d_df): + """Test ideal DTE calculation on Friday""" + market = HistoricalMarket(quotes_df=quotes_1d_df) + strategy = SellWeeklyPuts(market) + next(market) + + # 2020-01-03 is a Friday (weekday=4), so ideal_dte = 5-4 = 1 (next day is Saturday) + market.current_date = pd.to_datetime("2020-01-03") + ideal_dte = strategy._get_ideal_dte() + assert ideal_dte == 1 # Tomorrow (Saturday counts as expiry day) + + def test_repr_without_hold(self, quotes_1d_df): + """Test string representation without hold_the_strike""" + market = HistoricalMarket(quotes_df=quotes_1d_df) + strategy = SellWeeklyPuts(market, ideal_strike=0.9, hold_the_strike=False) + assert repr(strategy) == "W(0.9)" + + def test_repr_with_hold(self, quotes_1d_df): + """Test string representation with hold_the_strike""" + market = HistoricalMarket(quotes_df=quotes_1d_df) + strategy = SellWeeklyPuts(market, ideal_strike=0.9, hold_the_strike=True) + assert repr(strategy) == "WH(0.9)" + + +class TestSellMonthlyPuts: + def test_monthly_inherits_from_weekly(self, quotes_1d_df): + """Test that SellMonthlyPuts inherits from SellWeeklyPuts""" + market = HistoricalMarket(quotes_df=quotes_1d_df) + strategy = SellMonthlyPuts(market) + assert isinstance(strategy, SellWeeklyPuts) + + def test_ideal_dte_is_30(self, quotes_1d_df): + """Test that ideal DTE for monthly is 30 days""" + market = HistoricalMarket(quotes_df=quotes_1d_df) + strategy = SellMonthlyPuts(market) + next(market) + + ideal_dte = strategy._get_ideal_dte() + assert ideal_dte == 30 + + def test_repr_without_hold(self, quotes_1d_df): + """Test string representation without hold_the_strike""" + market = HistoricalMarket(quotes_df=quotes_1d_df) + strategy = SellMonthlyPuts(market, ideal_strike=0.9, hold_the_strike=False) + assert repr(strategy) == "M(0.9)" + + def test_repr_with_hold(self, quotes_1d_df): + """Test string representation with hold_the_strike""" + market = HistoricalMarket(quotes_df=quotes_1d_df) + strategy = SellMonthlyPuts(market, ideal_strike=1.1, hold_the_strike=True) + assert repr(strategy) == "MH(1.1)" + + def test_opening_position(self, quotes_1d_df): + """Test that monthly strategy opens positions""" + market = HistoricalMarket(quotes_df=quotes_1d_df) + strategy = SellMonthlyPuts(market) + next(market) + strategy.run() + + positions = strategy.get_open_positions() + assert len(positions) == 1 diff --git a/src/tests/test_wallet.py b/src/tests/test_wallet.py index 6fe3d7cd..c46ed0aa 100644 --- a/src/tests/test_wallet.py +++ b/src/tests/test_wallet.py @@ -1,8 +1,172 @@ -from src.wallet import Wallet +import pandas as pd +from pytest import fixture + +from src.options import Put +from src.wallet import Wallet, Position + + +@fixture +def sample_put(): + """Create a sample Put option""" + return Put(strike=100, expiration=pd.to_datetime("2020-01-08")) + + +@fixture +def sample_position(sample_put): + """Create a sample short position""" + return Position(option=sample_put, quantity=-1, cost=2.0) + + +class TestPosition: + """Test the Position class""" + + def test_position_init(self, sample_put): + """Test Position initialization""" + position = Position(option=sample_put, quantity=-1, cost=2.0) + assert position.option == sample_put + assert position.quantity == -1 + assert position.cost == 2.0 + assert position.close_value is None + assert position.closed_at is None + + def test_position_long(self, sample_put): + """Test long position has positive quantity""" + position = Position(option=sample_put, quantity=1, cost=2.0) + assert position.quantity == 1 + + def test_position_short(self, sample_put): + """Test short position has negative quantity""" + position = Position(option=sample_put, quantity=-1, cost=2.0) + assert position.quantity == -1 + + def test_position_is_expired(self, sample_position): + """Test is_expired method""" + assert sample_position.is_expired(pd.to_datetime("2020-01-09")) is True + assert sample_position.is_expired(pd.to_datetime("2020-01-08")) is False + assert sample_position.is_expired(pd.to_datetime("2020-01-07")) is False + + def test_position_is_expiring(self, sample_position): + """Test is_expiring method""" + assert sample_position.is_expiring(pd.to_datetime("2020-01-08")) is True + assert sample_position.is_expiring(pd.to_datetime("2020-01-07")) is False + assert sample_position.is_expiring(pd.to_datetime("2020-01-09")) is False + + def test_position_close(self, sample_position): + """Test closing a position""" + close_date = pd.to_datetime("2020-01-07") + close_value = -1.5 + sample_position.close(close_date, close_value) + assert sample_position.close_value == -1.5 + assert sample_position.closed_at == close_date + + def test_position_repr(self, sample_position): + """Test Position string representation""" + repr_str = repr(sample_position) + assert "Position" in repr_str + assert "Put(100" in repr_str + assert "-1" in repr_str + assert "2.0" in repr_str class TestWallet: - def test_instance(self): + """Test the Wallet class""" + + def test_wallet_init_with_zero(self): + """Test Wallet initialization with zero cash""" wallet = Wallet(0) assert wallet.cash == 0 assert wallet.positions == [] + + def test_wallet_init_with_cash(self): + """Test Wallet initialization with starting cash""" + wallet = Wallet(1000) + assert wallet.cash == 1000 + assert wallet.positions == [] + + def test_wallet_add_position_updates_cash(self, sample_position): + """Test adding position updates cash when update_cash=True""" + wallet = Wallet(1000) + wallet.add_position(sample_position, update_cash=True) + # For short position: cash -= (-1) * 2.0 = cash += 2.0 + assert wallet.cash == 1002.0 + assert len(wallet.positions) == 1 + + def test_wallet_add_position_no_cash_update(self, sample_position): + """Test adding position without updating cash""" + wallet = Wallet(1000) + wallet.add_position(sample_position, update_cash=False) + assert wallet.cash == 1000 + assert len(wallet.positions) == 1 + + def test_wallet_add_long_position(self, sample_put): + """Test adding a long position decreases cash""" + wallet = Wallet(1000) + long_position = Position(option=sample_put, quantity=1, cost=2.0) + wallet.add_position(long_position, update_cash=True) + # For long position: cash -= 1 * 2.0 = 998 + assert wallet.cash == 998.0 + + def test_wallet_add_multiple_positions(self, sample_put): + """Test adding multiple positions""" + wallet = Wallet(1000) + pos1 = Position(option=sample_put, quantity=-1, cost=2.0) + pos2 = Position(option=sample_put, quantity=-1, cost=3.0) + wallet.add_position(pos1, update_cash=True) + wallet.add_position(pos2, update_cash=True) + assert wallet.cash == 1005.0 # 1000 + 2 + 3 + assert len(wallet.positions) == 2 + + def test_wallet_get_expired_positions(self, sample_put): + """Test getting expired positions""" + wallet = Wallet(0) + pos1 = Position(option=sample_put, quantity=-1, cost=2.0) + pos2_put = Put(strike=105, expiration=pd.to_datetime("2020-01-15")) + pos2 = Position(option=pos2_put, quantity=-1, cost=3.0) + wallet.add_position(pos1, update_cash=False) + wallet.add_position(pos2, update_cash=False) + + # On 2020-01-10, first position is expired, second is not + expired = wallet.get_expired_positions(pd.to_datetime("2020-01-10")) + assert len(expired) == 1 + assert expired[0].option.expiration == pd.to_datetime("2020-01-08") + + def test_wallet_get_expiring_positions(self, sample_put): + """Test getting expiring positions""" + wallet = Wallet(0) + pos1 = Position(option=sample_put, quantity=-1, cost=2.0) + pos2_put = Put(strike=105, expiration=pd.to_datetime("2020-01-15")) + pos2 = Position(option=pos2_put, quantity=-1, cost=3.0) + wallet.add_position(pos1, update_cash=False) + wallet.add_position(pos2, update_cash=False) + + # On 2020-01-08, first position is expiring + expiring = wallet.get_expiring_positions(pd.to_datetime("2020-01-08")) + assert len(expiring) == 1 + assert expiring[0].option.expiration == pd.to_datetime("2020-01-08") + + def test_wallet_get_open_positions(self, sample_put): + """Test getting open (not expired) positions""" + wallet = Wallet(0) + pos1 = Position(option=sample_put, quantity=-1, cost=2.0) + pos2_put = Put(strike=105, expiration=pd.to_datetime("2020-01-15")) + pos2 = Position(option=pos2_put, quantity=-1, cost=3.0) + wallet.add_position(pos1, update_cash=False) + wallet.add_position(pos2, update_cash=False) + + # On 2020-01-10, only second position is still open + open_pos = wallet.get_open_positions(pd.to_datetime("2020-01-10")) + assert len(open_pos) == 1 + assert open_pos[0].option.expiration == pd.to_datetime("2020-01-15") + + def test_wallet_get_open_positions_all_open(self, sample_put): + """Test getting open positions when all are open""" + wallet = Wallet(0) + pos1 = Position(option=sample_put, quantity=-1, cost=2.0) + pos2_put = Put(strike=105, expiration=pd.to_datetime("2020-01-15")) + pos2 = Position(option=pos2_put, quantity=-1, cost=3.0) + wallet.add_position(pos1, update_cash=False) + wallet.add_position(pos2, update_cash=False) + + # On 2020-01-05, both positions are still open + open_pos = wallet.get_open_positions(pd.to_datetime("2020-01-05")) + assert len(open_pos) == 2 From 2687cb1e671e463686bce46dc7ea3c70d69bab74 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:06:21 +0000 Subject: [PATCH 3/3] Update documentation and add docstrings to address review feedback Co-authored-by: nix1 <1424680+nix1@users.noreply.github.com> --- .github/copilot-instructions.md | 16 +++++++++------- src/__init__.py | 6 ++++++ src/markets.py | 6 ++++++ src/options.py | 7 +++++++ src/strategies.py | 6 ++++++ src/tests/test_strategies.py | 4 ++-- src/wallet.py | 8 ++++++++ 7 files changed, 44 insertions(+), 9 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 61cbbec7..9a03de3a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -55,8 +55,8 @@ pytest -v ``` **Test location**: All tests are in `src/tests/` directory. -**Test files**: `test_markets.py`, `test_strategies.py`, `test_wallet.py` -**Expected behavior**: All 5 tests should pass in under 1 second. +**Test files**: `test_markets.py`, `test_options.py`, `test_strategies.py`, `test_wallet.py` +**Expected behavior**: All 55 tests should pass in under 1 second. ### Running the Application @@ -77,7 +77,7 @@ python 3_main.py # Runs backtesting strategies ### Directory Structure ``` -/home/runner/work/bye/bye/ +./ ├── .github/ │ └── workflows/ │ └── python-app.yml # CI workflow (flake8 + pytest) @@ -89,6 +89,7 @@ python 3_main.py # Runs backtesting strategies │ ├── wallet.py # Wallet and Position classes │ └── tests/ # All unit tests │ ├── test_markets.py +│ ├── test_options.py │ ├── test_strategies.py │ └── test_wallet.py ├── 1_load.py # Step 1: Load raw data @@ -96,6 +97,7 @@ python 3_main.py # Runs backtesting strategies ├── 3_main.py # Step 3: Run backtesting ├── requirements.txt # Python dependencies (pinned versions) ├── README.md # User documentation +├── .gitignore # Git ignore file └── LICENSE # MIT-style license ``` @@ -139,12 +141,12 @@ python 3_main.py # Runs backtesting strategies **Testing Approach**: - Fixtures for sample quote DataFrames - Focus on core functionality (market iteration, position opening) -- Limited test coverage (only 5 tests total) +- Comprehensive test coverage (55 tests total) ### Key Facts for Making Changes 1. **Data Flow**: Raw data (txt) -> interim (parquet) -> processed (parquet) -> backtesting results -2. **No .gitignore**: Repository has no root .gitignore file. Be careful not to commit venv/, data/, or cache files. +2. **Git Ignore**: Repository has a .gitignore file that excludes venv/, data/, and cache files. 3. **Date Handling**: Uses pandas timestamps throughout. Market quotes grouped by `[QUOTE_DATE]` and `[UNDERLYING_LAST]`. 4. **Column Naming**: OptionsDX data uses bracketed column names like `[STRIKE]`, `[P_BID]`, `[EXPIRE_DATE]`. 5. **Position Convention**: Negative quantity = short position, positive = long position. @@ -170,7 +172,7 @@ python 3_main.py # Runs backtesting strategies Before submitting changes: 1. ✅ Activate virtual environment 2. ✅ Run `flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics` (must have zero errors) -3. ✅ Run `pytest -v` (all 5 tests must pass) +3. ✅ Run `pytest -v` (all 55 tests must pass) 4. ✅ If modifying core logic, test with sample data if available 5. ✅ Ensure no venv/, data/, or cache files are staged for commit @@ -180,7 +182,7 @@ These instructions have been validated by: - Creating a fresh virtual environment - Installing all dependencies (with setuptools workaround) - Running both flake8 checks (0 errors, expected warnings) -- Running pytest (5/5 tests passed in 0.67s) +- Running pytest (55/55 tests passed in <0.5s) - Exploring all source files and configuration **Only search for additional information if these instructions are incomplete or found to be incorrect.** diff --git a/src/__init__.py b/src/__init__.py index e69de29b..c28b38e2 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -0,0 +1,6 @@ +""" +BYE (Backtesting Yield Estimator) - A Python tool for backtesting option trading strategies. + +This package provides tools for backtesting option trading strategies on historical data, +with a focus on SPY (S&P 500 ETF) put-writing strategies for long-term investment. +""" diff --git a/src/markets.py b/src/markets.py index 614e1f2e..6bf6e87b 100644 --- a/src/markets.py +++ b/src/markets.py @@ -1,3 +1,9 @@ +""" +Historical market data management and trading execution. + +This module provides the HistoricalMarket class which manages market state, +iterates through historical quotes, and executes trades (buy, sell, open, close positions). +""" from collections.abc import Iterator from src.options import Put diff --git a/src/options.py b/src/options.py index ee8a89d1..e8f1e74e 100644 --- a/src/options.py +++ b/src/options.py @@ -1,3 +1,10 @@ +""" +Option contract classes for trading strategies. + +This module provides the abstract Option base class and the Put implementation +for representing option contracts with strike prices, expiration dates, and +methods for determining in-the-money status and intrinsic value. +""" from abc import abstractmethod diff --git a/src/strategies.py b/src/strategies.py index 4edd426c..76bdb5eb 100644 --- a/src/strategies.py +++ b/src/strategies.py @@ -1,3 +1,9 @@ +""" +Trading strategy implementations for backtesting. + +This module provides the abstract Strategy base class and concrete implementations +like SellWeeklyPuts and SellMonthlyPuts for backtesting option trading strategies. +""" from abc import abstractmethod from src.wallet import Wallet diff --git a/src/tests/test_strategies.py b/src/tests/test_strategies.py index 8987c678..b1698c95 100644 --- a/src/tests/test_strategies.py +++ b/src/tests/test_strategies.py @@ -125,8 +125,8 @@ def test_write_put_adds_position(self, quotes_1d_df): assert len(strategy.wallet.positions) == 1 assert strategy.wallet.cash > initial_cash # Received premium - def test_get_ideal_dte_monday(self, quotes_1d_df): - """Test ideal DTE calculation on Monday""" + def test_get_ideal_dte_wednesday(self, quotes_1d_df): + """Test ideal DTE calculation on Wednesday""" market = HistoricalMarket(quotes_df=quotes_1d_df) strategy = SellWeeklyPuts(market) next(market) diff --git a/src/wallet.py b/src/wallet.py index e4b05d6b..c4e47c0c 100644 --- a/src/wallet.py +++ b/src/wallet.py @@ -1,3 +1,11 @@ +""" +Position and wallet management for tracking trades and capital. + +This module provides the Position class for representing individual trades +and the Wallet class for managing cash and tracking open/closed positions. +""" + + class Position: def __init__(self, option, quantity, cost): self.option = option